Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add new routes and features for ticket and flag management (CRUD api) #15

Merged
merged 18 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ SENTRY_DNS=

# Log level to use, DEBUG by default in dev
LOG_LEVEL=DEBUG

POSTGRES_HOST=postgres
POSTGRES_DB=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_EXPOSE=127.0.0.1:5432
Valimp marked this conversation as resolved.
Show resolved Hide resolved
154 changes: 152 additions & 2 deletions app/api.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
from datetime import datetime
from enum import Enum
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates
from openfoodfacts import Flavor
from openfoodfacts.utils import get_logger
from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict
from pydantic import BaseModel, Field

from app.config import settings
from app.models import FlagModel, TicketModel, db
from app.utils import init_sentry

logger = get_logger(level=settings.log_level.to_int())


app = FastAPI(
title="nutripatrol",
contact={
Expand Down Expand Up @@ -38,3 +44,147 @@ def main_page(request: Request):
@app.get("/robots.txt", response_class=PlainTextResponse)
def robots_txt():
return """User-agent: *\nDisallow: /"""


@app.middleware("http")
async def catch_exceptions(request: Request, call_next):
try:
return await call_next(request)
except DoesNotExist:
raise HTTPException(status_code=404, detail="Not found")
except Exception as e:
logger.exception(e)
raise HTTPException(status_code=500, detail="Internal server error")


class TicketStatus(str, Enum):
open = "open"
closed = "closed"


class TicketCreate(BaseModel):
barcode: str = Field(..., description="Barcode of the product")
type: str = Field(..., description="Type of the issue")
Valimp marked this conversation as resolved.
Show resolved Hide resolved
url: str = Field(..., description="URL of the product")
status: TicketStatus = Field(..., description="Status of the ticket")
image_id: str = Field(..., description="Image ID of the product")
Valimp marked this conversation as resolved.
Show resolved Hide resolved
flavour: Flavor = Field(..., description="Flavour of the product")
Valimp marked this conversation as resolved.
Show resolved Hide resolved
created_at: datetime = Field(default_factory=datetime.utcnow)


class Ticket(TicketCreate):
id: int = Field(..., description="ID of the ticket")


class FlagCreate(BaseModel):
barcode: str = Field(..., description="Barcode of the product")
type: str = Field(..., description="Type of the issue")
url: str = Field(..., description="URL of the product")
Valimp marked this conversation as resolved.
Show resolved Hide resolved
user_id: str = Field(..., description="User ID of the flagger")
Valimp marked this conversation as resolved.
Show resolved Hide resolved
device_id: str = Field(..., description="Device ID of the flagger")
Valimp marked this conversation as resolved.
Show resolved Hide resolved
source: str = Field(..., description="Source of the flag")
confidence: float = Field(
...,
description="Confidence of the flag, it's a machine learning confidence score. It's a float between 0 and 1 and it's optional.",
)
image_id: str = Field(..., description="Image ID of the product")
flavour: Flavor = Field(..., description="Flavour of the product")
reason: str = Field(..., description="Reason of the flag")
comment: str = Field(..., description="Comment of the flag")
created_at: datetime = Field(default_factory=datetime.utcnow)


class Flag(FlagCreate):
id: int = Field(..., description="ID of the flag")
ticket_id: int = Field(..., description="ID of the ticket associated with the flag")


# Create a flag (one to one relationship)
@app.post("/flags")
def create_flag(flag: FlagCreate) -> Flag:
with db:
# Search for existing ticket
# With the same barcode, url, type and flavour
ticket = TicketModel.get_or_none(
TicketModel.barcode == flag.barcode,
TicketModel.url == flag.url,
TicketModel.type == flag.type,
TicketModel.flavour == flag.flavour,
)
# If no ticket found, create a new one
if ticket is None:
newTicket = TicketCreate(
barcode=flag.barcode,
url=flag.url,
type=flag.type,
flavour=flag.flavour,
status="open",
image_id=flag.image_id,
)
ticket = _create_ticket(newTicket)
new_flag = FlagModel.create(**flag.model_dump())
# Associate the flag with the ticket
new_flag.ticket_id = ticket.id
new_flag.save()
return new_flag


# Get all flags (one to many relationship)
@app.get("/flags")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's okay for a first version, but we should quickly add pagination

def get_flags():
with db:
flags = FlagModel.select()
return [model_to_dict(flag) for flag in flags]


# Get flag by ID (one to one relationship)
@app.get("/flags/{flag_id}")
def get_flag(flag_id: int):
with db:
flag = FlagModel.get_by_id(flag_id)
return flag


def _create_ticket(ticket: TicketCreate):
return TicketModel.create(**ticket.model_dump())


# Create a ticket (one to one relationship)
@app.post("/tickets")
def create_ticket(ticket: TicketCreate) -> Ticket:
with db:
return _create_ticket(ticket)


# Get all tickets (one to many relationship)
@app.get("/tickets")
def get_tickets():
with db:
tickets = TicketModel.select()
return [model_to_dict(ticket) for ticket in tickets]


# Get ticket by id (one to one relationship)
@app.get("/tickets/{ticket_id}")
def get_ticket(ticket_id: int):
with db:
ticket = TicketModel.get_by_id(ticket_id)
return ticket


# Get all flags for a ticket by id (one to many relationship)
@app.get("/tickets/{ticket_id}/flags")
def get_flags_by_ticket(ticket_id: int):
with db:
flags = FlagModel.select().where(FlagModel.ticket_id == ticket_id)
return [model_to_dict(flag) for flag in flags]


# Update ticket status by id with enum : open, closed (soft delete)
@app.put("/tickets/{ticket_id}/status")
def update_ticket_status(ticket_id: int, status: TicketStatus):
with db:
ticket = TicketModel.get_by_id(ticket_id)
ticket.status = status
ticket.save()
return {"message": f"Ticket with ID {ticket_id} has been updated"}
63 changes: 63 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from peewee import (
CharField,
DateTimeField,
FloatField,
ForeignKeyField,
IntegerField,
Model,
PostgresqlDatabase,
)

db = PostgresqlDatabase(
"postgres", user="postgres", password="postgres", host="postgres", port=5432
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use envvar instead of hardcoded values, you can add declare them in config.Settings. Make sure they are defined in docker-compose.yml (in the environment section), otherwise docker doesn't pass the envvar to the container

)


# Définissez vos modèles de table
Valimp marked this conversation as resolved.
Show resolved Hide resolved
class TicketModel(Model):
id = IntegerField(primary_key=True)
barcode = CharField()
type = CharField(null=False)
url = CharField()
status = CharField(null=False)
image_id = CharField()
flavour = CharField(null=False)
Valimp marked this conversation as resolved.
Show resolved Hide resolved
created_at = DateTimeField(null=False)

class Meta:
database = db
table_name = "tickets"


class ModeratorActionModel(Model):
id = IntegerField(primary_key=True)
action_type = CharField()
moderator_id = IntegerField()
user_id = IntegerField()
ticket = ForeignKeyField(TicketModel, backref="moderator_actions")
created_at = DateTimeField(null=False)

class Meta:
database = db
table_name = "moderator_actions"


class FlagModel(Model):
id = IntegerField(primary_key=True)
ticket = ForeignKeyField(TicketModel, backref="flags")
barcode = CharField()
type = CharField(null=False)
url = CharField()
user_id = CharField()
device_id = CharField()
source = CharField()
confidence = FloatField()
image_id = CharField()
flavour = CharField(null=False)
reason = CharField()
comment = CharField(max_length=500)
created_at = DateTimeField(null=False)

class Meta:
database = db
table_name = "flags"
17 changes: 16 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,20 @@ services:
ports:
- "${API_PORT}:8000"

postgres:
restart: $RESTART_POLICY
image: postgres:16.0-alpine
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
volumes:
- postgres-data:/var/lib/postgresql/data
command: postgres -c shared_buffers=1024MB -c work_mem=64MB
mem_limit: 4g
shm_size: 1g
ports:
- "${POSTGRES_EXPOSE:-127.0.0.1:5432}:5432"

volumes:
rediscache:
postgres-data:
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ openfoodfacts==0.1.10
requests==2.31.0
pydantic-settings==2.0.3
sentry-sdk[fastapi]==1.31.0
jinja2==3.1.2
jinja2==3.1.2
peewee==3.17.0
psycopg2-binary==2.9.9
Loading