feat(amendment): ROUND 2 — scheduler + upload documenti
Seconda parte della replica soccorso istruttorio speculare al BE Gepafin.
Completata: scheduler cron (expire + reminder), upload documenti istruttore
e benef, fix duplicati config.
==SCHEDULER (app/scheduler.py NUOVO)==
APScheduler BackgroundScheduler integrato nel lifespan FastAPI.
Due cron attivi (timezone Europe/Rome):
expire_amendments() - cron 01:05 ogni notte
Speculare a ApplicationAmendmentScheduler.processAmendmentExpirationScheduler.
Trova amendment AWAITING con deadline < today, passa a EXPIRED.
Rimette pratica a UNDER_REVIEW se non ha altri amendment aperti.
Ritorna dict stats per logging/test.
queue_reminders() - cron 09:00 ogni mattina
Speculare a ExpirationScheduler.processAmendmentExpiration (data-driven).
Legge remission_expiration_config (type='AMENDMENT', interval_days=N),
per ogni riga trova amendment con deadline esattamente today+N e setta
pec_retry_after (marker che il BE vede via /internal pending-reminder).
Multipli row = multipli reminder (seed: 7gg + 2gg).
Il microservizio aggiorna solo stato DB. L invio effettivo di email
reminder lo fa il BE Gepafin tramite polling, tenant-aware.
==UPLOAD DOCUMENTI==
3 nuovi endpoint nel router istruttoria:
POST /instructor/{pid}/amendment/{aid}/upload-document
- Istruttore allega PDF al soccorso (motivazione, scheda tecnica).
- Consentito in DRAFT o AWAITING. Sostituisce precedente se esiste.
- Popola amendment_document_path + amendment_document_type.
DELETE /instructor/{pid}/amendment/{aid}/upload-document
- Rimuove allegato (solo in DRAFT).
POST /instructor/{pid}/amendment/{aid}/upload-response-document
- Benef allega PDF come supporto alla risposta.
- Consentito in AWAITING/RESPONSE_RECEIVED, solo proprietario.
- Popola response_document_path + response_document_type.
Riusa save_upload() esistente con entity_type dedicati.
==FIX storage.py==
Whitelist entity_type estesa con 'amendment-instructor-doc' +
'amendment-response-doc' (prima accettava solo invoice/ula/document,
bloccando l'upload con StorageError).
==FIX migration dedup==
Scoperto in test: migration 8 faceva INSERT ON CONFLICT DO NOTHING su
remission_expiration_config ma senza UNIQUE constraint. Ogni restart
inseriva duplicati (16 righe in DB invece di 2). Fix in migration 9:
DELETE duplicati + ADD UNIQUE (type, interval_days) + re-seed pulito.
==REQUIREMENTS==
APScheduler==3.10.4
==TEST E2E==
/tmp/test_amendment_r2_fixed.py passa su tutto:
[A] upload amendment_document istruttore + response_document benef + respond
[B] amendment scaduto artificiale -> expire_amendments lo marca EXPIRED,
pratica torna UNDER_REVIEW
[C] amendment a +2gg e +7gg -> queue_reminders accoda 2 reminder,
/internal pending-reminder li espone entrambi
This commit is contained in:
@@ -6,12 +6,13 @@ from decimal import Decimal
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, or_, and_
|
||||
|
||||
from ..db import get_db
|
||||
from ..auth import AuthUser, get_current_user
|
||||
from ..storage import save_upload, FileTooLargeError, MimeNotAllowedError, StorageError
|
||||
from ..models import RemissionPractice, RemissionAmendmentRequest
|
||||
from ..schemas import (
|
||||
AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus,
|
||||
@@ -384,6 +385,59 @@ def close_amendment(practice_id: UUID, amendment_id: UUID,
|
||||
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/{practice_id}/amendment/{amendment_id}/upload-document", response_model=ApiResponse)
|
||||
async def upload_amendment_document(practice_id: UUID, amendment_id: UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(_require_instructor)):
|
||||
"""Allega documento dell'istruttore al soccorso (motivazione, scheda tecnica, ecc.).
|
||||
Consentito in DRAFT o AWAITING. Sostituisce il precedente se esiste."""
|
||||
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||
if ar.status not in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value):
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"Upload consentito in DRAFT/AWAITING (attuale: {ar.status})")
|
||||
p = _get_practice_or_404(db, practice_id)
|
||||
try:
|
||||
rel_path, size, digest, mime, safe_name = save_upload(
|
||||
application_id=p.application_id,
|
||||
entity_type="amendment-instructor-doc",
|
||||
entity_id=ar.id,
|
||||
file_obj=file.file,
|
||||
original_filename=file.filename or "amendment.pdf",
|
||||
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}")
|
||||
|
||||
ar.amendment_document_path = rel_path
|
||||
ar.amendment_document_type = mime
|
||||
db.commit()
|
||||
db.refresh(ar)
|
||||
return ApiResponse(message="Documento allegato al soccorso",
|
||||
data={"amendment_id": str(ar.id), "path": rel_path,
|
||||
"filename": safe_name, "size_bytes": size, "mime": mime})
|
||||
|
||||
|
||||
@router.delete("/{practice_id}/amendment/{amendment_id}/upload-document", response_model=ApiResponse)
|
||||
def delete_amendment_document(practice_id: UUID, amendment_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(_require_instructor)):
|
||||
"""Rimuove il documento istruttore allegato al soccorso (consentito solo in DRAFT)."""
|
||||
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||
if ar.status != AmendmentStatus.DRAFT.value:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"Rimozione allegato consentita solo in DRAFT (attuale: {ar.status})")
|
||||
ar.amendment_document_path = None
|
||||
ar.amendment_document_type = None
|
||||
db.commit()
|
||||
return ApiResponse(message="Documento allegato rimosso",
|
||||
data={"amendment_id": str(ar.id)})
|
||||
|
||||
|
||||
# Endpoint beneficiario: visualizza amendments sulla sua pratica + risponde
|
||||
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
|
||||
def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
|
||||
@@ -556,3 +610,44 @@ def set_instructor_final_notes(practice_id: UUID, body: InstructorFinalNotesBody
|
||||
db.refresh(p)
|
||||
return ApiResponse(message="Verbale aggiornato",
|
||||
data=PracticeOut.model_validate(p).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/{practice_id}/amendment/{amendment_id}/upload-response-document", response_model=ApiResponse)
|
||||
async def upload_response_document(practice_id: UUID, amendment_id: UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(get_current_user)):
|
||||
"""Beneficiario allega un documento come supporto alla sua risposta al soccorso.
|
||||
Consentito su amendment in stato AWAITING, solo dal proprietario pratica."""
|
||||
p = _get_practice_or_404(db, practice_id)
|
||||
if user.is_beneficiary() and p.user_id != user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
|
||||
|
||||
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||
if ar.status not in (AmendmentStatus.AWAITING.value, AmendmentStatus.RESPONSE_RECEIVED.value):
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"Upload risposta consentito solo in AWAITING/RESPONSE_RECEIVED (attuale: {ar.status})")
|
||||
|
||||
try:
|
||||
rel_path, size, digest, mime, safe_name = save_upload(
|
||||
application_id=p.application_id,
|
||||
entity_type="amendment-response-doc",
|
||||
entity_id=ar.id,
|
||||
file_obj=file.file,
|
||||
original_filename=file.filename or "response.pdf",
|
||||
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}")
|
||||
|
||||
ar.response_document_path = rel_path
|
||||
ar.response_document_type = mime
|
||||
db.commit()
|
||||
db.refresh(ar)
|
||||
return ApiResponse(message="Documento risposta allegato",
|
||||
data={"amendment_id": str(ar.id), "path": rel_path,
|
||||
"filename": safe_name, "size_bytes": size, "mime": mime})
|
||||
|
||||
Reference in New Issue
Block a user