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 16 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
7 changes: 7 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ 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
# expose postgres on localhost for dev
# POSTGRES_EXPOSE=127.0.0.1:5432
2 changes: 2 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: 3.11
- uses: pre-commit/[email protected]
48 changes: 13 additions & 35 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
176 changes: 174 additions & 2 deletions app/api.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import hashlib
from datetime import datetime
from enum import Enum, auto
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 +45,168 @@ 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 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:
device_id = hashlib.sha1(str(request.client.host).encode()).hexdigest()
return device_id


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


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 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")
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 or image flagged")
user_id: str = Field(..., description="User 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 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)


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, request: Request) -> 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)
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


# 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:
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):
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:
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:
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:
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")
62 changes: 62 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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

)


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:
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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
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