From 6c8040f3beb3ce1ccc3fd43358ef9d925bb6cf1d Mon Sep 17 00:00:00 2001 From: Valimp Date: Wed, 15 Nov 2023 12:45:51 +0100 Subject: [PATCH 01/17] partial CRUD functionnalities for flags --- .env | 6 +++ app/api.py | 102 ++++++++++++++++++++++++++++++++++++++++++++- app/models.py | 60 ++++++++++++++++++++++++++ docker-compose.yml | 17 +++++++- requirements.txt | 4 +- 5 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 app/models.py diff --git a/.env b/.env index 8eed936..5bdc5aa 100644 --- a/.env +++ b/.env @@ -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 \ No newline at end of file diff --git a/app/api.py b/app/api.py index e149bff..2ec8bb3 100644 --- a/app/api.py +++ b/app/api.py @@ -1,11 +1,16 @@ +from datetime import datetime 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.utils import get_logger +from peewee import DoesNotExist +from playhouse.shortcuts import model_to_dict +from pydantic import BaseModel from app.config import settings +from app.models import Flags, db from app.utils import init_sentry logger = get_logger(level=settings.log_level.to_int()) @@ -38,3 +43,98 @@ def main_page(request: Request): @app.get("/robots.txt", response_class=PlainTextResponse) def robots_txt(): return """User-agent: *\nDisallow: /""" + + +# CRUD Flags + + +class FlagCreate(BaseModel): + barcode: str + type: str + url: str + user_id: str + device_id: str + source: str + confidence: float + image_id: str + flavour: str + reason: str + comment: str + created_at: datetime + + +class FlagResponse(BaseModel): + id: int + barcode: str + type: str + url: str + user_id: str + device_id: str + source: str + confidence: float + image_id: str + flavour: str + reason: str + comment: str + created_at: datetime + + +# Create a flag +@app.post("/flags") +async def create_flag(flag: FlagCreate): + with db: + try: + new_flag = await Flags.create(**flag.dict()) + return model_to_dict(new_flag) + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") + + +# Get all flags +@app.get("/flags") +def get_flags(): + with db: + try: + flags = Flags.select() + return [model_to_dict(flag) for flag in flags] + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") + + +# Get flag by ID +@app.get("/flags/{flag_id}") +def get_flag(flag_id: int): + with db: + try: + flag = Flags.get_by_id(flag_id) + return model_to_dict(flag) + except DoesNotExist: + raise HTTPException(status_code=404, detail="Flag not found") + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") + + +# Update a flag +@app.put("/flags/{flag_id}") +async def update_flag(item_id: int): + with db: + try: + return {"message": "Updated"} + except DoesNotExist: + raise HTTPException(status_code=404, detail="Flag not found") + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") + + +# Delete a flag +@app.delete("/flags/{flag_id}") +async def delete_flag(flag_id: int): + with db: + try: + flag = Flags.get_by_id(flag_id) + flag.delete_instance() + return {"message": f"Flag with ID {flag_id} has been deleted"} + except DoesNotExist: + raise HTTPException(status_code=404, detail="Flag not found") + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..2595e76 --- /dev/null +++ b/app/models.py @@ -0,0 +1,60 @@ +from peewee import ( + CharField, + DateTimeField, + FloatField, + ForeignKeyField, + IntegerField, + Model, + PostgresqlDatabase, +) + +db = PostgresqlDatabase( + "postgres", user="postgres", password="postgres", host="postgres", port=5432 +) + + +# Définissez vos modèles de table +class Tickets(Model): + id = IntegerField(primary_key=True) + barcode = CharField() + type = CharField() + url = CharField() + status = CharField() + image_id = CharField() + flavour = CharField() + created_at = DateTimeField() + + class Meta: + database = db + + +class ModeratorActions(Model): + id = IntegerField(primary_key=True) + action_type = CharField() + moderator_id = IntegerField() + user_id = IntegerField() + ticket_id = ForeignKeyField(Tickets, backref="moderator_actions") + created_at = DateTimeField() + + class Meta: + database = db + + +class Flags(Model): + id = IntegerField(primary_key=True) + ticket_id = ForeignKeyField(Tickets, backref="flags") + barcode = CharField() + type = CharField() + url = CharField() + user_id = CharField() + device_id = CharField() + source = CharField() + confidence = FloatField() + image_id = CharField() + flavour = CharField() + reason = CharField() + comment = CharField(max_length=500) + created_at = DateTimeField() + + class Meta: + database = db diff --git a/docker-compose.yml b/docker-compose.yml index 8393e86..d57051a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 49ba13e..1aff57f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +jinja2==3.1.2 +peewee==3.17.0 +psycopg2-binary==2.9.9 \ No newline at end of file From c36ac171ccb4cc22d3e43a4f5fc6568cec743150 Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 16 Nov 2023 12:07:40 +0100 Subject: [PATCH 02/17] flag routes --- app/api.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/app/api.py b/app/api.py index 2ec8bb3..4afa164 100644 --- a/app/api.py +++ b/app/api.py @@ -7,7 +7,7 @@ from openfoodfacts.utils import get_logger from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.config import settings from app.models import Flags, db @@ -60,7 +60,7 @@ class FlagCreate(BaseModel): flavour: str reason: str comment: str - created_at: datetime + created_at: datetime = Field(default_factory=datetime.utcnow) class FlagResponse(BaseModel): @@ -79,12 +79,26 @@ class FlagResponse(BaseModel): created_at: datetime +class FlagsUpdate(BaseModel): + barcode: str + type: str + url: str + user_id: str + device_id: str + source: str + confidence: float + image_id: str + flavour: str + reason: str + comment: str + + # Create a flag @app.post("/flags") -async def create_flag(flag: FlagCreate): +def create_flag(flag: FlagCreate): with db: try: - new_flag = await Flags.create(**flag.dict()) + new_flag = Flags.create(**flag.dict()) return model_to_dict(new_flag) except Exception as error: raise HTTPException(status_code=500, detail=f"{error}") @@ -115,11 +129,14 @@ def get_flag(flag_id: int): # Update a flag -@app.put("/flags/{flag_id}") -async def update_flag(item_id: int): +@app.patch("/flags/{flag_id}") +async def update_flag(flag_id: int, updated_data: FlagsUpdate): with db: try: - return {"message": "Updated"} + flag = Flags.get_by_id(flag_id) + Flags.update(**updated_data.dict()) + flag.save() + return {"message": f"Flag with id {flag_id} updated successfully"} except DoesNotExist: raise HTTPException(status_code=404, detail="Flag not found") except Exception as error: From d2181151132310967124e9766c082c646334bb9c Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 16 Nov 2023 12:08:44 +0100 Subject: [PATCH 03/17] delete update flag --- app/api.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/app/api.py b/app/api.py index 4afa164..f98afeb 100644 --- a/app/api.py +++ b/app/api.py @@ -128,21 +128,6 @@ def get_flag(flag_id: int): raise HTTPException(status_code=500, detail=f"{error}") -# Update a flag -@app.patch("/flags/{flag_id}") -async def update_flag(flag_id: int, updated_data: FlagsUpdate): - with db: - try: - flag = Flags.get_by_id(flag_id) - Flags.update(**updated_data.dict()) - flag.save() - return {"message": f"Flag with id {flag_id} updated successfully"} - except DoesNotExist: - raise HTTPException(status_code=404, detail="Flag not found") - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") - - # Delete a flag @app.delete("/flags/{flag_id}") async def delete_flag(flag_id: int): From f92ba89c509ac1c0fa0855a49c9c741e7be5520a Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 16 Nov 2023 14:11:08 +0100 Subject: [PATCH 04/17] crud for tickets --- app/api.py | 94 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/app/api.py b/app/api.py index f98afeb..6c5f4ee 100644 --- a/app/api.py +++ b/app/api.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field from app.config import settings -from app.models import Flags, db +from app.models import Flags, Tickets, db from app.utils import init_sentry logger = get_logger(level=settings.log_level.to_int()) @@ -63,36 +63,6 @@ class FlagCreate(BaseModel): created_at: datetime = Field(default_factory=datetime.utcnow) -class FlagResponse(BaseModel): - id: int - barcode: str - type: str - url: str - user_id: str - device_id: str - source: str - confidence: float - image_id: str - flavour: str - reason: str - comment: str - created_at: datetime - - -class FlagsUpdate(BaseModel): - barcode: str - type: str - url: str - user_id: str - device_id: str - source: str - confidence: float - image_id: str - flavour: str - reason: str - comment: str - - # Create a flag @app.post("/flags") def create_flag(flag: FlagCreate): @@ -140,3 +110,65 @@ async def delete_flag(flag_id: int): raise HTTPException(status_code=404, detail="Flag not found") except Exception as error: raise HTTPException(status_code=500, detail=f"{error}") + + +# CRUD Tickets + + +class TicketCreate(BaseModel): + barcode: str + type: str + url: str + status: str + image_id: str + flavour: str + created_at: datetime = Field(default_factory=datetime.utcnow) + + +# Create a ticket +@app.post("/tickets") +def create_ticket(ticket: TicketCreate): + with db: + try: + new_ticket = Tickets.create(**ticket.dict()) + return model_to_dict(new_ticket) + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") + + +# Get all tickets +@app.get("/tickets") +def get_tickets(): + with db: + try: + tickets = Tickets.select() + return [model_to_dict(ticket) for ticket in tickets] + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") + + +# Get ticket by id +@app.get("/tickets/{ticket_id}") +def get_ticket(ticket_id: int): + with db: + try: + ticket = Tickets.get_by_id(ticket_id) + return model_to_dict(ticket) + except DoesNotExist: + raise HTTPException(status_code=404, detail="Flag not found") + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") + + +# Delete ticket by id +@app.delete("/tickets/{ticket_id}") +def delete_ticket(ticket_id: int): + with db: + try: + flag = Tickets.get_by_id(ticket_id) + flag.delete_instance() + return {"message": f"Flag with ID {ticket_id} has been deleted"} + except DoesNotExist: + raise HTTPException(status_code=404, detail="Flag not found") + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") From 1e202dab7b1d1bab7f5b4207ca935b96ef234b8c Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 30 Nov 2023 11:51:26 +0100 Subject: [PATCH 05/17] api routes for flags and tickets --- app/api.py | 155 +++++++++++++++++++++++++++++++++++--------------- app/models.py | 13 +++-- 2 files changed, 117 insertions(+), 51 deletions(-) diff --git a/app/api.py b/app/api.py index 6c5f4ee..9ca36a0 100644 --- a/app/api.py +++ b/app/api.py @@ -4,13 +4,14 @@ 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 Flags, Tickets, db +from app.models import FlagModel, TicketModel, db from app.utils import init_sentry logger = get_logger(level=settings.log_level.to_int()) @@ -48,62 +49,103 @@ def robots_txt(): # CRUD Flags +class TicketCreate(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") + status: str = Field(..., description="Status of the ticket") + image_id: str = Field(..., description="Image ID of the product") + flavour: Flavor = Field(..., description="Flavour of the product") + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class Ticket(TicketCreate): + id: int = Field(..., description="ID of the ticket") + + class FlagCreate(BaseModel): - barcode: str - type: str - url: str - user_id: str - device_id: str - source: str - confidence: float - image_id: str - flavour: str - reason: str - comment: str + barcode: str = Field(..., description="Barcode of the product") + type: str = Field(..., description="Type of the issue") + url: str = Field(..., description="URL of the product") + user_id: str = Field(..., description="User ID of the flagger") + device_id: str = Field(..., description="Device ID of the flagger") + source: str = Field(..., description="Source of the flag") + confidence: float = Field(..., description="Confidence of the flag") + 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) -# Create a flag +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): +def create_flag(flag: FlagCreate) -> Flag: with db: try: - new_flag = Flags.create(**flag.dict()) - return model_to_dict(new_flag) + # 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 except Exception as error: raise HTTPException(status_code=500, detail=f"{error}") -# Get all flags +# Get all flags (one to many relationship) @app.get("/flags") def get_flags(): with db: try: - flags = Flags.select() + flags = FlagModel.select() return [model_to_dict(flag) for flag in flags] except Exception as error: raise HTTPException(status_code=500, detail=f"{error}") -# Get flag by ID +# Get flag by ID (one to one relationship) @app.get("/flags/{flag_id}") def get_flag(flag_id: int): with db: try: - flag = Flags.get_by_id(flag_id) - return model_to_dict(flag) + flag = FlagModel.get_by_id(flag_id) + return flag except DoesNotExist: raise HTTPException(status_code=404, detail="Flag not found") except Exception as error: raise HTTPException(status_code=500, detail=f"{error}") -# Delete a flag +# Delete a flag by ID (hard delete) @app.delete("/flags/{flag_id}") -async def delete_flag(flag_id: int): +def delete_flag(flag_id: int): with db: try: - flag = Flags.get_by_id(flag_id) + flag = FlagModel.get_by_id(flag_id) flag.delete_instance() return {"message": f"Flag with ID {flag_id} has been deleted"} except DoesNotExist: @@ -115,60 +157,81 @@ async def delete_flag(flag_id: int): # CRUD Tickets -class TicketCreate(BaseModel): - barcode: str - type: str - url: str - status: str - image_id: str - flavour: str - created_at: datetime = Field(default_factory=datetime.utcnow) +def _create_ticket(ticket: TicketCreate): + return TicketModel.create(**ticket.model_dump()) -# Create a ticket +# Create a ticket (one to one relationship) @app.post("/tickets") -def create_ticket(ticket: TicketCreate): +def create_ticket(ticket: TicketCreate) -> Ticket: with db: - try: - new_ticket = Tickets.create(**ticket.dict()) - return model_to_dict(new_ticket) - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") + return _create_ticket(ticket) -# Get all tickets +# Get all tickets (one to many relationship) @app.get("/tickets") def get_tickets(): with db: try: - tickets = Tickets.select() + tickets = TicketModel.select() return [model_to_dict(ticket) for ticket in tickets] except Exception as error: raise HTTPException(status_code=500, detail=f"{error}") -# Get ticket by id +# Get ticket by id (one to one relationship) @app.get("/tickets/{ticket_id}") def get_ticket(ticket_id: int): with db: try: - ticket = Tickets.get_by_id(ticket_id) - return model_to_dict(ticket) + ticket = TicketModel.get_by_id(ticket_id) + return ticket except DoesNotExist: raise HTTPException(status_code=404, detail="Flag not found") except Exception as error: raise HTTPException(status_code=500, detail=f"{error}") -# Delete ticket by id +# Delete ticket by id (hard delete) @app.delete("/tickets/{ticket_id}") def delete_ticket(ticket_id: int): with db: try: - flag = Tickets.get_by_id(ticket_id) + flag = TicketModel.get_by_id(ticket_id) flag.delete_instance() return {"message": f"Flag with ID {ticket_id} has been deleted"} except DoesNotExist: raise HTTPException(status_code=404, detail="Flag not found") except Exception as error: raise HTTPException(status_code=500, detail=f"{error}") + + +# 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: + try: + flags = FlagModel.select().where(FlagModel.ticket_id == ticket_id) + return [model_to_dict(flag) for flag in flags] + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") + + +# 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: str): + if status not in ["open", "closed"]: + raise HTTPException( + status_code=400, + detail="Status must be one of the following : open, closed", + ) + with db: + try: + ticket = TicketModel.get_by_id(ticket_id) + ticket.status = status + ticket.save() + return {"message": f"Ticket with ID {ticket_id} has been updated"} + except DoesNotExist: + raise HTTPException(status_code=404, detail="Ticket not found") + except Exception as error: + raise HTTPException(status_code=500, detail=f"{error}") diff --git a/app/models.py b/app/models.py index 2595e76..6beaa6a 100644 --- a/app/models.py +++ b/app/models.py @@ -14,7 +14,7 @@ # Définissez vos modèles de table -class Tickets(Model): +class TicketModel(Model): id = IntegerField(primary_key=True) barcode = CharField() type = CharField() @@ -26,23 +26,25 @@ class Tickets(Model): class Meta: database = db + table_name = "tickets" -class ModeratorActions(Model): +class ModeratorActionModel(Model): id = IntegerField(primary_key=True) action_type = CharField() moderator_id = IntegerField() user_id = IntegerField() - ticket_id = ForeignKeyField(Tickets, backref="moderator_actions") + ticket_id = ForeignKeyField(TicketModel, backref="moderator_actions") created_at = DateTimeField() class Meta: database = db + table_name = "moderator_actions" -class Flags(Model): +class FlagModel(Model): id = IntegerField(primary_key=True) - ticket_id = ForeignKeyField(Tickets, backref="flags") + ticket = ForeignKeyField(TicketModel, backref="flags") barcode = CharField() type = CharField() url = CharField() @@ -58,3 +60,4 @@ class Flags(Model): class Meta: database = db + table_name = "flags" From ca884711f1acc78de7cee98b65573ad39b55036d Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 30 Nov 2023 14:38:20 +0100 Subject: [PATCH 06/17] Add non-null fields --- app/models.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/models.py b/app/models.py index 6beaa6a..51414db 100644 --- a/app/models.py +++ b/app/models.py @@ -17,12 +17,12 @@ class TicketModel(Model): id = IntegerField(primary_key=True) barcode = CharField() - type = CharField() + type = CharField(null=False) url = CharField() - status = CharField() + status = CharField(null=False) image_id = CharField() - flavour = CharField() - created_at = DateTimeField() + flavour = CharField(null=False) + created_at = DateTimeField(null=False) class Meta: database = db @@ -34,8 +34,8 @@ class ModeratorActionModel(Model): action_type = CharField() moderator_id = IntegerField() user_id = IntegerField() - ticket_id = ForeignKeyField(TicketModel, backref="moderator_actions") - created_at = DateTimeField() + ticket = ForeignKeyField(TicketModel, backref="moderator_actions") + created_at = DateTimeField(null=False) class Meta: database = db @@ -46,17 +46,17 @@ class FlagModel(Model): id = IntegerField(primary_key=True) ticket = ForeignKeyField(TicketModel, backref="flags") barcode = CharField() - type = CharField() + type = CharField(null=False) url = CharField() user_id = CharField() device_id = CharField() source = CharField() confidence = FloatField() image_id = CharField() - flavour = CharField() + flavour = CharField(null=False) reason = CharField() comment = CharField(max_length=500) - created_at = DateTimeField() + created_at = DateTimeField(null=False) class Meta: database = db From 196378ff59102f70e58924b3cc790437c4820994 Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 30 Nov 2023 14:42:29 +0100 Subject: [PATCH 07/17] remove delete routes --- app/api.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/app/api.py b/app/api.py index 9ca36a0..88d1b89 100644 --- a/app/api.py +++ b/app/api.py @@ -46,9 +46,6 @@ def robots_txt(): return """User-agent: *\nDisallow: /""" -# CRUD Flags - - class TicketCreate(BaseModel): barcode: str = Field(..., description="Barcode of the product") type: str = Field(..., description="Type of the issue") @@ -140,23 +137,6 @@ def get_flag(flag_id: int): raise HTTPException(status_code=500, detail=f"{error}") -# Delete a flag by ID (hard delete) -@app.delete("/flags/{flag_id}") -def delete_flag(flag_id: int): - with db: - try: - flag = FlagModel.get_by_id(flag_id) - flag.delete_instance() - return {"message": f"Flag with ID {flag_id} has been deleted"} - except DoesNotExist: - raise HTTPException(status_code=404, detail="Flag not found") - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") - - -# CRUD Tickets - - def _create_ticket(ticket: TicketCreate): return TicketModel.create(**ticket.model_dump()) @@ -192,20 +172,6 @@ def get_ticket(ticket_id: int): raise HTTPException(status_code=500, detail=f"{error}") -# Delete ticket by id (hard delete) -@app.delete("/tickets/{ticket_id}") -def delete_ticket(ticket_id: int): - with db: - try: - flag = TicketModel.get_by_id(ticket_id) - flag.delete_instance() - return {"message": f"Flag with ID {ticket_id} has been deleted"} - except DoesNotExist: - raise HTTPException(status_code=404, detail="Flag not found") - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") - - # 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): From 98f82716f3d10b269cd74c88ab749b1b7f7e8484 Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 30 Nov 2023 14:46:10 +0100 Subject: [PATCH 08/17] remake update ticket status --- app/api.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/api.py b/app/api.py index 88d1b89..d29964f 100644 --- a/app/api.py +++ b/app/api.py @@ -1,4 +1,5 @@ from datetime import datetime +from enum import Enum from pathlib import Path from fastapi import FastAPI, HTTPException, Request @@ -184,13 +185,13 @@ def get_flags_by_ticket(ticket_id: int): # Update ticket status by id with enum : open, closed (soft delete) +class TicketStatus(str, Enum): + open = "open" + closed = "closed" + + @app.put("/tickets/{ticket_id}/status") -def update_ticket_status(ticket_id: int, status: str): - if status not in ["open", "closed"]: - raise HTTPException( - status_code=400, - detail="Status must be one of the following : open, closed", - ) +def update_ticket_status(ticket_id: int, status: TicketStatus): with db: try: ticket = TicketModel.get_by_id(ticket_id) From 349f68c90a534f0c8cbc91edf43c9628d83b3047 Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 30 Nov 2023 15:17:45 +0100 Subject: [PATCH 09/17] add middleware to raise HTTPExceptions --- app/api.py | 130 ++++++++++++++++++++++++----------------------------- 1 file changed, 58 insertions(+), 72 deletions(-) diff --git a/app/api.py b/app/api.py index d29964f..f1440a9 100644 --- a/app/api.py +++ b/app/api.py @@ -17,7 +17,6 @@ logger = get_logger(level=settings.log_level.to_int()) - app = FastAPI( title="nutripatrol", contact={ @@ -47,11 +46,27 @@ 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") url: str = Field(..., description="URL of the product") - status: str = Field(..., description="Status of the ticket") + status: TicketStatus = Field(..., description="Status of the ticket") image_id: str = Field(..., description="Image ID of the product") flavour: Flavor = Field(..., description="Flavour of the product") created_at: datetime = Field(default_factory=datetime.utcnow) @@ -68,7 +83,10 @@ class FlagCreate(BaseModel): user_id: str = Field(..., description="User ID of the flagger") device_id: str = Field(..., description="Device ID of the flagger") source: str = Field(..., description="Source of the flag") - confidence: float = Field(..., description="Confidence 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") @@ -85,57 +103,46 @@ class Flag(FlagCreate): @app.post("/flags") def create_flag(flag: FlagCreate) -> Flag: with db: - try: - # 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, + # 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, ) - # 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 - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") + 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") def get_flags(): with db: - try: - flags = FlagModel.select() - return [model_to_dict(flag) for flag in flags] - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") + 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: - try: - flag = FlagModel.get_by_id(flag_id) - return flag - except DoesNotExist: - raise HTTPException(status_code=404, detail="Flag not found") - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") + flag = FlagModel.get_by_id(flag_id) + return flag def _create_ticket(ticket: TicketCreate): @@ -153,52 +160,31 @@ def create_ticket(ticket: TicketCreate) -> Ticket: @app.get("/tickets") def get_tickets(): with db: - try: - tickets = TicketModel.select() - return [model_to_dict(ticket) for ticket in tickets] - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") + 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: - try: - ticket = TicketModel.get_by_id(ticket_id) - return ticket - except DoesNotExist: - raise HTTPException(status_code=404, detail="Flag not found") - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") + 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: - try: - flags = FlagModel.select().where(FlagModel.ticket_id == ticket_id) - return [model_to_dict(flag) for flag in flags] - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") + 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) -class TicketStatus(str, Enum): - open = "open" - closed = "closed" - - @app.put("/tickets/{ticket_id}/status") def update_ticket_status(ticket_id: int, status: TicketStatus): with db: - try: - ticket = TicketModel.get_by_id(ticket_id) - ticket.status = status - ticket.save() - return {"message": f"Ticket with ID {ticket_id} has been updated"} - except DoesNotExist: - raise HTTPException(status_code=404, detail="Ticket not found") - except Exception as error: - raise HTTPException(status_code=500, detail=f"{error}") + ticket = TicketModel.get_by_id(ticket_id) + ticket.status = status + ticket.save() + return {"message": f"Ticket with ID {ticket_id} has been updated"} From 97b1c22414a0c6f83db8231b6fdd4673709d836c Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 7 Dec 2023 09:32:25 +0100 Subject: [PATCH 10/17] fix config issues --- .env | 3 ++- app/api.py | 14 +++++++------- app/models.py | 1 - 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.env b/.env index 5bdc5aa..b3aa137 100644 --- a/.env +++ b/.env @@ -20,4 +20,5 @@ POSTGRES_HOST=postgres POSTGRES_DB=postgres POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres -POSTGRES_EXPOSE=127.0.0.1:5432 \ No newline at end of file +# expose postgres on localhost for dev +# POSTGRES_EXPOSE=127.0.0.1:5432 \ No newline at end of file diff --git a/app/api.py b/app/api.py index f1440a9..591bf94 100644 --- a/app/api.py +++ b/app/api.py @@ -1,5 +1,5 @@ from datetime import datetime -from enum import Enum +from enum import Enum, auto from pathlib import Path from fastapi import FastAPI, HTTPException, Request @@ -58,16 +58,16 @@ async def catch_exceptions(request: Request, call_next): class TicketStatus(str, Enum): - open = "open" - closed = "closed" + open = auto() + closed = auto() class TicketCreate(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") + url: str = Field(..., description="URL of the product, only for search issues") status: TicketStatus = Field(..., description="Status of the ticket") - image_id: str = Field(..., description="Image ID of the product") + image_id: str = Field(..., description="ID of the flagged image") flavour: Flavor = Field(..., description="Flavour of the product") created_at: datetime = Field(default_factory=datetime.utcnow) @@ -79,7 +79,7 @@ class Ticket(TicketCreate): 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") + url: str = Field(..., description="URL of the product, only for search issues") user_id: str = Field(..., description="User ID of the flagger") device_id: str = Field(..., description="Device ID of the flagger") source: str = Field(..., description="Source of the flag") @@ -87,7 +87,7 @@ class FlagCreate(BaseModel): ..., 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") + image_id: str = Field(..., description="Image ID of the flagged image") flavour: Flavor = Field(..., description="Flavour of the product") reason: str = Field(..., description="Reason of the flag") comment: str = Field(..., description="Comment of the flag") diff --git a/app/models.py b/app/models.py index 51414db..e55f917 100644 --- a/app/models.py +++ b/app/models.py @@ -13,7 +13,6 @@ ) -# Définissez vos modèles de table class TicketModel(Model): id = IntegerField(primary_key=True) barcode = CharField() From 0235ec5f5798c6fccbae1af313c5708b5cf08491 Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 7 Dec 2023 09:43:54 +0100 Subject: [PATCH 11/17] DoesNotExist exception --- app/api.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/api.py b/app/api.py index 591bf94..7774d81 100644 --- a/app/api.py +++ b/app/api.py @@ -50,8 +50,6 @@ def robots_txt(): 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") @@ -141,8 +139,11 @@ def get_flags(): @app.get("/flags/{flag_id}") def get_flag(flag_id: int): with db: - flag = FlagModel.get_by_id(flag_id) - return flag + try: + flag = FlagModel.get_by_id(flag_id) + return flag + except DoesNotExist: + raise HTTPException(status_code=404, detail="Not found") def _create_ticket(ticket: TicketCreate): @@ -168,23 +169,32 @@ def get_tickets(): @app.get("/tickets/{ticket_id}") def get_ticket(ticket_id: int): with db: - ticket = TicketModel.get_by_id(ticket_id) - return ticket + try: + ticket = TicketModel.get_by_id(ticket_id) + return ticket + except DoesNotExist: + raise HTTPException(status_code=404, detail="Not found") # 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] + try: + flags = FlagModel.select().where(FlagModel.ticket_id == ticket_id) + return [model_to_dict(flag) for flag in flags] + except DoesNotExist: + raise HTTPException(status_code=404, detail="Not found") # 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"} + try: + ticket = TicketModel.get_by_id(ticket_id) + ticket.status = status + ticket.save() + return {"message": f"Ticket with ID {ticket_id} has been updated"} + except DoesNotExist: + raise HTTPException(status_code=404, detail="Not found") From f04a72cf8801ce9e07f6e3543bc7f55abd4996c7 Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 7 Dec 2023 10:58:59 +0100 Subject: [PATCH 12/17] anonymise device_id --- app/api.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/api.py b/app/api.py index 7774d81..975ebf5 100644 --- a/app/api.py +++ b/app/api.py @@ -1,3 +1,4 @@ +import hashlib from datetime import datetime from enum import Enum, auto from pathlib import Path @@ -55,6 +56,13 @@ async def catch_exceptions(request: Request, call_next): raise HTTPException(status_code=500, detail="Internal server error") +def _get_device_id(request: Request): + device_id = request.query_params.get("device_id") + if device_id is None: + device_id = hashlib.sha1(str(request.client.host).encode()).hexdigest() + return device_id + + class TicketStatus(str, Enum): open = auto() closed = auto() @@ -79,7 +87,6 @@ class FlagCreate(BaseModel): type: str = Field(..., description="Type of the issue") url: str = Field(..., description="URL of the product, only for search issues") user_id: str = Field(..., description="User ID of the flagger") - device_id: str = Field(..., description="Device ID of the flagger") source: str = Field(..., description="Source of the flag") confidence: float = Field( ..., @@ -95,11 +102,12 @@ class FlagCreate(BaseModel): class Flag(FlagCreate): id: int = Field(..., description="ID of the flag") ticket_id: int = Field(..., description="ID of the ticket associated with the flag") + device_id: str = Field(..., description="Device ID of the flagger") # Create a flag (one to one relationship) @app.post("/flags") -def create_flag(flag: FlagCreate) -> Flag: +def create_flag(flag: FlagCreate, request: Request) -> Flag: with db: # Search for existing ticket # With the same barcode, url, type and flavour @@ -120,7 +128,11 @@ def create_flag(flag: FlagCreate) -> Flag: image_id=flag.image_id, ) ticket = _create_ticket(newTicket) + device_id = _get_device_id(request) + logger.info(f"Device ID: {device_id}") new_flag = FlagModel.create(**flag.model_dump()) + # Associate the flag with the device + new_flag.device_id = device_id # Associate the flag with the ticket new_flag.ticket_id = ticket.id new_flag.save() From 8060140afef75373d1526deb7c8ca68c442651d0 Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 7 Dec 2023 11:04:50 +0100 Subject: [PATCH 13/17] url description --- app/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api.py b/app/api.py index 975ebf5..20f8e69 100644 --- a/app/api.py +++ b/app/api.py @@ -71,7 +71,7 @@ class TicketStatus(str, Enum): class TicketCreate(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, only for search issues") + url: str = Field(..., description="URL of the product or image flagged") status: TicketStatus = Field(..., description="Status of the ticket") image_id: str = Field(..., description="ID of the flagged image") flavour: Flavor = Field(..., description="Flavour of the product") @@ -85,7 +85,7 @@ class Ticket(TicketCreate): 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, only for search issues") + url: str = Field(..., description="URL of the product or image flagged") user_id: str = Field(..., description="User ID of the flagger") source: str = Field(..., description="Source of the flag") confidence: float = Field( From 1d19e081c276080e40499c4c122557e73f23d4e7 Mon Sep 17 00:00:00 2001 From: Valimp Date: Thu, 7 Dec 2023 11:27:07 +0100 Subject: [PATCH 14/17] fix: fix incorrect imports sorting --- .pre-commit-config.yaml | 48 +++++++++++------------------------------ pyproject.toml | 7 ++++++ 2 files changed, 20 insertions(+), 35 deletions(-) create mode 100644 pyproject.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 736e6a8..fdaa88c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,39 +1,17 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + # Note for all linters: do not forget to update pyproject.toml when updating version. + - repo: https://github.com/ambv/black + rev: 23.11.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: debug-statements - - id: double-quote-string-fixer - - id: name-tests-test - - id: requirements-txt-fixer -- repo: https://github.com/asottile/setup-cfg-fmt - rev: v2.0.0 + - id: black + language_version: python3.11 + + - repo: https://github.com/pycqa/flake8 + rev: 6.1.0 hooks: - - id: setup-cfg-fmt -- repo: https://github.com/asottile/reorder_python_imports - rev: v3.8.2 + - id: flake8 + + - repo: https://github.com/timothycrosley/isort + rev: 5.12.0 hooks: - - id: reorder-python-imports - args: [--py37-plus, --add-import, 'from __future__ import annotations'] -- repo: https://github.com/asottile/add-trailing-comma - rev: v2.2.3 - hooks: - - id: add-trailing-comma - args: [--py36-plus] -- repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v1.6.0 - hooks: - - id: autopep8 -- repo: https://github.com/PyCQA/flake8 - rev: 5.0.2 - hooks: - - id: flake8 - args: [--ignore=E501] -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.971 - hooks: - - id: mypy - additional_dependencies: [types-all] + - id: isort \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..56a5ff5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.isort] # From https://black.readthedocs.io/en/stable/compatible_configs.html#isort +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +line_length = 88 \ No newline at end of file From 26a0b10f605e55ab3a0a46f76d3a802d4e29c5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bournhonesque?= Date: Thu, 7 Dec 2023 11:34:57 +0100 Subject: [PATCH 15/17] chore: fix github action for pre-commit --- .github/workflows/pre-commit.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index ed4198d..5478626 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -10,5 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.11 - uses: pre-commit/action@v3.0.0 From 58cb2d893e6c2547f1447cbde46479de7e606321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bournhonesque?= Date: Thu, 7 Dec 2023 16:45:33 +0100 Subject: [PATCH 16/17] feat: add fixes and features after pull request --- .env | 6 +- app/api.py | 247 +++++++++++++++++++++++++++++++++------------ app/config.py | 7 ++ app/models.py | 46 +++++---- docker-compose.yml | 5 + 5 files changed, 223 insertions(+), 88 deletions(-) diff --git a/.env b/.env index b3aa137..6a9a3cc 100644 --- a/.env +++ b/.env @@ -21,4 +21,8 @@ POSTGRES_DB=postgres POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres # expose postgres on localhost for dev -# POSTGRES_EXPOSE=127.0.0.1:5432 \ No newline at end of file +# POSTGRES_EXPOSE=127.0.0.1:5432 + +# The top-level domain used for Open Food Facts, +# it's either `net` (staging) or `org` (production) +OFF_TLD=net \ No newline at end of file diff --git a/app/api.py b/app/api.py index 20f8e69..9f0bfbb 100644 --- a/app/api.py +++ b/app/api.py @@ -1,16 +1,18 @@ import hashlib from datetime import datetime -from enum import Enum, auto +from enum import StrEnum, auto from pathlib import Path +from typing import Any 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 openfoodfacts.images import generate_image_url +from openfoodfacts.utils import URLBuilder, get_logger from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from app.config import settings from app.models import FlagModel, TicketModel, db @@ -47,15 +49,6 @@ 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 Exception as e: - logger.exception(e) - raise HTTPException(status_code=500, detail="Internal server error") - - def _get_device_id(request: Request): device_id = request.query_params.get("device_id") if device_id is None: @@ -63,40 +56,149 @@ def _get_device_id(request: Request): return device_id -class TicketStatus(str, Enum): +class TicketStatus(StrEnum): open = auto() closed = auto() +class IssueType(StrEnum): + """Type of the flag/ticket.""" + + # Issue about any of the product fields (image excluded), or about the + # product as a whole + product = auto() + # Issue about a product image + image = auto() + # Issue about search results + search = auto() + + class TicketCreate(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 or image flagged") - status: TicketStatus = Field(..., description="Status of the ticket") - image_id: str = Field(..., description="ID of the flagged image") - flavour: Flavor = Field(..., description="Flavour of the product") - created_at: datetime = Field(default_factory=datetime.utcnow) + barcode: str | None = Field( + None, + description="Barcode of the product, if the ticket is about a product or a product image. " + "In case of a search issue, this field is null.", + ) + type: IssueType = Field(..., description="Type of the issue") + url: str = Field(..., description="URL of the product or of the flagged image") + status: TicketStatus = Field(TicketStatus.open, description="Status of the ticket") + image_id: str | None = Field( + None, + description="ID of the flagged image, if the ticket type is `image`", + examples=["1", "front_fr"], + ) + flavor: Flavor = Field( + ..., description="Flavor (project) associated with the ticket" + ) + created_at: datetime = Field( + default_factory=datetime.utcnow, description="Creation datetime of the ticket" + ) class Ticket(TicketCreate): id: int = Field(..., description="ID of the ticket") +class SourceType(StrEnum): + mobile = auto() + web = auto() + robotoff = auto() + + 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 or image flagged") - user_id: str = Field(..., description="User ID of the flagger") - source: str = Field(..., description="Source of the flag") - confidence: float = Field( + barcode: str | None = Field( + None, + description="Barcode of the product, if the flag is about a product or a product image. " + "In case of a search issue, this field is null.", + ) + type: IssueType = Field(..., description="Type of the issue") + url: str = Field(..., description="URL of the product or of the flagged image") + user_id: str = Field(..., description="Open Food Facts User ID of the flagger") + source: SourceType = 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.", + description="Source of the flag. It can be a user from the mobile app, " + "the web or a flag generated automatically by robotoff.", + ) + confidence: float | None = Field( + None, + ge=0, + le=1, + description="Confidence score of the model that generated the flag, " + "this field should only be provided by Robotoff.", + ) + image_id: str | None = Field( + None, + min_length=1, + description="ID of the flagged image", + examples=["1", "front_fr"], + ) + flavor: Flavor = Field( + ..., description="Flavor (project) associated with the ticket" + ) + reason: str | None = Field( + None, + min_length=1, + description="Reason for flagging provided by the user. The field is optional.", ) - image_id: str = Field(..., description="Image ID of the flagged image") - 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) + comment: str | None = Field( + None, + description="Comment provided by the user during flagging. This is a free text field.", + ) + created_at: datetime = Field( + default_factory=datetime.utcnow, description="Creation datetime of the flag" + ) + + @model_validator(mode="after") + def image_id_is_provided_when_type_is_image(self) -> "FlagCreate": + """Validate that `image_id` is provided when flag type is `image`.""" + if self.type is IssueType.image and self.image_id is None: + raise ValueError("`image_id` must be provided when flag type is `image`") + return self + + @model_validator(mode="after") + def barcode_should_not_be_provided_for_search_type(self) -> "FlagCreate": + """Validate that `barcode` is not provided when flag type is + `search`.""" + if self.type is IssueType.search and self.barcode is not None: + raise ValueError( + "`barcode` must not be provided when flag type is `search`" + ) + return self + + @model_validator(mode="before") + @classmethod + def generate_url(cls, data: Any) -> Any: + """Generate a URL for the flag based on the flag type and flavor.""" + if not isinstance(data, dict): + # Let Pydantic handle the validation + return data + flag_type = data.get("type") + flavor = data.get("flavor") + barcode = data.get("barcode") + image_id = data.get("image_id") + + if not flag_type or flavor not in [f.value for f in Flavor]: + # Let Pydantic handle the validation + return data + + flavor_enum = Flavor[flavor] + environment = settings.off_tld + # Set-up a default URL in case validation fails + + if flag_type == "product": + base_url = URLBuilder.world(flavor_enum, environment) + data["url"] = f"{base_url}/product/{barcode}" + elif flag_type == "image": + if image_id: + data["url"] = generate_image_url( + barcode, image_id, flavor_enum, environment + ) + else: + # Set-up a dummy URL in case image_id is not provided + # Pydantic otherwise raises an error + data["url"] = "http://localhost" + + return data class Flag(FlagCreate): @@ -107,44 +209,59 @@ class Flag(FlagCreate): # Create a flag (one to one relationship) @app.post("/flags") -def create_flag(flag: FlagCreate, request: Request) -> Flag: +def create_flag(flag: FlagCreate, request: Request): with db: + # Check if the flag already exists + if ( + FlagModel.get_or_none( + FlagModel.barcode == flag.barcode, + FlagModel.url == flag.url, + FlagModel.type == flag.type, + FlagModel.flavor == flag.flavor, + FlagModel.user_id == flag.user_id, + ) + is not None + ): + raise HTTPException( + status_code=409, + detail="Flag already exists", + ) + # Search for existing ticket - # With the same barcode, url, type and flavour + # With the same barcode, url, type and flavor ticket = TicketModel.get_or_none( TicketModel.barcode == flag.barcode, TicketModel.url == flag.url, TicketModel.type == flag.type, - TicketModel.flavour == flag.flavour, + TicketModel.flavor == flag.flavor, ) # 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( + TicketCreate( + barcode=flag.barcode, + url=flag.url, + type=flag.type, + flavor=flag.flavor, + image_id=flag.image_id, + ) ) - ticket = _create_ticket(newTicket) + elif ticket.status == TicketStatus.closed: + # Reopen the ticket if it was closed + ticket.status = TicketStatus.open + ticket.save() + device_id = _get_device_id(request) - logger.info(f"Device ID: {device_id}") - new_flag = FlagModel.create(**flag.model_dump()) - # Associate the flag with the device - new_flag.device_id = device_id - # Associate the flag with the ticket - new_flag.ticket_id = ticket.id - new_flag.save() - return new_flag + return model_to_dict( + FlagModel.create(ticket=ticket, device_id=device_id, **flag.model_dump()) + ) # Get all flags (one to many relationship) @app.get("/flags") def get_flags(): with db: - flags = FlagModel.select() - return [model_to_dict(flag) for flag in flags] + return {"flags": list(FlagModel.select().dicts().iterator())} # Get flag by ID (one to one relationship) @@ -152,8 +269,7 @@ def get_flags(): def get_flag(flag_id: int): with db: try: - flag = FlagModel.get_by_id(flag_id) - return flag + return FlagModel.get_by_id(flag_id) except DoesNotExist: raise HTTPException(status_code=404, detail="Not found") @@ -173,8 +289,7 @@ def create_ticket(ticket: TicketCreate) -> Ticket: @app.get("/tickets") def get_tickets(): with db: - tickets = TicketModel.select() - return [model_to_dict(ticket) for ticket in tickets] + return {"tickets": list(TicketModel.select().dicts().iterator())} # Get ticket by id (one to one relationship) @@ -182,8 +297,7 @@ def get_tickets(): def get_ticket(ticket_id: int): with db: try: - ticket = TicketModel.get_by_id(ticket_id) - return ticket + return model_to_dict(TicketModel.get_by_id(ticket_id)) except DoesNotExist: raise HTTPException(status_code=404, detail="Not found") @@ -192,11 +306,14 @@ def get_ticket(ticket_id: int): @app.get("/tickets/{ticket_id}/flags") def get_flags_by_ticket(ticket_id: int): with db: - try: - flags = FlagModel.select().where(FlagModel.ticket_id == ticket_id) - return [model_to_dict(flag) for flag in flags] - except DoesNotExist: - raise HTTPException(status_code=404, detail="Not found") + return { + "flags": list( + FlagModel.select() + .where(FlagModel.ticket_id == ticket_id) + .dicts() + .iterator() + ) + } # Update ticket status by id with enum : open, closed (soft delete) @@ -207,6 +324,6 @@ def update_ticket_status(ticket_id: int, status: TicketStatus): ticket = TicketModel.get_by_id(ticket_id) ticket.status = status ticket.save() - return {"message": f"Ticket with ID {ticket_id} has been updated"} + return model_to_dict(ticket) except DoesNotExist: raise HTTPException(status_code=404, detail="Not found") diff --git a/app/config.py b/app/config.py index 7051e60..40a22ce 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,6 @@ from enum import StrEnum +from openfoodfacts import Environment from pydantic_settings import BaseSettings @@ -29,6 +30,12 @@ def to_int(self): class Settings(BaseSettings): sentry_dns: str | None = None log_level: LoggingLevel = LoggingLevel.INFO + postgres_host: str = "localhost" + postgres_db: str = "postgres" + postgres_user: str = "postgres" + postgres_password: str = "postgres" + postgres_port: int = 5432 + off_tld: Environment = Environment.net settings = Settings() diff --git a/app/models.py b/app/models.py index e55f917..882fe15 100644 --- a/app/models.py +++ b/app/models.py @@ -3,25 +3,29 @@ DateTimeField, FloatField, ForeignKeyField, - IntegerField, Model, PostgresqlDatabase, ) +from .config import settings + db = PostgresqlDatabase( - "postgres", user="postgres", password="postgres", host="postgres", port=5432 + settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + host=settings.postgres_host, + port=settings.postgres_port, ) class TicketModel(Model): - id = IntegerField(primary_key=True) - barcode = CharField() - type = CharField(null=False) + barcode = CharField(null=True) + type = CharField() url = CharField() - status = CharField(null=False) - image_id = CharField() - flavour = CharField(null=False) - created_at = DateTimeField(null=False) + status = CharField() + image_id = CharField(null=True) + flavor = CharField() + created_at = DateTimeField() class Meta: database = db @@ -29,12 +33,11 @@ class Meta: class ModeratorActionModel(Model): - id = IntegerField(primary_key=True) action_type = CharField() - moderator_id = IntegerField() - user_id = IntegerField() + moderator_id = CharField() + user_id = CharField() ticket = ForeignKeyField(TicketModel, backref="moderator_actions") - created_at = DateTimeField(null=False) + created_at = DateTimeField() class Meta: database = db @@ -42,20 +45,19 @@ class Meta: class FlagModel(Model): - id = IntegerField(primary_key=True) ticket = ForeignKeyField(TicketModel, backref="flags") - barcode = CharField() - type = CharField(null=False) + barcode = CharField(null=True) + type = CharField() 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) + confidence = FloatField(null=True) + image_id = CharField(null=True) + flavor = CharField() + reason = CharField(null=True) + comment = CharField(max_length=500, null=True) + created_at = DateTimeField() class Meta: database = db diff --git a/docker-compose.yml b/docker-compose.yml index d57051a..8846ce9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,11 @@ x-api-common: &api-common environment: - SENTRY_DNS - LOG_LEVEL + - POSTGRES_USER + - POSTGRES_PASSWORD + - POSTGRES_DB + - POSTGRES_HOST + - OFF_TLD networks: - default From d7be33caf3319bacb7bc0c56211dfb3ba7329e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bournhonesque?= Date: Thu, 7 Dec 2023 17:20:37 +0100 Subject: [PATCH 17/17] fix: delete moderator_id field --- app/api.py | 1 + app/models.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api.py b/app/api.py index 9f0bfbb..dc293cb 100644 --- a/app/api.py +++ b/app/api.py @@ -50,6 +50,7 @@ def robots_txt(): def _get_device_id(request: Request): + """Get the device ID from the request, or generate one if not provided.""" device_id = request.query_params.get("device_id") if device_id is None: device_id = hashlib.sha1(str(request.client.host).encode()).hexdigest() diff --git a/app/models.py b/app/models.py index 882fe15..e4426db 100644 --- a/app/models.py +++ b/app/models.py @@ -34,7 +34,6 @@ class Meta: class ModeratorActionModel(Model): action_type = CharField() - moderator_id = CharField() user_id = CharField() ticket = ForeignKeyField(TicketModel, backref="moderator_actions") created_at = DateTimeField()