Files
gepafin-rendicontazione-api/app/routers/files.py
BFLOWS 83bb0a29ec feat(auth): autorizza ROLE_CONFIDI come proprietario pratica (parallelo BENEFICIARY)
Risoluzione 403 segnalato da Rinaldo Bonazzo su upload fattura con utente
ROLE_CONFIDI (confidi4@test.test). Pattern allineato al BE Gepafin che
in DashboardDao, CompanyDocumentDao e FaqDao raggruppa BENEFICIARY+CONFIDI
con stessi diritti operativi sulla pratica.

==RAZIONALE==
Sui bandi con call.confidi=true il confidi sottomette la application
per conto dell'azienda e diventa user_id della application. Lato
microservizio rendicontazione la pratica viene ereditata con stesso
user_id, quindi il confidi e proprietario della pratica e deve poter
fare upload/download/delete come il beneficiario.

==MODIFICHE==

app/auth.py:
- Aggiunto AuthUser.is_confidi() — controlla ROLE_CONFIDI
- Aggiunto AuthUser.is_owner_role() — True per BENEFICIARY o CONFIDI
- Aggiornato docstring header con ROLE_CONFIDI
- Manteno is_beneficiary() per backward compat (non rimosso, non chiamato)

Sostituzione is_beneficiary() -> is_owner_role() in 11 punti dove la
semantica era 'proprietario pratica':
- app/routers/files.py: 3 (_can_upload, _can_download, _can_delete)
- app/routers/instructor.py: 2 (respond-beneficiary, ack-amendment)
- app/routers/practices.py: 3 (visibilita, create, schema gating)
- app/routers/custom_checks.py: 3 (declared, gate)

==COMPORTAMENTO==

Per ROLE_CONFIDI vale ora la stessa regola di BENEFICIARY:
- upload/download/delete: solo se practice.user_id == user.user_id
  AND practice.status IN ('DRAFT','AWAITING_AMENDMENT')
- respond-beneficiary: solo se proprietario pratica
- visualizzazione: solo proprie pratiche
- creazione: solo se schema PUBLISHED

Confidi su pratica di altri o su pratica non editabile -> 403 come prima.

==TEST E2E (4 step verdi)==
/tmp/test_confidi_upload.py:
1. CONFIDI proprietario DRAFT upload Invoice_zapier2024.pdf -> 200 (era 403)
2. CONFIDI NON proprietario -> 403 (scoping)
3. CONFIDI proprietario ma SUBMITTED -> 403 (stato)
4. BENEFICIARY proprietario DRAFT (regressione) -> 200
2026-04-27 09:06:10 +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_owner_role() 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_owner_role() 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_owner_role() 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
}
)