Files
gepafin-rendicontazione-api/app/routers/assignment.py
BFLOWS 86681678c4 feat(v2): endpoint multi-tranche + custom_checks + assignment manager
A4 /mine + /start + /copy-ula-options (practices.py):
- GET /mine raggruppa per application_id, ogni app ha:
  tranches[], max_tranches, can_start_new, start_blocked_reason,
  already_approved_sum, max_remission_global, max_remission_next_tranche
- POST /start valida: count<max, last terminale, residuo>0 -> 400 con detail parlante
- Bulk copy ULA da tranche N-1 se copy_ula_from_previous=true (reset verification_*)
- Legge suggested_instructor da gepafin_schema.assigned_applications (solo tranche 1)
- upgrade_schema_to_v2 al snapshot per allineare a v2 schemi vecchi
- GET /{id}/copy-ula-options: preview ULA tranche N-1 per pre-fill FE

A5 custom_checks.py (nuovo router):
- GET /{id}/custom-checks merge definition+values con defaults
- PUT /.../declare (beneficiary form-data + optional file upload 15MB, PDF/JPG/PNG)
  storage dedicato /var/uploads/custom_checks/{practice_id}/{code}/<sha12>-file
- DELETE /.../document (beneficiary) reset metadata + cleanup FS
- PUT /.../verify (istruttore) VALIDO/NON_VALIDO/PENDING + notes
- GET /.../document?inline=0|1 stream con Content-Disposition
- Matrix autorizzazioni: declare solo benef su DRAFT/AWAITING, verify solo istruttore

A6 assignment.py (nuovo router, manager view):
- GET /instructor-manager/assignments: pratiche attive con suggested+assigned+is_unassigned
- GET /instructor-manager/instructors: elenco PRE_INSTRUCTOR+MANAGER per dropdown riassegna
- POST /instructor/{id}/reassign: cambio assigned_instructor_id + audit log in
  instructor_checklist.reassignment_log [{at,by_user_id,from,to,reason}]
- Solo ROLE_INSTRUCTOR_MANAGER + ROLE_SUPER_ADMIN

Test curl E2E tutti passati: tranche 2 creata con copy 2 ULA, check dichiarati+verificati,
download PDF polizza OK, reassign con audit log scritto.
2026-04-18 17:35:56 +02:00

184 lines
7.0 KiB
Python

"""
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,
}
)