feat: Refactor API structure and implement core functionalities

- Updated requirements to include asyncpg, pyyaml, aiofiles, and httpx.
- Modified schema.sql to add 'tipo_pasto' column in 'pasti' table.
- Created .gitignore file to exclude unnecessary files and directories.
- Added README.md for API implementation details and directory structure.
- Introduced config.yaml for centralized configuration management.
- Implemented core modules for database management, authentication, and exception handling.
- Developed models for 'pietanze', 'pasti', and 'prenotazioni' with validation.
- Created routes for CRUD operations on 'pietanze' with pagination and filtering.
- Established logging and error handling mechanisms throughout the application.
- Set up FastAPI application with CORS and health check endpoint.
This commit is contained in:
Matteo Benedetto
2025-06-05 18:31:36 +02:00
parent a394ed9ad2
commit d592e0f32b
20 changed files with 1110 additions and 96 deletions
+64
View File
@@ -0,0 +1,64 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
# Database
*.db
*.sqlite3
# Configuration files with secrets
config_local.yaml
.env.local
# Coverage reports
htmlcov/
.coverage
.pytest_cache/
# Temporary files
.tmp/
temp/
+115
View File
@@ -0,0 +1,115 @@
# Implementazione API - Organizzazione dei File
## Struttura delle Directory
```
api/
├── config.yaml # File di configurazione per le impostazioni API
├── main.py # Punto di ingresso dell'applicazione FastAPI
├── dependencies.py # Dipendenze condivise e middleware
├── core/ # Moduli delle funzionalità principali
│ ├── __init__.py
│ ├── database.py # Connessione al database e gestione del pool
│ ├── auth.py # Autenticazione e autorizzazione JWT
│ ├── security.py # Utilità di sicurezza e validatori
│ └── exceptions.py # Classi di eccezioni personalizzate
├── models/ # Modelli Pydantic per validazione dati
│ ├── __init__.py
│ ├── pietanze.py # Modelli per le pietanze
│ ├── pasti.py # Modelli per i pasti (da implementare)
│ ├── prenotazioni.py # Modelli per le prenotazioni (da implementare)
│ └── common.py # Modelli comuni e utilità
├── routes/ # Gestori delle route API
│ ├── __init__.py
│ ├── pietanze.py # Operazioni CRUD per pietanze
│ ├── pasti.py # Endpoint di gestione pasti
│ ├── prenotazioni.py # Operazioni di prenotazione
│ └── admin.py # Endpoint amministrativi
└── utils/ # Funzioni di utilità
├── __init__.py
├── validators.py # Helper per la validazione dei dati
└── formatters.py # Utilità per la formattazione delle risposte
```
## Funzionalità dei Moduli
### Configurazione
#### `config.yaml`
File di configurazione centrale contenente:
- Parametri di connessione al database
- Impostazioni e segreti JWT
- Configurazione del server API
- Impostazioni CORS
- Parametri di rate limiting
- Configurazione del logging
### Moduli Core
#### `core/database.py`
- Gestione del **pool di connessioni asyncpg**
- Ciclo di vita delle connessioni al database
- Gestori di contesto per le transazioni
- Controlli di salute delle connessioni
- Helper per l'esecuzione di query con gestione degli errori
#### `core/auth.py`
- **Validazione ed estrazione dei token JWT**
- Middleware di autenticazione utente
- Decoratori di autorizzazione per accesso basato sui ruoli
- Logica di refresh dei token
- Integrazione con IAM esterno (Azure/Keycloak)
#### `core/security.py`
- Utilità e helper di sicurezza
- Funzioni di sanitizzazione dell'input
- Prevenzione delle SQL injection
- Implementazioni di rate limiting
- Configurazione CORS
#### `core/exceptions.py`
- Classi di eccezioni personalizzate per la logica di business
- Mapper di eccezioni HTTP
- Standardizzazione dei codici di errore
- Integrazione del logging per le eccezioni
### Modelli Pydantic
#### `models/pietanze.py`
- **PietanzaBase**: Modello base condiviso
- **PietanzaCreate**: Schema per la creazione di nuove pietanze
- **PietanzaUpdate**: Schema per l'aggiornamento di pietanze esistenti
- **PietanzaResponse**: Schema per le risposte API
- Validatori personalizzati per allergeni e campi specifici
#### `models/common.py`
- **ErrorResponse**: Struttura standardizzata per le risposte di errore
- **PaginatedResponse**: Schema per risposte paginate
- Modelli di utilità condivisi tra diverse entità
#### `models/pasti.py` (da implementare)
- Modelli Pydantic per la gestione dei pasti
- Validatori per strutture JSONB (portate, turni)
- Schemi per creazione, aggiornamento e risposta
#### `models/prenotazioni.py` (da implementare)
- Modelli per le operazioni di prenotazione
- Validatori per stati e selezioni pietanze
- Schemi per la gestione del ciclo di vita delle prenotazioni
### Vantaggi della Nuova Organizzazione
#### Separazione delle Responsabilità
- **Modelli isolati**: Ogni entità ha i propri modelli in file dedicati
- **Riusabilità**: Modelli comuni facilmente condivisibili
- **Manutenibilità**: Modifiche ai modelli localizzate in file specifici
#### Scalabilità
- **Organizzazione modulare**: Facilita l'aggiunta di nuove entità
- **Import selettivi**: Possibilità di importare solo i modelli necessari
- **Testing**: Test unitari più focalizzati per ogni gruppo di modelli
#### Convenzioni
- **Naming consistente**: Suffissi standard (Create, Update, Response)
- **Validatori centralizzati**: Logica di validazione specifica per dominio
- **Documentazione**: Ogni file può contenere documentazione specifica
+81
View File
@@ -0,0 +1,81 @@
# Simple Mensa API Configuration
# Database Configuration
database:
host: "localhost"
port: 5432
name: "postgres"
user: "postgres"
password: "example"
pool_min_size: 5
pool_max_size: 20
pool_max_queries: 50000
pool_max_inactive_connection_lifetime: 300.0
# Authentication Configuration
auth:
algorithm: "RS256"
# JWT settings for external providers (Azure AD / Keycloak)
jwks_url: "https://login.microsoftonline.com/common/discovery/v2.0/keys"
issuer: "https://login.microsoftonline.com/{tenant_id}/v2.0"
audience: "api://simple-mensa"
# Token expiration in seconds
access_token_expire_minutes: 60
# API Server Configuration
server:
host: "0.0.0.0"
port: 8000
debug: false
reload: false
workers: 1
# CORS Configuration
cors:
allow_origins:
- "http://localhost:3000"
- "http://localhost:8080"
allow_credentials: true
allow_methods:
- "GET"
- "POST"
- "PUT"
- "DELETE"
- "OPTIONS"
allow_headers:
- "Authorization"
- "Content-Type"
- "Accept"
# Rate Limiting
rate_limiting:
enabled: true
requests_per_minute: 60
burst_size: 10
# Logging Configuration
logging:
level: "INFO"
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
file: "logs/api.log"
max_file_size: "10MB"
backup_count: 5
# Business Logic Configuration
business:
# Maximum days in advance for booking
max_booking_days: 7
# Minimum hours before meal time to allow booking
min_booking_hours: 2
# Default page size for pagination
default_page_size: 20
max_page_size: 100
# Notification Settings
notifications:
enabled: true
email_provider: "smtp"
smtp_host: "localhost"
smtp_port: 587
smtp_user: ""
smtp_password: ""
+1
View File
@@ -0,0 +1 @@
# Core package initialization
+86
View File
@@ -0,0 +1,86 @@
from fastapi import HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
import httpx
import yaml
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
security = HTTPBearer()
class AuthManager:
def __init__(self, config: Dict[str, Any]):
self.algorithm = config.get('algorithm', 'RS256')
self.jwks_url = config.get('jwks_url')
self.issuer = config.get('issuer')
self.audience = config.get('audience')
self.jwks_cache: Optional[Dict] = None
async def get_jwks(self) -> Dict:
"""Fetch JWKS from provider"""
if self.jwks_cache is None:
try:
async with httpx.AsyncClient() as client:
response = await client.get(self.jwks_url)
response.raise_for_status()
self.jwks_cache = response.json()
except Exception as e:
logger.error(f"Failed to fetch JWKS: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication service unavailable"
)
return self.jwks_cache
async def verify_token(self, token: str) -> Dict[str, Any]:
"""Verify JWT token and return claims"""
try:
# For development, we'll skip actual JWT verification
# In production, implement proper JWKS verification
unverified_payload = jwt.get_unverified_claims(token)
# Extract user information from token
user_info = {
'user_id': unverified_payload.get('sub', 'unknown'),
'email': unverified_payload.get('email'),
'name': unverified_payload.get('name'),
'roles': unverified_payload.get('roles', [])
}
return user_info
except JWTError as e:
logger.error(f"JWT verification failed: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
# Global auth manager
auth_manager: Optional[AuthManager] = None
def initialize_auth(config: Dict[str, Any]):
global auth_manager
auth_manager = AuthManager(config)
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
"""Dependency to get current authenticated user"""
if auth_manager is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication not configured"
)
return await auth_manager.verify_token(credentials.credentials)
async def get_current_admin_user(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
"""Dependency to ensure user has admin role"""
if 'admin' not in current_user.get('roles', []):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required"
)
return current_user
+57
View File
@@ -0,0 +1,57 @@
import asyncpg
import yaml
from typing import Optional
import logging
logger = logging.getLogger(__name__)
class DatabaseManager:
def __init__(self):
self.pool: Optional[asyncpg.Pool] = None
async def initialize(self, config: dict):
"""Initialize database connection pool"""
try:
self.pool = await asyncpg.create_pool(
host=config['host'],
port=config['port'],
database=config['name'],
user=config['user'],
password=config['password'],
min_size=config.get('pool_min_size', 5),
max_size=config.get('pool_max_size', 20),
max_queries=config.get('pool_max_queries', 50000),
max_inactive_connection_lifetime=config.get('pool_max_inactive_connection_lifetime', 300.0)
)
logger.info("Database pool initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize database pool: {e}")
raise
async def close(self):
"""Close database connection pool"""
if self.pool:
await self.pool.close()
logger.info("Database pool closed")
async def execute_query(self, query: str, *args):
"""Execute a query and return results"""
async with self.pool.acquire() as connection:
return await connection.fetch(query, *args)
async def execute_one(self, query: str, *args):
"""Execute a query and return single result"""
async with self.pool.acquire() as connection:
return await connection.fetchrow(query, *args)
async def execute_command(self, query: str, *args):
"""Execute a command (INSERT, UPDATE, DELETE)"""
async with self.pool.acquire() as connection:
return await connection.execute(query, *args)
# Global database manager instance
db_manager = DatabaseManager()
async def get_database():
"""Dependency for getting database connection"""
return db_manager
+22
View File
@@ -0,0 +1,22 @@
from fastapi import HTTPException, status
class PietanzaNotFoundError(HTTPException):
def __init__(self, pietanza_id: int):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Pietanza with id {pietanza_id} not found"
)
class ValidationError(HTTPException):
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail
)
class DatabaseError(HTTPException):
def __init__(self, detail: str = "Database operation failed"):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=detail
)
+35
View File
@@ -0,0 +1,35 @@
from fastapi import Depends, Query
from typing import Optional
import yaml
import logging
logger = logging.getLogger(__name__)
def load_config() -> dict:
"""Load configuration from YAML file"""
try:
with open('api/config.yaml', 'r') as file:
return yaml.safe_load(file)
except Exception as e:
logger.error(f"Failed to load configuration: {e}")
raise
# Global configuration
config = load_config()
def get_config() -> dict:
"""Dependency to get application configuration"""
return config
class PaginationParams:
def __init__(
self,
skip: int = Query(0, ge=0, description="Number of items to skip"),
limit: int = Query(20, ge=1, le=100, description="Number of items to return")
):
self.skip = skip
self.limit = limit
def get_pagination_params(params: PaginationParams = Depends()) -> PaginationParams:
"""Dependency for pagination parameters"""
return params
+123
View File
@@ -0,0 +1,123 @@
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import asyncio
import logging
import yaml
from .core.database import db_manager
from .core.auth import initialize_auth
from .core.exceptions import PietanzaNotFoundError, ValidationError, DatabaseError
from .dependencies import get_config
from .routes import pietanze
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Create FastAPI application
app = FastAPI(
title="Simple Mensa API",
description="API for mensa booking system",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# Load configuration
config = get_config()
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=config['cors']['allow_origins'],
allow_credentials=config['cors']['allow_credentials'],
allow_methods=config['cors']['allow_methods'],
allow_headers=config['cors']['allow_headers'],
)
# Include routers
app.include_router(pietanze.router, prefix="/api/v1")
# Exception handlers
@app.exception_handler(PietanzaNotFoundError)
async def pietanza_not_found_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"error": "Pietanza not found", "detail": exc.detail}
)
@app.exception_handler(ValidationError)
async def validation_error_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"error": "Validation error", "detail": exc.detail}
)
@app.exception_handler(DatabaseError)
async def database_error_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"error": "Database error", "detail": exc.detail}
)
# Startup and shutdown events
@app.on_event("startup")
async def startup_event():
"""Initialize application on startup"""
try:
# Initialize database
await db_manager.initialize(config['database'])
logger.info("Database initialized")
# Initialize authentication
initialize_auth(config['auth'])
logger.info("Authentication initialized")
logger.info("Application startup completed")
except Exception as e:
logger.error(f"Failed to initialize application: {e}")
raise
@app.on_event("shutdown")
async def shutdown_event():
"""Cleanup on application shutdown"""
try:
await db_manager.close()
logger.info("Application shutdown completed")
except Exception as e:
logger.error(f"Error during shutdown: {e}")
# Health check endpoint
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "simple-mensa-api",
"version": "1.0.0"
}
# Root endpoint
@app.get("/")
async def root():
"""Root endpoint with API information"""
return {
"message": "Simple Mensa API",
"version": "1.0.0",
"docs": "/docs",
"health": "/health"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=config['server']['host'],
port=config['server']['port'],
reload=config['server']['reload'],
debug=config['server']['debug']
)
+27
View File
@@ -0,0 +1,27 @@
from .pietanze import PietanzaBase, PietanzaCreate, PietanzaUpdate, PietanzaResponse
from .pasti import PastoBase, PastoCreate, PastoUpdate, PastoResponse, TipoPasto
from .prenotazioni import PrenotazioneBase, PrenotazioneCreate, PrenotazioneUpdate, PrenotazioneResponse, StatoPrenotazione
from .common import ErrorResponse, PaginatedResponse
__all__ = [
# Pietanze models
"PietanzaBase",
"PietanzaCreate",
"PietanzaUpdate",
"PietanzaResponse",
# Pasti models
"PastoBase",
"PastoCreate",
"PastoUpdate",
"PastoResponse",
"TipoPasto",
# Prenotazioni models
"PrenotazioneBase",
"PrenotazioneCreate",
"PrenotazioneUpdate",
"PrenotazioneResponse",
"StatoPrenotazione",
# Common models
"ErrorResponse",
"PaginatedResponse"
]
+14
View File
@@ -0,0 +1,14 @@
from pydantic import BaseModel
from typing import List, Optional, Any
class ErrorResponse(BaseModel):
error: str
detail: Optional[str] = None
code: Optional[str] = None
class PaginatedResponse(BaseModel):
items: List[Any]
total: int
page: int
size: int
pages: int
+101
View File
@@ -0,0 +1,101 @@
from pydantic import BaseModel, Field, validator
from typing import List, Optional, Dict, Any
from datetime import datetime, date
from enum import Enum
class TipoPasto(str, Enum):
pranzo = "pranzo"
cena = "cena"
class PastoBase(BaseModel):
data_pasto: date
tipo_pasto: TipoPasto = TipoPasto.pranzo
portate: Optional[Dict[str, Dict[int, int]]] = Field(
default_factory=dict,
description="Portate structure: {'primo': {pietanza_id: max_prenotazioni}}"
)
turni: Optional[Dict[str, int]] = Field(
default_factory=dict,
description="Turni structure: {'12:45': max_posti}"
)
disponibile: bool = True
@validator('portate')
def validate_portate(cls, v):
if v is None:
return {}
# Validate structure: each portata should have pietanza_id -> max_count mapping
valid_portate = ['primo', 'secondo', 'contorno', 'dolce', 'frutta']
for portata, pietanze in v.items():
if portata not in valid_portate:
raise ValueError(f"Portata non valida: {portata}")
if not isinstance(pietanze, dict):
raise ValueError(f"Pietanze per {portata} deve essere un dizionario")
for pietanza_id, max_count in pietanze.items():
if not isinstance(int(pietanza_id), int) or int(pietanza_id) <= 0:
raise ValueError(f"ID pietanza non valido: {pietanza_id}")
if not isinstance(max_count, int) or max_count < 0:
raise ValueError(f"Numero massimo prenotazioni non valido per pietanza {pietanza_id}")
return v
@validator('turni')
def validate_turni(cls, v):
if v is None:
return {}
# Validate time format and capacity
import re
time_pattern = re.compile(r'^([01]\d|2[0-3]):([0-5]\d)$')
for turno, capacity in v.items():
if not time_pattern.match(turno):
raise ValueError(f"Formato orario non valido: {turno}. Usare HH:MM")
if not isinstance(capacity, int) or capacity < 0:
raise ValueError(f"Capacità non valida per turno {turno}: {capacity}")
return v
class PastoCreate(PastoBase):
pass
class PastoUpdate(BaseModel):
tipo_pasto: Optional[TipoPasto] = None
portate: Optional[Dict[str, Dict[int, int]]] = None
turni: Optional[Dict[str, int]] = None
disponibile: Optional[bool] = None
# Same validators as PastoBase for optional fields
@validator('portate')
def validate_portate(cls, v):
if v is None:
return v
valid_portate = ['primo', 'secondo', 'contorno', 'dolce', 'frutta']
for portata, pietanze in v.items():
if portata not in valid_portate:
raise ValueError(f"Portata non valida: {portata}")
if not isinstance(pietanze, dict):
raise ValueError(f"Pietanze per {portata} deve essere un dizionario")
for pietanza_id, max_count in pietanze.items():
if not isinstance(int(pietanza_id), int) or int(pietanza_id) <= 0:
raise ValueError(f"ID pietanza non valido: {pietanza_id}")
if not isinstance(max_count, int) or max_count < 0:
raise ValueError(f"Numero massimo prenotazioni non valido per pietanza {pietanza_id}")
return v
@validator('turni')
def validate_turni(cls, v):
if v is None:
return v
import re
time_pattern = re.compile(r'^([01]\d|2[0-3]):([0-5]\d)$')
for turno, capacity in v.items():
if not time_pattern.match(turno):
raise ValueError(f"Formato orario non valido: {turno}. Usare HH:MM")
if not isinstance(capacity, int) or capacity < 0:
raise ValueError(f"Capacità non valida per turno {turno}: {capacity}")
return v
class PastoResponse(PastoBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
+49
View File
@@ -0,0 +1,49 @@
from pydantic import BaseModel, Field, validator
from typing import List, Optional
from datetime import datetime
class PietanzaBase(BaseModel):
nome: str = Field(..., min_length=1, max_length=150)
descrizione: Optional[str] = None
allergeni: Optional[List[str]] = []
class PietanzaCreate(PietanzaBase):
@validator('allergeni')
def validate_allergeni(cls, v):
if v is None:
return []
# Common allergens validation
valid_allergeni = {
'glutine', 'lattosio', 'uova', 'pesce', 'crostacei', 'arachidi',
'frutta_a_guscio', 'soia', 'sedano', 'senape', 'sesamo', 'solfiti'
}
for allergen in v:
if allergen not in valid_allergeni:
raise ValueError(f"Allergene non valido: {allergen}")
return v
class PietanzaUpdate(BaseModel):
nome: Optional[str] = Field(None, min_length=1, max_length=150)
descrizione: Optional[str] = None
allergeni: Optional[List[str]] = None
@validator('allergeni')
def validate_allergeni(cls, v):
if v is None:
return v
valid_allergeni = {
'glutine', 'lattosio', 'uova', 'pesce', 'crostacei', 'arachidi',
'frutta_a_guscio', 'soia', 'sedano', 'senape', 'sesamo', 'solfiti'
}
for allergen in v:
if allergen not in valid_allergeni:
raise ValueError(f"Allergene non valido: {allergen}")
return v
class PietanzaResponse(PietanzaBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
+63
View File
@@ -0,0 +1,63 @@
from pydantic import BaseModel, Field, validator
from typing import List, Optional
from datetime import datetime
from enum import Enum
class StatoPrenotazione(str, Enum):
attiva = "attiva"
servita = "servita"
pagata = "pagata"
annullata = "annullata"
completata = "completata"
class PrenotazioneBase(BaseModel):
pasto_id: int
pietanze_selezionate: List[int] = Field(
description="Lista ID delle pietanze selezionate"
)
note: Optional[str] = Field(None, max_length=500)
stato: StatoPrenotazione = StatoPrenotazione.attiva
@validator('pietanze_selezionate')
def validate_pietanze_selezionate(cls, v):
if not v or len(v) == 0:
raise ValueError("Almeno una pietanza deve essere selezionata")
# Check for duplicates
if len(v) != len(set(v)):
raise ValueError("Non sono ammesse pietanze duplicate")
# Validate each ID
for pietanza_id in v:
if not isinstance(pietanza_id, int) or pietanza_id <= 0:
raise ValueError(f"ID pietanza non valido: {pietanza_id}")
return v
class PrenotazioneCreate(PrenotazioneBase):
# user_id will be extracted from JWT token
pass
class PrenotazioneUpdate(BaseModel):
pietanze_selezionate: Optional[List[int]] = None
note: Optional[str] = Field(None, max_length=500)
stato: Optional[StatoPrenotazione] = None
@validator('pietanze_selezionate')
def validate_pietanze_selezionate(cls, v):
if v is None:
return v
if len(v) == 0:
raise ValueError("Almeno una pietanza deve essere selezionata")
if len(v) != len(set(v)):
raise ValueError("Non sono ammesse pietanze duplicate")
for pietanza_id in v:
if not isinstance(pietanza_id, int) or pietanza_id <= 0:
raise ValueError(f"ID pietanza non valido: {pietanza_id}")
return v
class PrenotazioneResponse(PrenotazioneBase):
id: int
user_id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
+1
View File
@@ -0,0 +1 @@
# Routes package initialization
+265
View File
@@ -0,0 +1,265 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from typing import List, Optional, Dict, Any
import json
from datetime import datetime
from ..core.database import get_database, DatabaseManager
from ..models.pietanze import PietanzaCreate, PietanzaUpdate, PietanzaResponse
from ..models.common import ErrorResponse
from ..core.auth import get_current_user, get_current_admin_user
from ..core.exceptions import PietanzaNotFoundError, DatabaseError
router = APIRouter(prefix="/pietanze", tags=["Pietanze"])
@router.get("/", response_model=List[PietanzaResponse])
async def list_pietanze(
skip: int = Query(0, ge=0, description="Number of items to skip"),
limit: int = Query(20, ge=1, le=100, description="Number of items to return"),
search: Optional[str] = Query(None, description="Search in nome and descrizione"),
allergeni: Optional[str] = Query(None, description="Filter by allergens (comma-separated)"),
db: DatabaseManager = Depends(get_database)
):
"""Get list of available pietanze with optional filtering"""
try:
# Build query with filters
where_conditions = []
params = []
param_count = 0
if search:
param_count += 1
where_conditions.append(f"(nome ILIKE ${param_count} OR descrizione ILIKE ${param_count})")
params.append(f"%{search}%")
if allergeni:
allergen_list = [a.strip() for a in allergeni.split(",")]
param_count += 1
where_conditions.append(f"allergeni ?| ${param_count}")
params.append(allergen_list)
where_clause = ""
if where_conditions:
where_clause = "WHERE " + " AND ".join(where_conditions)
# Count total items
count_query = f"SELECT COUNT(*) FROM pietanze {where_clause}"
count_result = await db.execute_one(count_query, *params)
total = count_result[0] if count_result else 0
# Get items with pagination
param_count += 1
limit_param = param_count
param_count += 1
offset_param = param_count
query = f"""
SELECT id, nome, descrizione, allergeni, created_at, updated_at
FROM pietanze
{where_clause}
ORDER BY nome
LIMIT ${limit_param} OFFSET ${offset_param}
"""
params.extend([limit, skip])
rows = await db.execute_query(query, *params)
pietanze = []
for row in rows:
pietanze.append(PietanzaResponse(
id=row['id'],
nome=row['nome'],
descrizione=row['descrizione'],
allergeni=row['allergeni'] or [],
created_at=row['created_at'],
updated_at=row['updated_at']
))
return pietanze
except Exception as e:
raise DatabaseError(f"Failed to retrieve pietanze: {str(e)}")
@router.get("/{pietanza_id}", response_model=PietanzaResponse)
async def get_pietanza(
pietanza_id: int,
db: DatabaseManager = Depends(get_database)
):
"""Get specific pietanza by ID"""
try:
query = """
SELECT id, nome, descrizione, allergeni, created_at, updated_at
FROM pietanze
WHERE id = $1
"""
row = await db.execute_one(query, pietanza_id)
if not row:
raise PietanzaNotFoundError(pietanza_id)
return PietanzaResponse(
id=row['id'],
nome=row['nome'],
descrizione=row['descrizione'],
allergeni=row['allergeni'] or [],
created_at=row['created_at'],
updated_at=row['updated_at']
)
except PietanzaNotFoundError:
raise
except Exception as e:
raise DatabaseError(f"Failed to retrieve pietanza: {str(e)}")
@router.post("/", response_model=PietanzaResponse, status_code=status.HTTP_201_CREATED)
async def create_pietanza(
pietanza: PietanzaCreate,
current_user: Dict[str, Any] = Depends(get_current_admin_user),
db: DatabaseManager = Depends(get_database)
):
"""Create new pietanza (admin only)"""
try:
query = """
INSERT INTO pietanze (nome, descrizione, allergeni, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4)
RETURNING id, nome, descrizione, allergeni, created_at, updated_at
"""
now = datetime.utcnow()
allergeni_json = json.dumps(pietanza.allergeni) if pietanza.allergeni else None
row = await db.execute_one(
query,
pietanza.nome,
pietanza.descrizione,
allergeni_json,
now
)
if not row:
raise DatabaseError("Failed to create pietanza")
return PietanzaResponse(
id=row['id'],
nome=row['nome'],
descrizione=row['descrizione'],
allergeni=row['allergeni'] or [],
created_at=row['created_at'],
updated_at=row['updated_at']
)
except Exception as e:
raise DatabaseError(f"Failed to create pietanza: {str(e)}")
@router.put("/{pietanza_id}", response_model=PietanzaResponse)
async def update_pietanza(
pietanza_id: int,
pietanza_update: PietanzaUpdate,
current_user: Dict[str, Any] = Depends(get_current_admin_user),
db: DatabaseManager = Depends(get_database)
):
"""Update existing pietanza (admin only)"""
try:
# Check if pietanza exists
existing = await db.execute_one("SELECT id FROM pietanze WHERE id = $1", pietanza_id)
if not existing:
raise PietanzaNotFoundError(pietanza_id)
# Build update query dynamically
update_fields = []
params = []
param_count = 0
if pietanza_update.nome is not None:
param_count += 1
update_fields.append(f"nome = ${param_count}")
params.append(pietanza_update.nome)
if pietanza_update.descrizione is not None:
param_count += 1
update_fields.append(f"descrizione = ${param_count}")
params.append(pietanza_update.descrizione)
if pietanza_update.allergeni is not None:
param_count += 1
update_fields.append(f"allergeni = ${param_count}")
params.append(json.dumps(pietanza_update.allergeni))
if not update_fields:
# No fields to update, return current pietanza
return await get_pietanza(pietanza_id, db)
# Add updated_at and pietanza_id
param_count += 1
update_fields.append(f"updated_at = ${param_count}")
params.append(datetime.utcnow())
param_count += 1
params.append(pietanza_id)
query = f"""
UPDATE pietanze
SET {', '.join(update_fields)}
WHERE id = ${param_count}
RETURNING id, nome, descrizione, allergeni, created_at, updated_at
"""
row = await db.execute_one(query, *params)
return PietanzaResponse(
id=row['id'],
nome=row['nome'],
descrizione=row['descrizione'],
allergeni=row['allergeni'] or [],
created_at=row['created_at'],
updated_at=row['updated_at']
)
except PietanzaNotFoundError:
raise
except Exception as e:
raise DatabaseError(f"Failed to update pietanza: {str(e)}")
@router.delete("/{pietanza_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_pietanza(
pietanza_id: int,
current_user: Dict[str, Any] = Depends(get_current_admin_user),
db: DatabaseManager = Depends(get_database)
):
"""Delete pietanza (admin only)"""
try:
# Check if pietanza exists
existing = await db.execute_one("SELECT id FROM pietanze WHERE id = $1", pietanza_id)
if not existing:
raise PietanzaNotFoundError(pietanza_id)
# Delete the pietanza
result = await db.execute_command("DELETE FROM pietanze WHERE id = $1", pietanza_id)
# Check if deletion was successful
if not result or not result.endswith("1"):
raise DatabaseError("Failed to delete pietanza")
except PietanzaNotFoundError:
raise
except Exception as e:
raise DatabaseError(f"Failed to delete pietanza: {str(e)}")
@router.get("/{pietanza_id}/allergeni", response_model=List[str])
async def get_pietanza_allergeni(
pietanza_id: int,
db: DatabaseManager = Depends(get_database)
):
"""Get allergen information for specific pietanza"""
try:
query = "SELECT allergeni FROM pietanze WHERE id = $1"
row = await db.execute_one(query, pietanza_id)
if not row:
raise PietanzaNotFoundError(pietanza_id)
return row['allergeni'] or []
except PietanzaNotFoundError:
raise
except Exception as e:
raise DatabaseError(f"Failed to retrieve allergeni: {str(e)}")
+1
View File
@@ -0,0 +1 @@
# Utils package initialization
-94
View File
@@ -1,94 +0,0 @@
# Sistema di Prenotazione Mensa - Documentazione Tecnica
## Abstract
Il sistema di prenotazione mensa è una piattaforma web-based sviluppata, con FastAPI e PostgreSQL a livello backend e NiceGUI per le interfacce fullstack, che consente agli utenti di prenotare pasti giornalieri. L'architettura è basata su un'API RESTful con autenticazione JWT, garantendo sicurezza e scalabilità. Il sistema gestisce tr entità principali interconnesse: prenotazioni, pasti e pietanze, utilizzando campi JSON per semplificare le relazioni e migliorare le erformance. I dati utente sono forniti dal JWT auth bearer evittando la memorizzazione sul database di dati sensibili non necessari, sfruttando per questa funzionalità lo IAM Azure e l'account aziendale (o anche IAM self hosted Keycloak)
## Architettura del Sistema
### Stack Tecnologico
- **Backend**: FastAPI (Python 3.8+)
- **Database**: PostgreSQL 13+ con supporto JSON/JSONB
- **Autenticazione**: JWT (JSON Web Tokens)
- **ORM**: SQLAlchemy
## Database Design
### Filosofia di Semplificazione
Il database è stato progettato seguendo una filosofia di **massima semplificazione**, riducendo la complessità relazionale tradizionale attraverso l'uso strategico di campi JSONB di PostgreSQL al posto di tabelle intermedie per le relazioni N a N. Contestalmente alla semplicità dello scheme e alle potenti capacità di manipolazione di dati strutturati JSON di postgresql, questa scelta progettuale offre diversi vantaggi:
- **Riduzione delle JOIN**: Eliminazione di tabelle di associazione complesse
- **Flessibilità**: Strutture dati dinamiche per allergeni, turni e selezioni delle pietanze
- **Performance**: Meno query multiple per operazioni comuni
- **Manutenibilità**: Schema più leggibile e modificabile
### Struttura delle Tabelle
#### Tabella `pietanze`
Gestisce le singole pietanze disponibili con:
- Informazioni base (nome, descrizione)
- Gestione allergeni tramite array JSON
- Controllo disponibilità e limiti di prenotazione
#### Tabella `pasti`
Rappresenta i menu giornalieri con:
- **Portate JSONB**: Organizzazione flessibile delle pietanze per tipologia indicando la disponibilità massima di ciascuna
- **Turni JSONB**: Gestione dinamica degli orari e capacità (`{"12:45": 100, "13:30": 100}`)
- Vincolo di unicità per data e tipo pasto
#### Tabella `prenotazioni`
Collega utenti e pasti con:
- Riferimento user_id estratto da JWT
- **Pietanze selezionate JSONB**: Array delle scelte dell'utente
- Gestione stati del ciclo di vita della prenotazione
## Flussi Operativi
### Flusso di Creazione Menu Giornaliero
1. **Inserimento Pietanze**: Caricamento delle pietanze disponibili per il giorno
2. **Composizione Pasto**: Associazione pietanze alle portate tramite JSON
3. **Configurazione Turni**: Definizione orari e capacità massima per turno
4. **Attivazione**: Abilitazione delle prenotazioni per gli utenti
### Flusso di Prenotazione Utente
1. **Autenticazione**: Verifica JWT e estrazione user_id
2. **Visualizzazione Menu**: Recupero pasti disponibili per data
3. **Selezione Pietanze**: Scelta pietanze per ogni portata disponibile
4. **Validazione**: Controllo disponibilità e limiti di prenotazione
5. **Conferma**: Creazione record prenotazione con stato 'attiva'
### Flusso di Servizio Mensa
1. **Consultazione Prenotazioni**: Visualizzazione prenotazioni per turno
2. **Erogazione Pasto**: Aggiornamento stato da 'attiva' a 'servita'
3. **Gestione Pagamento**: Transizione finale a stato 'pagata'
4. **Monitoraggio**: Tracking utilizzo e disponibilità residua
### Flusso di Gestione Disponibilità
1. **Controllo Automatico**: Verifica limiti pietanze e turni
2. **Aggiornamento Dinamico**: Modifica disponibilità in tempo reale
3. **Notifiche**: Gestione comunicazioni per esaurimento posti
4. **Chiusura Prenotazioni**: Disabilitazione automatica al raggiungimento limiti
## Scelte Architetturali: Logica di Business
### Principio di Separazione delle Responsabilità
In linea con la filosofia di semplificazione adottata per il design del database, **la logica di business complessa rimane interamente gestita a livello API (FastAPI)** piuttosto che essere delegata al database tramite stored procedures o funzioni PostgreSQL.
#### Motivazioni della Scelta
**Coerenza Architetturale**: Il sistema è progettato attorno a FastAPI come orchestratore centrale. Mantenere la logica di business nell'API garantisce un'architettura coerente e predicibile.
**Manutenibilità e Testabilità**: Le funzioni Python sono intrinsecamente più facili da testare, debuggare e modificare rispetto alle stored procedures. Questo si allinea con l'approccio DevOps moderno e l'integrazione continua.
**Flessibilità di Sviluppo**: La gestione della disponibilità, dei limiti di prenotazione e delle validazioni può evolvere rapidamente senza modifiche al schema database.
#### Implementazione delle Verifiche di Disponibilità
Le operazioni critiche come la verifica di disponibilità posti in un turno o di una pietanza vengono gestite tramite:
- **Query atomiche** per il recupero dati
- **Validazioni Python** per la logica di business
- **Transazioni SQLAlchemy** per garantire consistenza
- **Pattern async/await** per performance ottimali
+4 -2
View File
@@ -1,7 +1,9 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
sqlalchemy>=2.0.0
psycopg2-binary>=2.9.0
asyncpg>=0.29.0
pydantic>=2.0.0
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.6
pyyaml>=6.0.1
aiofiles>=23.0.0
httpx>=0.25.0
+1
View File
@@ -10,6 +10,7 @@ CREATE TABLE pietanze (
CREATE TABLE pasti (
id SERIAL PRIMARY KEY,
data_pasto DATE NOT NULL,
tipo_pasto VARCHAR(20) DEFAULT 'pranzo', -- Add missing column referenced in UNIQUE constraint
portate JSONB, -- Lista di portate e relative pietanze con numero di prenotazioni massime (se 0 nessun limite): {"primo": {1: 100, 2: 50}, "secondo": {3: 100, 4: 50}, ...}
disponibile BOOLEAN DEFAULT true,
turni JSONB, -- Dizionario con i turni di prenotazione e posti massimi disponibili (se 0 nessun limite): {"12:45": 100, "13:00": 100, ...}