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
This commit is contained in:
@@ -14,7 +14,7 @@ from fastapi.responses import FileResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from backend.config import CAMPAIGNS_PATH, CONFIG_PATH, OUTPUTS_PATH, PROMPTS_PATH
|
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(export.router)
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
app.include_router(prompts.router)
|
app.include_router(prompts.router)
|
||||||
|
app.include_router(swipe.router)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
141
backend/routers/swipe.py
Normal file
141
backend/routers/swipe.py
Normal file
@@ -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
|
||||||
52
backend/schemas/swipe.py
Normal file
52
backend/schemas/swipe.py
Normal file
@@ -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
|
||||||
181
backend/services/swipe_service.py
Normal file
181
backend/services/swipe_service.py
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user