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.
This commit is contained in:
183
app/routers/assignment.py
Normal file
183
app/routers/assignment.py
Normal file
@@ -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,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user