Files
gepafin-rendicontazione-api/app/routers/files.py
BFLOWS 7c8de6aec8 feat(docs): link documenti dal repository company + gate submit su EXPIRED
Implementa il riutilizzo dei documenti caricati in fase domanda (gepafin_schema.company_document):
il benef puo selezionarli dal picker repository invece di caricarli dal PC, ereditando
filename/expires_at/storage_path. Tracciato via source_company_document_id per lookup
live dello stato (VALID/DUE/EXPIRED).

Modifiche:
- migrations.py: ALTER TABLE remission_document ADD source_company_document_id + index partial
- models.py: aggiunto campo source_company_document_id su RemissionDocument
- schemas.py: esposto source_company_document_id in DocumentUpsert + DocumentOut
- routers/files.py: nuovo POST /document/{id}/link-from-repository — verifica ownership
  company, pulisce file PC precedente, copia metadati dal sorgente, ritorna source_status
- routers/practices.py: nuovo check documents_not_expired in _compute_gate_check —
  JOIN live su gepafin_schema.company_document.status per doc linkati, controllo expires_at
  per upload diretti. Gate hard: documento EXPIRED blocca submit (422).

Test E2E verificati via curl/JWT offline:
- link VALID → metadati copiati, gate passed
- link EXPIRED → gate overall FAIL con detail 'Scaduti: DURC'
- re-link VALID → gate torna passed
- submit bloccato solo su check non-doc (fatture/altri doc mancanti), docs_not_expired OK

Seed sandbox: 4 document_category + 5 company_document su NAPOLI SAS (3 VALID / 1 DUE / 1 EXPIRED).
2026-04-20 18:47:03 +02:00

366 lines
13 KiB
Python

"""
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 pydantic import BaseModel
from fastapi.responses import FileResponse, Response
from sqlalchemy import text
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")
# ---------- Link da repository company ----------
# 2026-04-20: riutilizzo documenti caricati in fase domanda.
# Il benef seleziona un documento dal proprio repository company invece di caricarlo
# dal PC. Non c'e upload fisico: copiamo solo i metadati (filename, expires_at,
# storage_path per preview/download) e tracciamo source_company_document_id per
# permettere lookup live dello status sorgente (VALID/DUE/EXPIRED).
class LinkFromRepositoryRequest(BaseModel):
company_document_id: int
@router.post("/document/{entity_id}/link-from-repository", response_model=ApiResponse)
def link_document_from_repository(
entity_id: UUID,
body: LinkFromRepositoryRequest,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""
Associa un remission_document esistente a un company_document del repository
della fase domanda. Sostituisce eventuali file precedenti caricati dal PC
(elimina dallo storage, azzera storage_path).
"""
# 1) carica il remission_document e verifica permesso upload (benef owner o admin)
entity = db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first()
if not entity:
raise HTTPException(status_code=404, detail="Documento non trovato")
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")
# pratica editabile solo in DRAFT (stessa regola dell'upload)
if practice.status != "DRAFT":
raise HTTPException(status_code=409, detail=f"Pratica in stato {practice.status}: non modificabile")
# 2) leggi il company_document (deve esistere, stessa company della pratica, non eliminato)
row = db.execute(text("""
SELECT id, file_name, file_path, type, status, expiration_date, company_id
FROM gepafin_schema.company_document
WHERE id = :cid AND is_deleted = false
"""), {"cid": body.company_document_id}).mappings().first()
if not row:
raise HTTPException(status_code=404, detail=f"company_document {body.company_document_id} non trovato")
if row["company_id"] != practice.company_id:
raise HTTPException(
status_code=403,
detail="Documento repository non appartiene alla company di questa pratica"
)
# 3) se c'era un file fisico caricato dal PC in precedenza, lo rimuoviamo per pulizia
if entity.storage_path and not entity.source_company_document_id:
try:
delete_file(entity.storage_path)
except Exception:
pass # non bloccare se il file non c'e piu
# 4) aggiorna metadati con quelli del repository
from datetime import datetime, timezone, date
entity.source_company_document_id = row["id"]
entity.filename = row["file_name"]
entity.storage_path = row["file_path"] # riuso del path fisico del BE per preview/download
entity.mime = None
entity.size_bytes = None
entity.sha256 = None
entity.uploaded_by = user.user_id
entity.uploaded_at = datetime.now(timezone.utc)
# scadenza dal sorgente (timestamp -> date)
exp = row["expiration_date"]
if exp is not None:
entity.expires_at = exp.date() if hasattr(exp, 'date') else exp
else:
entity.expires_at = None
db.commit()
db.refresh(entity)
return ApiResponse(
success=True,
message=f"Documento collegato dal repository (source_status={row['status']})",
data={
"id": str(entity.id),
"doc_code": entity.doc_code,
"filename": entity.filename,
"source_company_document_id": entity.source_company_document_id,
"expires_at": entity.expires_at.isoformat() if entity.expires_at else None,
"source_status": row["status"], # VALID | DUE | EXPIRED — per UI semaforo
}
)