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
366 lines
13 KiB
Python
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
|
|
}
|
|
)
|