Files
gepafin-rendicontazione-api/app/routers/custom_checks.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

320 lines
13 KiB
Python

"""
Endpoint custom_checks v2: dichiarazione beneficiario + documento opzionale + verifica istruttore.
Merge definition (da schema_snapshot.custom_checks[]) + value (RemissionCustomCheckValue).
Path storage custom_checks: /var/uploads/custom_checks/{practice_id}/{code}/<sha12>-file.pdf
(fuori dal pattern invoice/ula/document per isolarli — non confondibili con allegati fattura/LUL).
"""
import io
import os
from datetime import datetime, timezone
from typing import List, Optional, Literal
from uuid import UUID
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice, RemissionCustomCheckValue
from ..schemas import ApiResponse, CustomCheckOut, CustomCheckVerifyBody
from ..storage import (
save_upload, delete_file, open_file,
FileTooLargeError, MimeNotAllowedError, StorageError, BASE_PATH,
)
router = APIRouter(prefix="/api/remission-practices", tags=["custom-checks"])
def _is_instructor(user: AuthUser) -> bool:
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
def _get_practice(db: Session, practice_id: UUID, user: AuthUser) -> RemissionPractice:
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
if not p:
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
# Autorizzazione base: beneficiario owner o istruttore
if user.is_owner_role() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Accesso negato")
if not user.is_owner_role() and not _is_instructor(user):
raise HTTPException(status_code=403, detail="Ruolo non autorizzato")
return p
def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool:
"""Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT."""
if not user.is_owner_role():
return False
if practice.user_id != user.user_id:
return False
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
def _can_verify(user: AuthUser, practice: RemissionPractice) -> bool:
if not _is_instructor(user):
return False
return practice.status in ("UNDER_REVIEW", "AWAITING_AMENDMENT")
def _schema_check_defs(practice: RemissionPractice) -> List[dict]:
return practice.schema_snapshot.get("custom_checks") or []
def _merge_check(definition: dict, value: Optional[RemissionCustomCheckValue]) -> dict:
out = {
"code": definition.get("code"),
"label": definition.get("label"),
"description": definition.get("description"),
"requires_document": bool(definition.get("requires_document", False)),
"required": bool(definition.get("required", False)),
# valori default
"beneficiary_declared": False,
"declared_at": None,
"filename_original": None,
"storage_path": None,
"size_bytes": None,
"document_uploaded_at": None,
"verification_status": "PENDING",
"verification_notes": None,
"verified_by": None,
"verified_at": None,
}
if value is not None:
out.update({
"beneficiary_declared": value.beneficiary_declared,
"declared_at": value.declared_at,
"storage_path": value.storage_path,
"size_bytes": value.size_bytes,
"document_uploaded_at": value.document_uploaded_at,
"verification_status": value.verification_status,
"verification_notes": value.verification_notes,
"verified_by": value.verified_by,
"verified_at": value.verified_at,
})
# filename originale ricostruito dal path (dopo il sha12-)
if value.storage_path:
basename = Path(value.storage_path).name
# formato: <sha12>-<original>
parts = basename.split("-", 1)
out["filename_original"] = parts[1] if len(parts) == 2 else basename
return out
def _get_or_create_value(db: Session, practice_id: UUID, code: str) -> RemissionCustomCheckValue:
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v:
v = RemissionCustomCheckValue(practice_id=practice_id, check_code=code)
db.add(v)
db.flush()
return v
# ---------- endpoints ----------
@router.get("/{practice_id}/custom-checks", response_model=ApiResponse)
def list_custom_checks(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)):
"""Ritorna i custom_checks della pratica: schema definition + valori correnti."""
p = _get_practice(db, practice_id, user)
defs = _schema_check_defs(p)
values_by_code = {v.check_code: v for v in p.custom_checks}
out = [_merge_check(d, values_by_code.get(d.get("code"))) for d in defs]
return ApiResponse(data={"custom_checks": out})
@router.put("/{practice_id}/custom-checks/{code}/declare", response_model=ApiResponse)
async def declare_custom_check(
practice_id: UUID,
code: str,
beneficiary_declared: bool = Form(...),
file: Optional[UploadFile] = File(None),
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Beneficiario dichiara il check (bool) e opzionalmente allega un documento.
Se requires_document=true nello schema, l'upload e raccomandato ma non imposto
lato server (la required-ness e un gate su /submit)."""
p = _get_practice(db, practice_id, user)
if not _can_declare(user, p):
raise HTTPException(
status_code=403,
detail="Solo beneficiario owner su pratica DRAFT o AWAITING_AMENDMENT puo dichiarare"
)
defs = {d["code"]: d for d in _schema_check_defs(p)}
if code not in defs:
raise HTTPException(status_code=404, detail=f"Custom check '{code}' non definito nello schema")
v = _get_or_create_value(db, p.id, code)
v.beneficiary_declared = bool(beneficiary_declared)
v.declared_at = datetime.now(timezone.utc) if beneficiary_declared else None
# Se arriva un file sostituisce l'eventuale esistente
if file is not None and file.filename:
try:
# path custom_checks/<practice_id>/<code>/<sha12>-<name> — sfrutto storage_adapter
# con entity_type "document" fittizio e un app_id = practice_id (sfrutto la dir)
# In alternativa faccio path custom scrivendolo direttamente qui.
# Scelgo via diretta per evitare collisione con document reale.
from hashlib import sha256
size = 0
hasher = sha256()
content = b""
while True:
chunk = await file.read(65536)
if not chunk:
break
content += chunk
size += len(chunk)
if size > 15 * 1024 * 1024:
raise HTTPException(status_code=413, detail="File troppo grande (max 15 MB)")
hasher.update(chunk)
mime = (file.content_type or "").lower().split(";")[0].strip()
if mime not in ("application/pdf", "image/jpeg", "image/png"):
raise HTTPException(
status_code=415,
detail=f"MIME non consentito: {mime}. Accettati: pdf, jpeg, png"
)
digest = hasher.hexdigest()
# sanitize filename
safe = "".join(c if (c.isalnum() or c in "-_.() ") else "_" for c in file.filename).strip().replace(" ", "_")
if len(safe) > 120:
root, ext = os.path.splitext(safe)
safe = root[:120 - len(ext)] + ext
target_dir = BASE_PATH / "custom_checks" / str(p.id) / code
target_dir.mkdir(parents=True, exist_ok=True)
final_path = target_dir / f"{digest[:12]}-{safe}"
final_path.write_bytes(content)
# Rimuovi eventuale file precedente (path diverso)
if v.storage_path and Path(BASE_PATH / v.storage_path) != final_path:
try:
delete_file(v.storage_path)
except Exception:
pass
v.storage_path = str(final_path.relative_to(BASE_PATH))
v.mime = mime
v.size_bytes = size
v.sha256 = digest
v.document_uploaded_at = datetime.now(timezone.utc)
v.uploaded_by = user.user_id
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Errore upload: {e}")
# Reset eventuale validazione precedente (beneficiario ha cambiato qualcosa)
v.verification_status = "PENDING"
v.verification_notes = None
v.verified_by = None
v.verified_at = None
db.commit()
db.refresh(v)
defs_by_code = {d["code"]: d for d in _schema_check_defs(p)}
return ApiResponse(message="Check aggiornato", data=_merge_check(defs_by_code[code], v))
@router.delete("/{practice_id}/custom-checks/{code}/document", response_model=ApiResponse)
def delete_custom_check_document(
practice_id: UUID, code: str,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Beneficiario rimuove il documento allegato (dichiarazione resta)."""
p = _get_practice(db, practice_id, user)
if not _can_declare(user, p):
raise HTTPException(status_code=403, detail="Non autorizzato")
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v or not v.storage_path:
return ApiResponse(message="Nessun documento da rimuovere")
try:
delete_file(v.storage_path)
except Exception:
pass
v.storage_path = None
v.mime = None
v.size_bytes = None
v.sha256 = None
v.document_uploaded_at = None
v.verification_status = "PENDING" # reset verify
v.verification_notes = None
db.commit()
return ApiResponse(message="Documento rimosso")
@router.put("/{practice_id}/custom-checks/{code}/verify", response_model=ApiResponse)
def verify_custom_check(
practice_id: UUID, code: str,
body: CustomCheckVerifyBody,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Istruttore valida il check (VALIDO | NON_VALIDO | PENDING)."""
p = _get_practice(db, practice_id, user)
if not _can_verify(user, p):
raise HTTPException(status_code=403, detail="Solo istruttore su pratica in lavorazione")
if body.verification_status not in ("PENDING", "VALIDO", "NON_VALIDO"):
raise HTTPException(status_code=422, detail="verification_status non valido")
defs = {d["code"]: d for d in _schema_check_defs(p)}
if code not in defs:
raise HTTPException(status_code=404, detail=f"Check '{code}' non nello schema")
v = _get_or_create_value(db, p.id, code)
v.verification_status = body.verification_status
v.verification_notes = body.verification_notes
v.verified_by = user.user_id
v.verified_at = datetime.now(timezone.utc) if body.verification_status != "PENDING" else None
db.commit()
db.refresh(v)
return ApiResponse(message="Check verificato", data=_merge_check(defs[code], v))
@router.get("/{practice_id}/custom-checks/{code}/document")
def download_custom_check_document(
practice_id: UUID, code: str,
inline: int = 0,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Download del documento allegato (stream con Content-Disposition)."""
from fastapi.responses import FileResponse
p = _get_practice(db, practice_id, user)
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v or not v.storage_path:
raise HTTPException(status_code=404, detail="Nessun documento allegato")
try:
abs_path = open_file(v.storage_path)
except FileNotFoundError:
raise HTTPException(status_code=410, detail="File non piu disponibile su storage")
basename = Path(v.storage_path).name
parts = basename.split("-", 1)
filename = parts[1] if len(parts) == 2 else basename
disp = "inline" if inline else "attachment"
return FileResponse(
path=str(abs_path),
media_type=v.mime or "application/octet-stream",
headers={"Content-Disposition": f'{disp}; filename="{filename}"'},
)