diff --git a/app/main.py b/app/main.py index ad2e049..9493971 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,9 @@ """ rendicontazione-api — microservizio sviluppato da BFLOWS per Gepafin. Gestisce schemi di rendicontazione per bando, pratiche di rendicontazione, -fatture, ULA, soccorso istruttorio. +fatture, ULA, soccorso istruttorio, upload file, verbale istruttoria. -Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic). +Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic) + weasyprint. Auth: JWT condiviso con GEPAFIN-BE. """ import logging @@ -14,7 +14,8 @@ from sqlalchemy import text from .config import get_settings from .db import engine, Base -from .routers import health, schemas, practices, debug, instructor +from .migrations import run_migrations +from .routers import health, schemas, practices, debug, instructor, files, verbale logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") log = logging.getLogger("rendicontazione-api") @@ -25,12 +26,12 @@ settings = get_settings() @asynccontextmanager async def lifespan(app: FastAPI): log.info("Avvio rendicontazione-api") - # Crea schema e tabelle se non esistono (bootstrap sandbox) try: with engine.begin() as conn: conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.db_schema}')) Base.metadata.create_all(bind=engine) - log.info(f"Schema '{settings.db_schema}' e tabelle inizializzate") + run_migrations(engine) + log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK") except Exception as e: log.error(f"Errore bootstrap DB: {e}") raise @@ -41,7 +42,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="rendicontazione-api", description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS", - version="0.1.0", + version="0.3.0", lifespan=lifespan, ) @@ -58,13 +59,15 @@ app.include_router(schemas.router) app.include_router(practices.router) app.include_router(debug.router) app.include_router(instructor.router) +app.include_router(files.router) +app.include_router(verbale.router) @app.get("/", tags=["root"]) def root(): return { "service": "rendicontazione-api", - "version": "0.1.0", + "version": "0.3.0", "docs": "/docs", "health": "/health", } diff --git a/app/migrations.py b/app/migrations.py new file mode 100644 index 0000000..33b7ce8 --- /dev/null +++ b/app/migrations.py @@ -0,0 +1,56 @@ +""" +Migration idempotente per sandbox. +Base.metadata.create_all() crea solo tabelle mancanti, non colonne aggiuntive. +Qui gestiamo ALTER TABLE ADD COLUMN IF NOT EXISTS per l'evoluzione dello schema. + +Ogni migration è una stringa SQL che puo essere eseguita piu volte senza errori. +""" +from sqlalchemy import text +import logging + +log = logging.getLogger("rendicontazione-api.migrations") + + +MIGRATIONS = [ + # 2026-04-18: colonne file upload su remission_invoice + """ + ALTER TABLE gepafin_rendic.remission_invoice + ADD COLUMN IF NOT EXISTS storage_path varchar(1024), + ADD COLUMN IF NOT EXISTS mime varchar(128), + ADD COLUMN IF NOT EXISTS size_bytes bigint, + ADD COLUMN IF NOT EXISTS sha256 varchar(64), + ADD COLUMN IF NOT EXISTS uploaded_by integer, + ADD COLUMN IF NOT EXISTS uploaded_at timestamptz; + """, + # 2026-04-18: colonne file upload su remission_ula_employee + """ + ALTER TABLE gepafin_rendic.remission_ula_employee + ADD COLUMN IF NOT EXISTS storage_path varchar(1024), + ADD COLUMN IF NOT EXISTS mime varchar(128), + ADD COLUMN IF NOT EXISTS size_bytes bigint, + ADD COLUMN IF NOT EXISTS sha256 varchar(64), + ADD COLUMN IF NOT EXISTS uploaded_by integer, + ADD COLUMN IF NOT EXISTS uploaded_at timestamptz; + """, + # 2026-04-18: colonne file upload su remission_document + """ + ALTER TABLE gepafin_rendic.remission_document + ADD COLUMN IF NOT EXISTS storage_path varchar(1024), + ADD COLUMN IF NOT EXISTS mime varchar(128), + ADD COLUMN IF NOT EXISTS size_bytes bigint, + ADD COLUMN IF NOT EXISTS sha256 varchar(64), + ADD COLUMN IF NOT EXISTS uploaded_by integer; + """, +] + + +def run_migrations(engine) -> None: + """Esegue tutte le migration in transazione. Log su ciascuna.""" + with engine.begin() as conn: + for i, sql in enumerate(MIGRATIONS, 1): + try: + conn.execute(text(sql)) + log.info(f"migration {i}/{len(MIGRATIONS)} OK") + except Exception as e: + log.error(f"migration {i} FAILED: {e}") + raise diff --git a/app/models.py b/app/models.py index e3c1965..1bb1358 100644 --- a/app/models.py +++ b/app/models.py @@ -4,7 +4,7 @@ Schema: gepafin_rendic (stesso DB del BE Gepafin sandbox). """ import uuid from datetime import datetime -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date, BigInteger from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.sql import func from sqlalchemy.orm import relationship @@ -102,7 +102,15 @@ class RemissionInvoice(Base): taxable = Column(Numeric(14, 2), nullable=False) # imponibile vat = Column(Numeric(14, 2), nullable=False, default=0) total = Column(Numeric(14, 2), nullable=False) - pdf_filename = Column(String(512), nullable=True) # per ora solo nome, upload vero dopo + pdf_filename = Column(String(512), nullable=True) # nome originale + + # File upload (bind mount /var/uploads dentro container) + storage_path = Column(String(1024), nullable=True) # relativo a /var/uploads + mime = Column(String(128), nullable=True) + size_bytes = Column(BigInteger, nullable=True) + sha256 = Column(String(64), nullable=True) + uploaded_by = Column(Integer, nullable=True) + uploaded_at = Column(DateTime(timezone=True), nullable=True) # Campi istruttoria (dual declared/verified) taxable_verified = Column(Numeric(14, 2), nullable=True) @@ -139,7 +147,15 @@ class RemissionUlaEmployee(Base): period_end_date = Column(Date, nullable=False) supporting_doc_type = Column(String(64), nullable=True) - supporting_doc_filename = Column(String(512), nullable=True) + supporting_doc_filename = Column(String(512), nullable=True) # nome originale + + # File upload + storage_path = Column(String(1024), nullable=True) + mime = Column(String(128), nullable=True) + size_bytes = Column(BigInteger, nullable=True) + sha256 = Column(String(64), nullable=True) + uploaded_by = Column(Integer, nullable=True) + uploaded_at = Column(DateTime(timezone=True), nullable=True) # Campi istruttoria fte_pct_verified = Column(Numeric(5, 4), nullable=True) @@ -164,11 +180,18 @@ class RemissionDocument(Base): nullable=False) doc_code = Column(String(64), nullable=False) # DURC / VISURA_CAMERALE / ... - filename = Column(String(512), nullable=True) + filename = Column(String(512), nullable=True) # nome originale uploaded_at = Column(DateTime(timezone=True), nullable=True) expires_at = Column(Date, nullable=True) notes = Column(Text, nullable=True) + # File upload + storage_path = Column(String(1024), nullable=True) + mime = Column(String(128), nullable=True) + size_bytes = Column(BigInteger, nullable=True) + sha256 = Column(String(64), nullable=True) + uploaded_by = Column(Integer, nullable=True) + # Campi istruttoria verification_status = Column(String(16), nullable=False, default="PENDING") # PENDING | VALIDO | NON_VALIDO | SCADUTO diff --git a/app/routers/files.py b/app/routers/files.py new file mode 100644 index 0000000..de01b80 --- /dev/null +++ b/app/routers/files.py @@ -0,0 +1,269 @@ +""" +Endpoint file upload/download/delete per fatture, ULA, documenti. +Storage: FS locale via app.storage. +Autorizzazione: + - UPLOAD: beneficiario su sua pratica in DRAFT/AWAITING_AMENDMENT, o istruttore + - DOWNLOAD: beneficiario su sua pratica, istruttore, superadmin + - DELETE: solo beneficiario su DRAFT/AWAITING_AMENDMENT +""" +from datetime import datetime, timezone +from uuid import UUID +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request +from fastapi.responses import FileResponse, Response +from sqlalchemy.orm import Session + +from ..db import get_db +from ..auth import AuthUser, get_current_user +from ..models import ( + RemissionPractice, RemissionInvoice, RemissionUlaEmployee, RemissionDocument +) +from ..storage import ( + save_upload, delete_file, open_file, + FileTooLargeError, MimeNotAllowedError, StorageError +) +from ..schemas import ApiResponse + +router = APIRouter(prefix="/api/remission-files", tags=["files"]) + + +def _is_instructor(user: AuthUser) -> bool: + return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN") + + +def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool: + """Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore.""" + if _is_instructor(user): + return True + if user.is_beneficiary() and practice.user_id == user.user_id: + return practice.status in ("DRAFT", "AWAITING_AMENDMENT") + return False + + +def _can_download(user: AuthUser, practice: RemissionPractice) -> bool: + if _is_instructor(user): + return True + if user.is_beneficiary() and practice.user_id == user.user_id: + return True + return False + + +def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool: + """Solo beneficiario su pratica modificabile. Istruttore non elimina file.""" + if user.is_beneficiary() and practice.user_id == user.user_id: + return practice.status in ("DRAFT", "AWAITING_AMENDMENT") + if user.is_superadmin(): + return True + return False + + +def _load_entity(db: Session, entity_type: str, entity_id: UUID): + if entity_type == "invoice": + return db.query(RemissionInvoice).filter(RemissionInvoice.id == entity_id).first() + if entity_type == "ula": + return db.query(RemissionUlaEmployee).filter(RemissionUlaEmployee.id == entity_id).first() + if entity_type == "document": + return db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first() + raise HTTPException(status_code=400, detail=f"entity_type non valido: {entity_type}") + + +def _serialize_file_meta(entity, entity_type: str) -> dict: + """Estrae metadata file dall'entita (invoice/ula/document).""" + filename_field = { + "invoice": "pdf_filename", + "ula": "supporting_doc_filename", + "document": "filename", + }[entity_type] + + has_file = bool(getattr(entity, "storage_path", None)) + return { + "has_file": has_file, + "filename_original": getattr(entity, filename_field, None), + "storage_path": entity.storage_path if has_file else None, + "mime": entity.mime, + "size_bytes": entity.size_bytes, + "sha256": entity.sha256, + "uploaded_at": entity.uploaded_at.isoformat() if getattr(entity, "uploaded_at", None) else None, + } + + +@router.post("/{entity_type}/{entity_id}/upload", response_model=ApiResponse) +async def upload_entity_file( + entity_type: Literal["invoice", "ula", "document"], + entity_id: UUID, + file: UploadFile = File(...), + db: Session = Depends(get_db), + user: AuthUser = Depends(get_current_user), +): + """ + Upload file per una entita (invoice/ula/document). + Se l'entita ha gia un file, lo sostituisce (vecchio file eliminato da FS). + """ + entity = _load_entity(db, entity_type, entity_id) + if not entity: + raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata") + + # Pratica di riferimento + practice = db.query(RemissionPractice).filter( + RemissionPractice.id == entity.practice_id + ).first() + if not practice: + raise HTTPException(status_code=404, detail="Pratica non trovata") + + if not _can_upload(user, practice): + raise HTTPException( + status_code=403, + detail="Non autorizzato a caricare file su questa pratica" + ) + + # Salva su FS + try: + rel_path, size, digest, mime, safe_name = save_upload( + application_id=practice.application_id, + entity_type=entity_type, + entity_id=entity.id, + file_obj=file.file, + original_filename=file.filename or "file.bin", + content_type=file.content_type, + ) + except FileTooLargeError as e: + raise HTTPException(status_code=413, detail=str(e)) + except MimeNotAllowedError as e: + raise HTTPException(status_code=415, detail=str(e)) + except StorageError as e: + raise HTTPException(status_code=500, detail=f"Errore storage: {e}") + + # Rimuovi eventuale file precedente su FS se diverso + old_path = entity.storage_path + if old_path and old_path != rel_path: + try: + delete_file(old_path) + except Exception: + pass # non blocchiamo l'upload per cleanup fallito + + # Aggiorna metadata + entity.storage_path = rel_path + entity.mime = mime + entity.size_bytes = size + entity.sha256 = digest + entity.uploaded_by = user.user_id + if hasattr(entity, "uploaded_at"): + entity.uploaded_at = datetime.now(timezone.utc) + + # Aggiorna filename originale a seconda del tipo + if entity_type == "invoice": + entity.pdf_filename = safe_name + elif entity_type == "ula": + entity.supporting_doc_filename = safe_name + elif entity_type == "document": + entity.filename = safe_name + # document ha gia uploaded_at proprio campo + entity.uploaded_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(entity) + + return ApiResponse( + success=True, + data=_serialize_file_meta(entity, entity_type), + message="File caricato", + ) + + +@router.get("/{entity_type}/{entity_id}") +def download_entity_file( + entity_type: Literal["invoice", "ula", "document"], + entity_id: UUID, + request: Request, + inline: int = 0, + db: Session = Depends(get_db), + user: AuthUser = Depends(get_current_user), +): + """ + Download (inline=0) o preview (inline=1) del file. + Ritorna il file con header appropriati. + """ + entity = _load_entity(db, entity_type, entity_id) + if not entity or not entity.storage_path: + raise HTTPException(status_code=404, detail="File non presente") + + practice = db.query(RemissionPractice).filter( + RemissionPractice.id == entity.practice_id + ).first() + if not practice: + raise HTTPException(status_code=404, detail="Pratica non trovata") + + if not _can_download(user, practice): + raise HTTPException(status_code=403, detail="Non autorizzato") + + try: + abs_path = open_file(entity.storage_path) + except FileNotFoundError: + raise HTTPException(status_code=410, detail="File non piu disponibile su storage") + + filename = None + if entity_type == "invoice": + filename = entity.pdf_filename + elif entity_type == "ula": + filename = entity.supporting_doc_filename + elif entity_type == "document": + filename = entity.filename + + disposition = "inline" if inline else "attachment" + headers = { + "Content-Disposition": f'{disposition}; filename="{filename or "file"}"', + } + return FileResponse( + path=str(abs_path), + media_type=entity.mime or "application/octet-stream", + headers=headers, + ) + + +@router.delete("/{entity_type}/{entity_id}", response_model=ApiResponse) +def delete_entity_file( + entity_type: Literal["invoice", "ula", "document"], + entity_id: UUID, + db: Session = Depends(get_db), + user: AuthUser = Depends(get_current_user), +): + """Elimina il file allegato. Entita resta, metadata file azzerati.""" + entity = _load_entity(db, entity_type, entity_id) + if not entity: + raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata") + + practice = db.query(RemissionPractice).filter( + RemissionPractice.id == entity.practice_id + ).first() + if not practice: + raise HTTPException(status_code=404, detail="Pratica non trovata") + + if not _can_delete(user, practice): + raise HTTPException(status_code=403, detail="Non autorizzato") + + if not entity.storage_path: + return ApiResponse(success=True, message="Nessun file da eliminare") + + try: + delete_file(entity.storage_path) + except Exception: + pass # rimuoviamo comunque i metadati + + entity.storage_path = None + entity.mime = None + entity.size_bytes = None + entity.sha256 = None + entity.uploaded_by = None + if hasattr(entity, "uploaded_at"): + entity.uploaded_at = None + + if entity_type == "invoice": + entity.pdf_filename = None + elif entity_type == "ula": + entity.supporting_doc_filename = None + elif entity_type == "document": + entity.filename = None + + db.commit() + return ApiResponse(success=True, message="File eliminato") diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..2e7d760 --- /dev/null +++ b/app/storage.py @@ -0,0 +1,159 @@ +""" +Storage adapter per file upload. + +Implementazione attuale: filesystem locale con bind mount /var/uploads. +Struttura: /var/uploads/{application_id}/{entity_type}/{entity_id}/{sha256}-{filename} + +Migrazione futura a S3/MinIO: cambiare solo questa classe. +""" +import hashlib +import os +import shutil +from pathlib import Path +from typing import BinaryIO, Optional, Tuple +from uuid import UUID + +BASE_PATH = Path(os.environ.get("RENDIC_UPLOAD_BASE", "/var/uploads")) +MAX_SIZE_BYTES = 15 * 1024 * 1024 # 15 MB + +ALLOWED_MIMES = { + "application/pdf": ".pdf", + "image/jpeg": ".jpg", + "image/png": ".png", +} + + +class StorageError(Exception): + pass + + +class FileTooLargeError(StorageError): + pass + + +class MimeNotAllowedError(StorageError): + pass + + +def _safe_filename(name: str, max_len: int = 120) -> str: + """Rimuove caratteri pericolosi, tronca.""" + keep = "-_.() " + clean = "".join(c if (c.isalnum() or c in keep) else "_" for c in name) + clean = clean.strip().replace(" ", "_") + if len(clean) > max_len: + root, ext = os.path.splitext(clean) + clean = root[: max_len - len(ext)] + ext + return clean or "file" + + +def save_upload( + application_id: int, + entity_type: str, # invoice | ula | document + entity_id: UUID, + file_obj: BinaryIO, + original_filename: str, + content_type: Optional[str], +) -> Tuple[str, int, str, str, str]: + """ + Salva il file su FS e ritorna (storage_path, size_bytes, sha256, mime, safe_filename). + storage_path è RELATIVO a BASE_PATH (es: "1/invoice/xxx/yyy-fattura.pdf"). + + Valida: + - mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione) + - dimensione <= MAX_SIZE_BYTES + """ + if entity_type not in ("invoice", "ula", "document"): + raise StorageError(f"entity_type non valido: {entity_type}") + + safe_name = _safe_filename(original_filename) + ext = os.path.splitext(safe_name)[1].lower() + + # Risolvi mime: prima content_type client, poi da estensione + mime = (content_type or "").lower().split(";")[0].strip() + if mime not in ALLOWED_MIMES: + # Fallback da estensione + ext_to_mime = {v: k for k, v in ALLOWED_MIMES.items()} + mime = ext_to_mime.get(ext, "") + if mime not in ALLOWED_MIMES: + raise MimeNotAllowedError( + f"MIME non consentito: '{content_type}' / estensione '{ext}'. " + f"Accettati: {list(ALLOWED_MIMES.keys())}" + ) + + # Calcola sha256 e size streaming per non tenere tutto in RAM + hasher = hashlib.sha256() + size = 0 + # Salva in tmp poi rename atomico + target_dir = BASE_PATH / str(application_id) / entity_type / str(entity_id) + target_dir.mkdir(parents=True, exist_ok=True) + tmp_path = target_dir / f".tmp-{entity_id}" + + try: + with open(tmp_path, "wb") as out: + while True: + chunk = file_obj.read(65536) + if not chunk: + break + size += len(chunk) + if size > MAX_SIZE_BYTES: + raise FileTooLargeError( + f"File {size} byte oltre limite {MAX_SIZE_BYTES}" + ) + hasher.update(chunk) + out.write(chunk) + + digest = hasher.hexdigest() + final_name = f"{digest[:12]}-{safe_name}" + final_path = target_dir / final_name + + # Se già esiste (dedup per sha+nome) rimuovi tmp e usa esistente + if final_path.exists(): + tmp_path.unlink(missing_ok=True) + else: + os.replace(tmp_path, final_path) + + rel_path = str(final_path.relative_to(BASE_PATH)) + return rel_path, size, digest, mime, safe_name + except Exception: + # cleanup tmp in caso di errore + try: + tmp_path.unlink(missing_ok=True) + except Exception: + pass + raise + + +def delete_file(storage_path: str) -> bool: + """Elimina fisicamente il file. Ritorna True se rimosso.""" + if not storage_path: + return False + abs_path = BASE_PATH / storage_path + # hardening: resta sotto BASE_PATH + try: + abs_path.resolve().relative_to(BASE_PATH.resolve()) + except ValueError: + raise StorageError(f"Path fuori da BASE_PATH: {storage_path}") + if abs_path.exists(): + abs_path.unlink() + # prova a rimuovere directory vuote (entity_id/entity_type) + try: + abs_path.parent.rmdir() + abs_path.parent.parent.rmdir() + except OSError: + pass + return True + return False + + +def open_file(storage_path: str) -> Path: + """Ritorna Path assoluto del file. Solleva FileNotFoundError se mancante.""" + if not storage_path: + raise FileNotFoundError("storage_path vuoto") + abs_path = BASE_PATH / storage_path + try: + abs_path.resolve().relative_to(BASE_PATH.resolve()) + except ValueError: + raise StorageError(f"Path fuori da BASE_PATH: {storage_path}") + if not abs_path.is_file(): + raise FileNotFoundError(f"File non trovato: {storage_path}") + return abs_path