From d64c7f45248acdef1ffd35dea9bf4dde90dc96d3 Mon Sep 17 00:00:00 2001 From: Michele Date: Mon, 9 Mar 2026 00:22:12 +0100 Subject: [PATCH] feat(03-01): SwipeService CRUD + Pydantic schemas + FastAPI router - backend/schemas/swipe.py: SwipeItem, SwipeItemCreate, SwipeItemUpdate, SwipeListResponse - backend/services/swipe_service.py: SwipeService con load/save/add/update/delete/mark_used su swipe_file.json - backend/routers/swipe.py: 5 endpoint REST (GET/POST/PUT/DELETE/mark-used), lazy init pattern - backend/main.py: registra swipe.router prima del SPA catch-all mount --- backend/main.py | 3 +- backend/routers/swipe.py | 141 +++++++++++++++++++++++ backend/schemas/swipe.py | 52 +++++++++ backend/services/swipe_service.py | 181 ++++++++++++++++++++++++++++++ 4 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 backend/routers/swipe.py create mode 100644 backend/schemas/swipe.py create mode 100644 backend/services/swipe_service.py diff --git a/backend/main.py b/backend/main.py index cc654d6..70ad684 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from backend.config import CAMPAIGNS_PATH, CONFIG_PATH, OUTPUTS_PATH, PROMPTS_PATH -from backend.routers import calendar, export, generate, prompts, settings +from backend.routers import calendar, export, generate, prompts, settings, swipe # --------------------------------------------------------------------------- @@ -92,6 +92,7 @@ app.include_router(generate.router) app.include_router(export.router) app.include_router(settings.router) app.include_router(prompts.router) +app.include_router(swipe.router) # --------------------------------------------------------------------------- diff --git a/backend/routers/swipe.py b/backend/routers/swipe.py new file mode 100644 index 0000000..6a70f01 --- /dev/null +++ b/backend/routers/swipe.py @@ -0,0 +1,141 @@ +"""Router per la gestione dello Swipe File. + +Endpoint: +- GET /api/swipe — lista tutte le idee (ordine: piu' recenti prima) +- POST /api/swipe — aggiunge una nuova idea +- PUT /api/swipe/{item_id} — modifica una voce esistente +- DELETE /api/swipe/{item_id} — elimina una voce +- POST /api/swipe/{item_id}/mark-used — segna la voce come usata in un calendario +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException + +from backend.config import DATA_PATH +from backend.schemas.swipe import ( + SwipeItem, + SwipeItemCreate, + SwipeItemUpdate, + SwipeListResponse, +) +from backend.services.swipe_service import SwipeService + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/swipe", tags=["swipe"]) + +# SwipeService creato lazily perche' DATA_PATH viene creato nel lifespan di FastAPI +_swipe_service: SwipeService | None = None + + +def _get_swipe_service() -> SwipeService: + """Ritorna l'istanza SwipeService, creandola al primo accesso. + + La creazione e' lazy perche' la directory DATA_PATH viene creata + durante il lifespan di FastAPI (main.py), non al momento dell'import. + """ + global _swipe_service + if _swipe_service is None: + # Assicura che la directory esista (normalmente gia' creata dal lifespan) + DATA_PATH.mkdir(parents=True, exist_ok=True) + _swipe_service = SwipeService(DATA_PATH) + return _swipe_service + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + + +@router.get("/", response_model=SwipeListResponse) +async def list_swipe_items() -> SwipeListResponse: + """Lista tutte le idee salvate nello Swipe File. + + Returns: + SwipeListResponse con items ordinati per data creazione (piu' recenti prima). + """ + items = _get_swipe_service().list_items() + return SwipeListResponse(items=items, total=len(items)) + + +@router.post("/", response_model=SwipeItem, status_code=201) +async def add_swipe_item(body: SwipeItemCreate) -> SwipeItem: + """Aggiunge una nuova idea allo Swipe File. + + Args: + body: SwipeItemCreate con topic (obbligatorio), nicchia e note (opzionali). + + Returns: + SwipeItem creato con id e timestamp generati automaticamente. + """ + item = _get_swipe_service().add_item(body) + logger.info("SwipeItem aggiunto | id=%s | topic=%s", item.id, item.topic[:50]) + return item + + +@router.put("/{item_id}", response_model=SwipeItem) +async def update_swipe_item(item_id: str, body: SwipeItemUpdate) -> SwipeItem: + """Modifica una voce esistente dello Swipe File. + + Args: + item_id: ID della voce da modificare. + body: SwipeItemUpdate con i campi da aggiornare (tutti opzionali). + + Returns: + SwipeItem aggiornato. + + Raises: + HTTPException 404: Se la voce non esiste. + """ + try: + item = _get_swipe_service().update_item(item_id, body) + logger.info("SwipeItem aggiornato | id=%s", item_id) + return item + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/{item_id}") +async def delete_swipe_item(item_id: str) -> dict: + """Elimina una voce dallo Swipe File. + + Args: + item_id: ID della voce da eliminare. + + Returns: + {"deleted": True} se l'operazione e' riuscita. + + Raises: + HTTPException 404: Se la voce non esiste. + """ + try: + _get_swipe_service().delete_item(item_id) + logger.info("SwipeItem eliminato | id=%s", item_id) + return {"deleted": True} + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/{item_id}/mark-used", response_model=SwipeItem) +async def mark_swipe_item_used(item_id: str) -> SwipeItem: + """Segna una voce come utilizzata in un calendario generato. + + Args: + item_id: ID della voce da segnare come usata. + + Returns: + SwipeItem aggiornato con used=True. + + Raises: + HTTPException 404: Se la voce non esiste. + """ + try: + item = _get_swipe_service().mark_used(item_id) + logger.info("SwipeItem segnato come usato | id=%s", item_id) + return item + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/backend/schemas/swipe.py b/backend/schemas/swipe.py new file mode 100644 index 0000000..42ef87e --- /dev/null +++ b/backend/schemas/swipe.py @@ -0,0 +1,52 @@ +"""Pydantic schemas per il modulo Swipe File. + +SwipeItem — Idea/topic salvata dall'utente +SwipeItemCreate — Payload creazione (topic obbligatorio, nicchia/note opzionali) +SwipeItemUpdate — Payload aggiornamento (tutti i campi opzionali per PATCH-like PUT) +SwipeListResponse — Risposta lista con contatore totale +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class SwipeItem(BaseModel): + """Rappresenta una singola idea/topic salvata nello Swipe File.""" + + id: str # UUID breve generato dal backend (uuid4.hex[:12]) + topic: str # Testo principale obbligatorio (min 3 chars) + nicchia: Optional[str] = None # Segmento/nicchia (es. "dentisti", "ecommerce") + note: Optional[str] = None # Note libere opzionali + created_at: str # ISO datetime UTC + updated_at: str # ISO datetime UTC (aggiornato a ogni modifica) + used: bool = False # True quando il topic e' stato usato in un calendario + + +class SwipeItemCreate(BaseModel): + """Payload per la creazione di una nuova voce dello Swipe File.""" + + topic: str = Field(..., min_length=3, max_length=200, description="Idea o topic da salvare") + nicchia: Optional[str] = Field(None, max_length=100) + note: Optional[str] = Field(None, max_length=1000) + + +class SwipeItemUpdate(BaseModel): + """Payload per l'aggiornamento di una voce esistente. + + Tutti i campi sono opzionali: viene aggiornato solo cio' che e' fornito. + Per cancellare nicchia o note, passare esplicitamente None. + """ + + topic: Optional[str] = Field(None, min_length=3, max_length=200) + nicchia: Optional[str] = Field(None, max_length=100) + note: Optional[str] = Field(None, max_length=1000) + + +class SwipeListResponse(BaseModel): + """Risposta per GET /api/swipe — lista di tutte le idee con contatore.""" + + items: list[SwipeItem] + total: int diff --git a/backend/services/swipe_service.py b/backend/services/swipe_service.py new file mode 100644 index 0000000..ea87237 --- /dev/null +++ b/backend/services/swipe_service.py @@ -0,0 +1,181 @@ +"""SwipeService — CRUD per il file data/swipe_file.json. + +Responsabilita': +- Carica e salva le voci dello Swipe File in un file JSON su disco. +- Ordina le voci per data creazione (piu' recenti prima). +- Genera ID brevi (uuid4.hex[:12]) e timestamp ISO UTC. +- Espone add/update/delete/mark_used come operazioni atomiche. + +Nota: le operazioni di I/O sono sincrone (json.load / json.dump). +Il file e' semplice JSON (lista di dict) e non richiede lock +perche' FastAPI e' single-process per questo caso d'uso. +""" + +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from backend.schemas.swipe import SwipeItem, SwipeItemCreate, SwipeItemUpdate + + +class SwipeService: + """Gestisce la persistenza del file swipe_file.json.""" + + def __init__(self, data_path: Path) -> None: + """ + Args: + data_path: Directory DATA_PATH. Il file sara' creato come + data_path / "swipe_file.json". + """ + self._file = data_path / "swipe_file.json" + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _load(self) -> list[dict]: + """Carica il file JSON dal disco. + + Returns: + Lista di dict. Ritorna lista vuota se il file non esiste. + """ + if not self._file.exists(): + return [] + try: + return json.loads(self._file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [] + + def _save(self, items: list[dict]) -> None: + """Salva la lista di dict nel file JSON. + + Args: + items: Lista di dict da persistere. + """ + self._file.write_text( + json.dumps(items, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + @staticmethod + def _now_iso() -> str: + """Ritorna timestamp corrente in formato ISO 8601 UTC.""" + return datetime.now(timezone.utc).isoformat() + + @staticmethod + def _new_id() -> str: + """Genera un ID breve (12 caratteri hex).""" + return uuid.uuid4().hex[:12] + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def list_items(self) -> list[SwipeItem]: + """Ritorna tutte le voci ordinate per data creazione (piu' recenti prima). + + Returns: + Lista di SwipeItem ordinata cronologicamente inversa. + """ + items = self._load() + # Ordina per created_at descending (ISO string sort works correctly) + items.sort(key=lambda x: x.get("created_at", ""), reverse=True) + return [SwipeItem(**item) for item in items] + + def add_item(self, data: SwipeItemCreate) -> SwipeItem: + """Aggiunge una nuova voce allo Swipe File. + + Args: + data: Dati validati per la nuova voce. + + Returns: + SwipeItem creato con id e timestamp generati. + """ + now = self._now_iso() + item = { + "id": self._new_id(), + "topic": data.topic, + "nicchia": data.nicchia, + "note": data.note, + "created_at": now, + "updated_at": now, + "used": False, + } + items = self._load() + items.append(item) + self._save(items) + return SwipeItem(**item) + + def update_item(self, item_id: str, data: SwipeItemUpdate) -> SwipeItem: + """Aggiorna una voce esistente. + + Aggiorna solo i campi non-None presenti in data. + Aggiorna sempre updated_at. + + Args: + item_id: ID della voce da aggiornare. + data: Campi da aggiornare. + + Returns: + SwipeItem aggiornato. + + Raises: + ValueError: Se l'ID non esiste. + """ + items = self._load() + for item in items: + if item["id"] == item_id: + if data.topic is not None: + item["topic"] = data.topic + if data.nicchia is not None: + item["nicchia"] = data.nicchia + if data.note is not None: + item["note"] = data.note + item["updated_at"] = self._now_iso() + self._save(items) + return SwipeItem(**item) + raise ValueError(f"SwipeItem con id='{item_id}' non trovato") + + def delete_item(self, item_id: str) -> bool: + """Elimina una voce dallo Swipe File. + + Args: + item_id: ID della voce da eliminare. + + Returns: + True se eliminata con successo. + + Raises: + ValueError: Se l'ID non esiste. + """ + items = self._load() + original_len = len(items) + items = [item for item in items if item["id"] != item_id] + if len(items) == original_len: + raise ValueError(f"SwipeItem con id='{item_id}' non trovato") + self._save(items) + return True + + def mark_used(self, item_id: str) -> SwipeItem: + """Segna una voce come utilizzata in un calendario. + + Args: + item_id: ID della voce da segnare come usata. + + Returns: + SwipeItem aggiornato con used=True. + + Raises: + ValueError: Se l'ID non esiste. + """ + items = self._load() + for item in items: + if item["id"] == item_id: + item["used"] = True + item["updated_at"] = self._now_iso() + self._save(items) + return SwipeItem(**item) + raise ValueError(f"SwipeItem con id='{item_id}' non trovato")