diff --git a/app/routers/assignment.py b/app/routers/assignment.py new file mode 100644 index 0000000..dabab93 --- /dev/null +++ b/app/routers/assignment.py @@ -0,0 +1,183 @@ +""" +Endpoint v2 per gestione assegnazione istruttori (capo istruttore / manager). +Solo ROLE_INSTRUCTOR_MANAGER + ROLE_SUPER_ADMIN. +""" +from datetime import datetime, timezone +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ..db import get_db +from ..auth import AuthUser, get_current_user +from ..models import RemissionPractice +from ..schemas import ApiResponse, PracticeReassignBody + +router = APIRouter( + prefix="/api/remission-practices", + tags=["assignment-manager"], +) + + +def _require_manager(user: AuthUser = Depends(get_current_user)) -> AuthUser: + if user.role not in ("ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN"): + raise HTTPException( + status_code=403, + detail="Richiesto ruolo manager o superadmin" + ) + return user + + +@router.get("/instructor-manager/assignments", response_model=ApiResponse) +def assignments_overview( + db: Session = Depends(get_db), + manager: AuthUser = Depends(_require_manager), +): + """Vista capo istruttore: pratiche con suggested + assigned + flag 'da assegnare'.""" + practices = db.query(RemissionPractice).filter( + RemissionPractice.status.in_(["SUBMITTED", "UNDER_REVIEW", "AWAITING_AMENDMENT"]) + ).order_by( + RemissionPractice.application_id, + RemissionPractice.sequence_number + ).all() + + # Enrichment: nome istruttori + company + items = [] + user_cache: dict = {} + + def _user_name(uid: Optional[int]) -> Optional[str]: + if uid is None: + return None + if uid in user_cache: + return user_cache[uid] + row = db.execute(text(""" + SELECT first_name || ' ' || last_name as name, email + FROM gepafin_schema.gepafin_user WHERE id = :uid + """), {"uid": uid}).mappings().first() + name = (row["name"] if row else None) or (row["email"] if row else None) + user_cache[uid] = name + return name + + for p in practices: + company_row = db.execute(text(""" + SELECT company_name, vat_number FROM gepafin_schema.company WHERE id = :cid + """), {"cid": p.company_id}).mappings().first() + call_row = db.execute(text(""" + SELECT name FROM gepafin_schema.call WHERE id = :cid + """), {"cid": p.call_id}).mappings().first() + + items.append({ + "id": str(p.id), + "application_id": p.application_id, + "sequence_number": p.sequence_number, + "period_label": p.period_label, + "call_id": p.call_id, + "call_name": call_row["name"] if call_row else None, + "company_id": p.company_id, + "company_name": company_row["company_name"] if company_row else None, + "status": p.status, + "submitted_at": p.submitted_at.isoformat() if p.submitted_at else None, + "amount_erogato": float(p.amount_erogato or 0), + "suggested_instructor_id": p.suggested_instructor_id, + "suggested_instructor_name": _user_name(p.suggested_instructor_id), + "assigned_instructor_id": p.assigned_instructor_id, + "assigned_instructor_name": _user_name(p.assigned_instructor_id), + "is_unassigned": p.assigned_instructor_id is None, + }) + + return ApiResponse(data={"assignments": items}) + + +@router.get("/instructor-manager/instructors", response_model=ApiResponse) +def list_available_instructors( + db: Session = Depends(get_db), + manager: AuthUser = Depends(_require_manager), +): + """Elenco istruttori disponibili per riassegnazione (pre_instructor + manager ACTIVE).""" + rows = db.execute(text(""" + SELECT u.id, u.email, u.first_name, u.last_name, r.role_type + FROM gepafin_schema.gepafin_user u + JOIN gepafin_schema.role r ON r.id = u.role_id + WHERE u.is_deleted = false + AND r.role_type IN ('ROLE_PRE_INSTRUCTOR', 'ROLE_INSTRUCTOR_MANAGER') + ORDER BY u.last_name, u.first_name + """)).mappings().all() + return ApiResponse(data={"instructors": [ + { + "user_id": r["id"], + "email": r["email"], + "first_name": r["first_name"], + "last_name": r["last_name"], + "role_type": r["role_type"], + "display_name": f"{r['first_name'] or ''} {r['last_name'] or ''}".strip() or r["email"], + } for r in rows + ]}) + + +@router.post("/instructor/{practice_id}/reassign", response_model=ApiResponse) +def reassign_instructor( + practice_id: UUID, + body: PracticeReassignBody, + db: Session = Depends(get_db), + manager: AuthUser = Depends(_require_manager), +): + """Manager assegna/riassegna la pratica a un istruttore diverso (o unassign se new_instructor_id=None). + Scrive audit entry in instructor_checklist.reassignment_log. + """ + p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first() + if not p: + raise HTTPException(status_code=404, detail="Pratica non trovata") + + old_instructor_id = p.assigned_instructor_id + + # Verifica nuovo istruttore se specificato + if body.new_instructor_id is not None: + row = db.execute(text(""" + SELECT u.id, r.role_type + FROM gepafin_schema.gepafin_user u + JOIN gepafin_schema.role r ON r.id = u.role_id + WHERE u.id = :uid AND u.is_deleted = false + """), {"uid": body.new_instructor_id}).mappings().first() + if not row: + raise HTTPException(status_code=404, + detail=f"Istruttore {body.new_instructor_id} non trovato") + if row["role_type"] not in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER"): + raise HTTPException(status_code=422, + detail="Utente non ha ruolo istruttore") + + # Audit log + checklist = dict(p.instructor_checklist or {}) + log = list(checklist.get("reassignment_log") or []) + log.append({ + "at": datetime.now(timezone.utc).isoformat(), + "by_user_id": manager.user_id, + "by_email": manager.email, + "from_instructor_id": old_instructor_id, + "to_instructor_id": body.new_instructor_id, + "reason": body.reassignment_reason, + }) + checklist["reassignment_log"] = log + + p.assigned_instructor_id = body.new_instructor_id + p.instructor_checklist = checklist + # Se passo da SUBMITTED + assegnato -> UNDER_REVIEW + # Altrimenti lascio status invariato (manager puo riassegnare anche durante review) + if p.status == "SUBMITTED" and body.new_instructor_id is not None: + p.status = "UNDER_REVIEW" + + db.commit() + db.refresh(p) + + action = "unassigned" if body.new_instructor_id is None else f"assigned to {body.new_instructor_id}" + return ApiResponse( + message=f"Pratica {action}", + data={ + "id": str(p.id), + "status": p.status, + "assigned_instructor_id": p.assigned_instructor_id, + "suggested_instructor_id": p.suggested_instructor_id, + "reassignment_log": log, + } + ) diff --git a/app/routers/custom_checks.py b/app/routers/custom_checks.py new file mode 100644 index 0000000..4d04d1c --- /dev/null +++ b/app/routers/custom_checks.py @@ -0,0 +1,319 @@ +""" +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}/-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_beneficiary() and p.user_id != user.user_id: + raise HTTPException(status_code=403, detail="Accesso negato") + if not user.is_beneficiary() 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_beneficiary(): + 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: - + 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///- — 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}"'}, + )