"""Scheduler per lifecycle amendment. Due cron job attivi: - expire_amendments(): ogni giorno 01:05 — trova amendment AWAITING con deadline < today, le passa a EXPIRED, rimette pratica a UNDER_REVIEW se non ci sono altri amendment aperti. Equivalente a ApplicationAmendmentScheduler.processAmendmentExpirationScheduler del BE. - queue_reminders(): ogni giorno 09:00 — legge remission_expiration_config (type='AMENDMENT', interval_days=N), trova amendment AWAITING con deadline == today + N giorni, setta flag reminder_queued_at sul record. Equivalente a ExpirationScheduler.processAmendmentExpiration del BE (data-driven da expiration_config). Le notifiche effettive (PEC reminder benef + email interna istruttore) le invia il BE Gepafin via polling sui nostri endpoint /internal. Il microservizio resta sender-agnostico. """ import logging from datetime import datetime, timezone, date, timedelta from typing import Optional from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from sqlalchemy.orm import Session from sqlalchemy import text from .db import SessionLocal from .models import RemissionAmendmentRequest, RemissionPractice, RemissionExpirationConfig log = logging.getLogger("rendicontazione-api.scheduler") def expire_amendments() -> dict: """Espira amendment AWAITING con deadline passata. Ritorna dict {expired_count, practices_reopened} per logging/test.""" db: Session = SessionLocal() today = date.today() stats = {"expired_count": 0, "practices_reopened": 0} try: expired_ars = db.query(RemissionAmendmentRequest).filter( RemissionAmendmentRequest.status == "AWAITING", RemissionAmendmentRequest.deadline < today, ).all() practice_ids_to_check = set() for ar in expired_ars: ar.status = "EXPIRED" practice_ids_to_check.add(ar.practice_id) stats["expired_count"] += 1 log.info(f"Amendment {ar.id} EXPIRED (deadline era {ar.deadline})") db.flush() for pid in practice_ids_to_check: p = db.query(RemissionPractice).filter(RemissionPractice.id == pid).first() if not p: continue others_open = [ a for a in p.amendment_requests if a.status in ("DRAFT", "AWAITING", "RESPONSE_RECEIVED") ] if not others_open and p.status == "AWAITING_AMENDMENT": p.status = "UNDER_REVIEW" stats["practices_reopened"] += 1 log.info(f"Pratica {pid} ritornata a UNDER_REVIEW (amendment scaduto, nessun altro aperto)") db.commit() log.info(f"expire_amendments: {stats}") except Exception as e: db.rollback() log.error(f"expire_amendments FAILED: {e}", exc_info=True) raise finally: db.close() return stats def queue_reminders() -> dict: """Legge config data-driven, per ogni interval_days trova amendment con deadline == today + N, scrive flag reminder_queued_at sull'amendment. Il BE vedra questi amendment come pending-reminder via /internal. Usiamo campo pec_retry_after come marker unificato (gia presente): - NULL: nessun reminder accodato - timestamp: reminder accodato in questo momento, BE invia al prossimo poll Ritorna dict {reminders_queued_by_interval}.""" db: Session = SessionLocal() today = date.today() stats = {"reminders_queued": 0, "by_interval": {}} try: configs = db.query(RemissionExpirationConfig).filter( RemissionExpirationConfig.type == "AMENDMENT", RemissionExpirationConfig.is_deleted.is_(False), ).all() for cfg in configs: target_deadline = today + timedelta(days=cfg.interval_days) ars = db.query(RemissionAmendmentRequest).filter( RemissionAmendmentRequest.status == "AWAITING", RemissionAmendmentRequest.deadline == target_deadline, RemissionAmendmentRequest.pec_sent_at.isnot(None), # solo se gia inviata PEC iniziale # evito di ri-accodare se gia accodato oggi RemissionAmendmentRequest.pec_retry_after.is_(None), ).all() for ar in ars: ar.pec_retry_after = datetime.now(timezone.utc) stats["reminders_queued"] += 1 log.info(f"Amendment {ar.id} reminder accodato ({cfg.interval_days}gg alla scadenza)") stats["by_interval"][cfg.interval_days] = len(ars) db.commit() log.info(f"queue_reminders: {stats}") except Exception as e: db.rollback() log.error(f"queue_reminders FAILED: {e}", exc_info=True) raise finally: db.close() return stats _scheduler: Optional[BackgroundScheduler] = None def start_scheduler(): """Avvia BackgroundScheduler con i 2 cron. Chiamato in lifespan FastAPI.""" global _scheduler if _scheduler is not None: log.warning("start_scheduler chiamato due volte, skip") return _scheduler _scheduler = BackgroundScheduler(timezone="Europe/Rome") # expire ogni notte 01:05 (dopo midnight, sicuro che today e cambiato) _scheduler.add_job( expire_amendments, CronTrigger(hour=1, minute=5), id="expire_amendments", replace_existing=True, misfire_grace_time=3600, ) # reminder ogni mattina 09:00 _scheduler.add_job( queue_reminders, CronTrigger(hour=9, minute=0), id="queue_reminders", replace_existing=True, misfire_grace_time=3600, ) _scheduler.start() log.info("Scheduler avviato: expire_amendments 01:05 + queue_reminders 09:00 (Europe/Rome)") return _scheduler def stop_scheduler(): global _scheduler if _scheduler is not None: _scheduler.shutdown(wait=False) _scheduler = None log.info("Scheduler fermato")