diff --git a/app/api/router.py b/app/api/router.py
index 0515c63..dba865f 100644
--- a/app/api/router.py
+++ b/app/api/router.py
@@ -1,6 +1,16 @@
from fastapi import APIRouter
-from app.api.routes import forms, input, jobs, llm, system, templates, weather, zipcode
+from app.api.routes import (
+ forms,
+ input,
+ jobs,
+ llm,
+ reports,
+ system,
+ templates,
+ weather,
+ zipcode,
+)
from app.core.config import API_PREFIX
api_router = APIRouter()
@@ -11,4 +21,5 @@
api_router.include_router(jobs.router, prefix=API_PREFIX)
api_router.include_router(weather.router, prefix=API_PREFIX)
api_router.include_router(zipcode.router, prefix=API_PREFIX)
-api_router.include_router(input.router, prefix=API_PREFIX)
\ No newline at end of file
+api_router.include_router(input.router, prefix=API_PREFIX)
+api_router.include_router(reports.router, prefix=API_PREFIX)
\ No newline at end of file
diff --git a/app/api/routes/reports.py b/app/api/routes/reports.py
new file mode 100644
index 0000000..8141f09
--- /dev/null
+++ b/app/api/routes/reports.py
@@ -0,0 +1,149 @@
+from datetime import datetime, timezone
+from pathlib import Path
+from uuid import UUID
+
+from fastapi import APIRouter, Depends
+from fastapi.responses import FileResponse, JSONResponse
+from sqlmodel import Session
+
+from app.api.deps import get_db
+from app.api.schemas.common import AsyncJobResponse
+from app.api.schemas.enums import JobStatus, JobType, OutputFormat
+from app.api.schemas.reporting import (
+ GenerateReportRequest,
+ PeriodicReport,
+ ReportProgressResponse,
+)
+from app.core.config import API_PREFIX
+from app.core.errors.base import AppError
+from app.db.repositories import create_job, create_report, get_report, update_job, update_report
+from app.models import Job, Report
+from app.services.reporting import ReportingService
+from app.tasks.report import generate_periodic_report
+
+router = APIRouter(prefix="/reports", tags=["reporting"])
+
+
+@router.post(
+ "/generate",
+ response_model=AsyncJobResponse,
+ status_code=202,
+ responses={
+ 202: {"description": "Report generation job accepted", "model": AsyncJobResponse},
+ },
+)
+def generate_report(body: GenerateReportRequest, db: Session = Depends(get_db)):
+ # Validate request bounds and check future date
+ ReportingService().validate_and_compute_bounds(body)
+
+ # 1. Create Job record
+ job = Job(
+ job_type=JobType.report_generation,
+ status=JobStatus.queued,
+ )
+ job.celery_task_id = job.job_id
+ job = create_job(db, job)
+
+ # 2. Create Report record
+ report = Report(
+ period_type=body.period_type,
+ year=body.year,
+ month=body.month,
+ quarter=body.quarter,
+ output_format=body.format,
+ status=JobStatus.queued,
+ job_id=UUID(job.job_id),
+ )
+ report = create_report(db, report)
+
+ # 3. Dispatch background task
+ try:
+ generate_periodic_report.apply_async(
+ (str(report.report_id),), task_id=job.celery_task_id
+ )
+ except Exception as e:
+ job.status = JobStatus.failed
+ job.error = {"error_code": "QUEUE_UNAVAILABLE", "message": str(e)}
+ job.updated_at = datetime.now(timezone.utc)
+ update_job(db, job)
+
+ report.status = JobStatus.failed
+ report.updated_at = datetime.now(timezone.utc)
+ update_report(db, report)
+
+ raise AppError(
+ f"Could not queue report generation job: {e}",
+ status_code=503,
+ error_code="QUEUE_UNAVAILABLE",
+ ) from e
+
+ return AsyncJobResponse(
+ job_id=job.job_id,
+ job_type=job.job_type,
+ status=JobStatus.processing,
+ estimated_seconds=30,
+ poll_url=f"{API_PREFIX}/reports/{report.report_id}",
+ report_id=str(report.report_id),
+ )
+
+
+@router.get(
+ "/{report_id}",
+ responses={
+ 200: {
+ "description": "Generated report (PDF binary or JSON)",
+ "content": {
+ "application/pdf": {"schema": {"type": "string", "format": "binary"}},
+ "application/json": {"schema": {"$ref": "#/components/schemas/PeriodicReport"}},
+ },
+ },
+ 202: {
+ "description": "Report still being generated",
+ "model": ReportProgressResponse,
+ },
+ },
+)
+def get_report_by_id(report_id: UUID, db: Session = Depends(get_db)):
+ report = get_report(db, report_id)
+ if not report:
+ raise AppError(
+ f"Report {report_id} not found",
+ status_code=404,
+ error_code="REPORT_NOT_FOUND",
+ )
+
+ if report.status in (JobStatus.queued, JobStatus.processing):
+ return JSONResponse(
+ status_code=202,
+ content={
+ "message": "Report generation is still in progress",
+ "status": "processing",
+ "retry_after_seconds": 10,
+ },
+ )
+
+ if report.status == JobStatus.failed:
+ raise AppError(
+ "Report generation failed",
+ status_code=500,
+ error_code="REPORT_FAILED",
+ )
+
+ # Completed
+ if report.output_format == OutputFormat.pdf:
+ if not report.pdf_path or not Path(report.pdf_path).exists():
+ raise AppError(
+ "Generated PDF report file not found",
+ status_code=500,
+ error_code="PDF_NOT_FOUND",
+ )
+ filename = Path(report.pdf_path).name
+ return FileResponse(
+ path=report.pdf_path,
+ media_type="application/pdf",
+ filename=filename,
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+ # OutputFormat.json or OutputFormat.both
+ return PeriodicReport.model_validate(report.json_data)
diff --git a/app/api/schemas/common.py b/app/api/schemas/common.py
index 8d29d31..145724a 100644
--- a/app/api/schemas/common.py
+++ b/app/api/schemas/common.py
@@ -41,6 +41,7 @@ class AsyncJobResponse(BaseModel):
status: JobStatus
estimated_seconds: int | None = None
poll_url: str | None = None
+ report_id: str | None = None
class Job(BaseModel):
diff --git a/app/api/schemas/reporting.py b/app/api/schemas/reporting.py
new file mode 100644
index 0000000..8214ed4
--- /dev/null
+++ b/app/api/schemas/reporting.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from app.api.schemas.enums import OutputFormat, PeriodType
+
+
+class GenerateReportRequest(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ period_type: PeriodType
+ year: int = Field(ge=2000, le=2100)
+ month: int | None = Field(default=None, ge=1, le=12)
+ quarter: int | None = Field(default=None, ge=1, le=4)
+ format: OutputFormat = Field(default=OutputFormat.pdf)
+
+
+class ReportSummaryGroup(BaseModel):
+ model_config = ConfigDict()
+
+ period: str
+ incident_count: int
+ forms_generated: int
+
+
+class ReportSummary(BaseModel):
+ model_config = ConfigDict()
+
+ date_from: date
+ date_to: date
+ total_incidents: int
+ by_type: dict[str, int]
+ by_status: dict[str, int]
+ forms_generated: int
+ avg_completeness_score: float
+ avg_processing_time_seconds: float
+ groups: list[ReportSummaryGroup]
+
+
+class IncidentSummaryItem(BaseModel):
+ model_config = ConfigDict()
+
+ incident_id: UUID
+ incident_number: str | None = None
+ incident_date: date | None = None
+ incident_type: str | None = None
+ status: str | None = None
+ forms_generated: int = 0
+
+
+class ReportCompliance(BaseModel):
+ model_config = ConfigDict()
+
+ neris_submission_rate: float
+ average_submission_delay_days: float
+ overdue_incidents: int
+
+
+class PeriodicReport(BaseModel):
+ model_config = ConfigDict()
+
+ report_id: UUID
+ period_type: PeriodType
+ period_label: str
+ generated_at: datetime
+ summary: ReportSummary
+ incidents: list[IncidentSummaryItem]
+ compliance: ReportCompliance
+
+
+class ReportProgressResponse(BaseModel):
+ model_config = ConfigDict()
+
+ message: str = "Report generation is still in progress"
+ status: str = "processing"
+ retry_after_seconds: int = 10
diff --git a/app/core/celery.py b/app/core/celery.py
index 8e7d9bb..623c882 100644
--- a/app/core/celery.py
+++ b/app/core/celery.py
@@ -18,7 +18,12 @@
result_expires=86400,
)
-celery_app.conf.include = ["app.tasks.fill", "app.tasks.purge", "app.tasks.transcribe"]
+celery_app.conf.include = [
+ "app.tasks.fill",
+ "app.tasks.purge",
+ "app.tasks.transcribe",
+ "app.tasks.report",
+]
celery_app.conf.beat_schedule = {
diff --git a/app/core/config.py b/app/core/config.py
index 83eaa1a..5fd6f4e 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -104,6 +104,9 @@ def _provider_env(prefix: str, **defaults: str) -> dict[str, str]:
# Voice input audio files land here: {AUDIO_DIR}/{input_id}.{ext}
AUDIO_DIR = DATA_DIR / "audio"
+# Generated periodic reports land here: {REPORTS_DIR}/{filename}
+REPORTS_DIR = DATA_DIR / "reports"
+
# Advisory estimate returned in VoiceInputResponse.estimated_processing_seconds.
ESTIMATED_TRANSCRIPTION_SECONDS = int(os.getenv("ESTIMATED_TRANSCRIPTION_SECONDS", "30"))
diff --git a/app/db/repositories.py b/app/db/repositories.py
index 51f6282..3cbe3bb 100644
--- a/app/db/repositories.py
+++ b/app/db/repositories.py
@@ -1,9 +1,10 @@
-from datetime import datetime
+from datetime import date, datetime, timezone
from uuid import UUID
+from sqlalchemy import and_, or_
from sqlmodel import Session, select
-from app.models import FormSubmission, Input, Job, Template
+from app.models import Extraction, Form, FormSubmission, Incident, Input, Job, Report, Template
# Templates
@@ -128,3 +129,61 @@ def update_input(session: Session, input_obj: Input) -> Input:
session.refresh(input_obj)
return input_obj
+
+# Reports
+def create_report(session: Session, report: Report) -> Report:
+ session.add(report)
+ session.commit()
+ session.refresh(report)
+ return report
+
+
+def get_report(session: Session, report_id: UUID) -> Report | None:
+ return session.get(Report, report_id)
+
+
+def update_report(session: Session, report: Report) -> Report:
+ session.add(report)
+ session.commit()
+ session.refresh(report)
+ return report
+
+
+# Incidents & Forms for Reporting
+def get_incidents_in_date_range(session: Session, start_date: date, end_date: date) -> list[Incident]:
+ statement = (
+ select(Incident)
+ .where(
+ Incident.deleted_at.is_(None),
+ or_(
+ Incident.incident_date.between(start_date, end_date),
+ and_(
+ Incident.incident_date.is_(None),
+ Incident.created_at >= datetime.combine(start_date, datetime.min.time(), tzinfo=timezone.utc),
+ Incident.created_at <= datetime.combine(end_date, datetime.max.time(), tzinfo=timezone.utc),
+ ),
+ ),
+ )
+ .order_by(Incident.incident_date.asc(), Incident.created_at.asc())
+ )
+ return list(session.exec(statement).all())
+
+
+def get_forms_for_incident(session: Session, incident_id: UUID) -> list[Form]:
+ statement = select(Form).where(Form.incident_id == incident_id)
+ return list(session.exec(statement).all())
+
+
+def get_forms_for_incidents(session: Session, incident_ids: list[UUID]) -> list[Form]:
+ if not incident_ids:
+ return []
+ statement = select(Form).where(Form.incident_id.in_(incident_ids))
+ return list(session.exec(statement).all())
+
+
+def get_extractions_by_ids(session: Session, extract_ids: list[UUID]) -> list[Extraction]:
+ if not extract_ids:
+ return []
+ statement = select(Extraction).where(Extraction.extract_id.in_(extract_ids))
+ return list(session.exec(statement).all())
+
diff --git a/app/services/reporting.py b/app/services/reporting.py
new file mode 100644
index 0000000..833b7a5
--- /dev/null
+++ b/app/services/reporting.py
@@ -0,0 +1,558 @@
+from __future__ import annotations
+
+import calendar
+import logging
+from datetime import date, datetime, timezone
+from pathlib import Path
+from uuid import UUID
+
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import letter
+from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
+from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
+from sqlmodel import Session
+
+from app.api.schemas.enums import (
+ FormStatus,
+ FormType,
+ JobStatus,
+ OutputFormat,
+ PeriodType,
+ ReportStatus,
+)
+from app.api.schemas.reporting import (
+ GenerateReportRequest,
+ IncidentSummaryItem,
+ PeriodicReport,
+ ReportCompliance,
+ ReportSummary,
+ ReportSummaryGroup,
+)
+from app.core.config import REPORTS_DIR
+from app.core.errors.base import AppError
+from app.db.repositories import (
+ get_extractions_by_ids,
+ get_forms_for_incidents,
+ get_incidents_in_date_range,
+ get_report,
+ update_report,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class ReportingService:
+ """Business logic for periodic report generation and analytics."""
+
+ def validate_and_compute_bounds(
+ self, req: GenerateReportRequest
+ ) -> tuple[date, date, str, str]:
+ """Validate report parameters and compute date range, label, and PDF filename.
+
+ Returns (date_from, date_to, period_label, pdf_filename).
+ Raises AppError with 422 if validation fails or a future period is requested.
+ """
+ now = datetime.now(timezone.utc)
+
+ if req.period_type == PeriodType.monthly:
+ if req.month is None:
+ raise AppError(
+ "month is required when period_type is monthly",
+ status_code=422,
+ error_code="VALIDATION_ERROR",
+ detail={"validation_errors": [{"field": "month", "issue": "Missing required field"}]},
+ )
+ if req.month < 1 or req.month > 12:
+ raise AppError(
+ "month must be between 1 and 12",
+ status_code=422,
+ error_code="VALIDATION_ERROR",
+ detail={"validation_errors": [{"field": "month", "issue": "Must be 1..12", "value": req.month}]},
+ )
+ if req.year > now.year or (req.year == now.year and req.month > now.month):
+ raise AppError(
+ "Cannot generate a report for a future period",
+ status_code=422,
+ error_code="FUTURE_PERIOD",
+ )
+ date_from = date(req.year, req.month, 1)
+ _, last_day = calendar.monthrange(req.year, req.month)
+ date_to = date(req.year, req.month, last_day)
+ period_label = f"{calendar.month_name[req.month]} {req.year}"
+ pdf_filename = f"FireForm_Monthly_{req.year}-{req.month:02d}.pdf"
+
+ elif req.period_type == PeriodType.quarterly:
+ if req.quarter is None:
+ raise AppError(
+ "quarter is required when period_type is quarterly",
+ status_code=422,
+ error_code="VALIDATION_ERROR",
+ detail={"validation_errors": [{"field": "quarter", "issue": "Missing required field"}]},
+ )
+ if req.quarter < 1 or req.quarter > 4:
+ raise AppError(
+ "Invalid quarter value",
+ status_code=422,
+ error_code="VALIDATION_ERROR",
+ detail={"validation_errors": [{"field": "quarter", "issue": "Must be 1, 2, 3, or 4", "value": req.quarter}]},
+ )
+ current_quarter = (now.month - 1) // 3 + 1
+ if req.year > now.year or (req.year == now.year and req.quarter > current_quarter):
+ raise AppError(
+ "Cannot generate a report for a future period",
+ status_code=422,
+ error_code="FUTURE_PERIOD",
+ )
+ start_month = (req.quarter - 1) * 3 + 1
+ end_month = start_month + 2
+ date_from = date(req.year, start_month, 1)
+ _, last_day = calendar.monthrange(req.year, end_month)
+ date_to = date(req.year, end_month, last_day)
+ period_label = f"Q{req.quarter} {req.year}"
+ pdf_filename = f"FireForm_Quarterly_{req.year}-Q{req.quarter}.pdf"
+
+ elif req.period_type == PeriodType.annual:
+ if req.year > now.year:
+ raise AppError(
+ "Cannot generate a report for a future period",
+ status_code=422,
+ error_code="FUTURE_PERIOD",
+ )
+ date_from = date(req.year, 1, 1)
+ date_to = date(req.year, 12, 31)
+ period_label = f"{req.year}"
+ pdf_filename = f"FireForm_Annual_{req.year}.pdf"
+
+ else:
+ raise AppError(
+ f"Unsupported period type: {req.period_type}",
+ status_code=422,
+ error_code="VALIDATION_ERROR",
+ )
+
+ return date_from, date_to, period_label, pdf_filename
+
+ def generate_report(self, session: Session, report_id: UUID) -> PeriodicReport:
+ """Core report generation pipeline: queries data, aggregates metrics, and builds PDF/JSON."""
+ report = get_report(session, report_id)
+ if not report:
+ raise AppError(f"Report {report_id} not found", status_code=404, error_code="REPORT_NOT_FOUND")
+
+ # Re-derive period bounds
+ req = GenerateReportRequest(
+ period_type=report.period_type,
+ year=report.year,
+ month=report.month,
+ quarter=report.quarter,
+ format=report.output_format,
+ )
+ date_from, date_to, period_label, pdf_filename = self.validate_and_compute_bounds(req)
+
+ # 1. Fetch incidents in date range
+ incidents = get_incidents_in_date_range(session, date_from, date_to)
+ incident_ids = [inc.incident_id for inc in incidents]
+
+ # 2. Fetch associated forms and extractions
+ forms = get_forms_for_incidents(session, incident_ids)
+ incident_forms_map: dict[UUID, list] = {inc_id: [] for inc_id in incident_ids}
+ for f in forms:
+ if f.incident_id in incident_forms_map:
+ incident_forms_map[f.incident_id].append(f)
+
+ extract_ids = [inc.extract_id for inc in incidents if inc.extract_id is not None]
+ extractions = get_extractions_by_ids(session, extract_ids)
+ extractions_map = {ext.extract_id: ext for ext in extractions}
+
+ # 3. Compute statistics & breakdowns
+ total_incidents = len(incidents)
+ by_type: dict[str, int] = {}
+ by_status: dict[str, int] = {}
+ for inc in incidents:
+ itype = inc.incident_type or "unspecified"
+ by_type[itype] = by_type.get(itype, 0) + 1
+ st = inc.status.value if hasattr(inc.status, "value") else str(inc.status)
+ by_status[st] = by_status.get(st, 0) + 1
+
+ completed_forms_count = sum(1 for f in forms if f.status == FormStatus.completed)
+
+ # Processing time
+ processing_times = [
+ ext.processing_time_seconds
+ for ext in extractions
+ if ext.processing_time_seconds is not None
+ ]
+ avg_processing_time = (
+ round(sum(processing_times) / len(processing_times), 1) if processing_times else 0.0
+ )
+
+ # Completeness score
+ completeness_scores = []
+ for inc in incidents:
+ ext = extractions_map.get(inc.extract_id)
+ if ext and ext.incident_contract:
+ # Estimate completeness based on populated top-level contract keys
+ populated = sum(1 for v in ext.incident_contract.values() if v is not None and v != "")
+ total = len(ext.incident_contract)
+ score = (populated / total * 100.0) if total > 0 else 100.0
+ completeness_scores.append(score)
+ elif total_incidents > 0:
+ completeness_scores.append(100.0)
+ avg_completeness = (
+ round(sum(completeness_scores) / len(completeness_scores), 1)
+ if completeness_scores
+ else (100.0 if total_incidents > 0 else 0.0)
+ )
+
+ # Period groups breakdown
+ groups: list[ReportSummaryGroup] = []
+ if report.period_type == PeriodType.monthly:
+ groups.append(
+ ReportSummaryGroup(
+ period=f"{report.year}-{report.month:02d}",
+ incident_count=total_incidents,
+ forms_generated=completed_forms_count,
+ )
+ )
+ elif report.period_type == PeriodType.quarterly:
+ start_m = (report.quarter - 1) * 3 + 1
+ for m in range(start_m, start_m + 3):
+ m_label = f"{report.year}-{m:02d}"
+ m_inc_count = sum(
+ 1
+ for inc in incidents
+ if (inc.incident_date and inc.incident_date.month == m)
+ or (not inc.incident_date and inc.created_at.month == m)
+ )
+ m_forms_count = sum(
+ len([f for f in incident_forms_map.get(inc.incident_id, []) if f.status == FormStatus.completed])
+ for inc in incidents
+ if (inc.incident_date and inc.incident_date.month == m)
+ or (not inc.incident_date and inc.created_at.month == m)
+ )
+ groups.append(
+ ReportSummaryGroup(
+ period=m_label,
+ incident_count=m_inc_count,
+ forms_generated=m_forms_count,
+ )
+ )
+ elif report.period_type == PeriodType.annual:
+ for m in range(1, 13):
+ m_label = f"{report.year}-{m:02d}"
+ m_inc_count = sum(
+ 1
+ for inc in incidents
+ if (inc.incident_date and inc.incident_date.month == m)
+ or (not inc.incident_date and inc.created_at.month == m)
+ )
+ m_forms_count = sum(
+ len([f for f in incident_forms_map.get(inc.incident_id, []) if f.status == FormStatus.completed])
+ for inc in incidents
+ if (inc.incident_date and inc.incident_date.month == m)
+ or (not inc.incident_date and inc.created_at.month == m)
+ )
+ groups.append(
+ ReportSummaryGroup(
+ period=m_label,
+ incident_count=m_inc_count,
+ forms_generated=m_forms_count,
+ )
+ )
+
+ # 4. Compliance metrics
+ # NERIS submission rate: incidents with completed NERIS form or submitted status
+ neris_submitted = 0
+ delays = []
+ overdue_count = 0
+ today = datetime.now(timezone.utc).date()
+
+ for inc in incidents:
+ has_neris = any(
+ f.form_type == FormType.neris and f.status == FormStatus.completed
+ for f in incident_forms_map.get(inc.incident_id, [])
+ )
+ is_submitted = inc.status == ReportStatus.submitted or has_neris
+ if is_submitted:
+ neris_submitted += 1
+ inc_date = inc.incident_date or inc.created_at.date()
+ delay = (inc.updated_at.date() - inc_date).days
+ delays.append(max(0, delay))
+ elif inc.status in (ReportStatus.draft, ReportStatus.under_review):
+ inc_date = inc.incident_date or inc.created_at.date()
+ if (today - inc_date).days > 30:
+ overdue_count += 1
+
+ neris_rate = round((neris_submitted / total_incidents * 100.0), 1) if total_incidents > 0 else 0.0
+ avg_delay = round(sum(delays) / len(delays), 1) if delays else 0.0
+
+ compliance = ReportCompliance(
+ neris_submission_rate=neris_rate,
+ average_submission_delay_days=avg_delay,
+ overdue_incidents=overdue_count,
+ )
+
+ summary = ReportSummary(
+ date_from=date_from,
+ date_to=date_to,
+ total_incidents=total_incidents,
+ by_type=by_type,
+ by_status=by_status,
+ forms_generated=completed_forms_count,
+ avg_completeness_score=avg_completeness,
+ avg_processing_time_seconds=avg_processing_time,
+ groups=groups,
+ )
+
+ incident_items = [
+ IncidentSummaryItem(
+ incident_id=inc.incident_id,
+ incident_number=inc.incident_number,
+ incident_date=inc.incident_date or inc.created_at.date(),
+ incident_type=inc.incident_type,
+ status=inc.status.value if hasattr(inc.status, "value") else str(inc.status),
+ forms_generated=len([f for f in incident_forms_map.get(inc.incident_id, []) if f.status == FormStatus.completed]),
+ )
+ for inc in incidents
+ ]
+
+ now_utc = datetime.now(timezone.utc)
+ periodic_report = PeriodicReport(
+ report_id=report.report_id,
+ period_type=report.period_type,
+ period_label=period_label,
+ generated_at=now_utc,
+ summary=summary,
+ incidents=incident_items,
+ compliance=compliance,
+ )
+
+ # 5. Generate PDF if format requires it
+ pdf_path_str: str | None = None
+ if report.output_format in (OutputFormat.pdf, OutputFormat.both):
+ pdf_path = Path(REPORTS_DIR) / pdf_filename
+ self._generate_pdf(pdf_path, periodic_report)
+ pdf_path_str = str(pdf_path)
+
+ # 6. Update database record
+ report.status = JobStatus.completed
+ report.period_label = period_label
+ report.generated_at = now_utc
+ report.summary = summary.model_dump(mode="json")
+ report.json_data = periodic_report.model_dump(mode="json")
+ report.pdf_path = pdf_path_str
+ report.updated_at = now_utc
+ update_report(session, report)
+
+ return periodic_report
+
+ def _generate_pdf(self, output_path: Path, report: PeriodicReport) -> None:
+ """Render a clean ReportLab PDF document for the periodic report."""
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ doc = SimpleDocTemplate(
+ str(output_path),
+ pagesize=letter,
+ rightMargin=40,
+ leftMargin=40,
+ topMargin=40,
+ bottomMargin=40,
+ )
+ styles = getSampleStyleSheet()
+
+ title_style = ParagraphStyle(
+ "ReportTitle",
+ parent=styles["Heading1"],
+ fontSize=22,
+ leading=26,
+ textColor=colors.HexColor("#b91c1c"), # FireForm red
+ spaceAfter=6,
+ )
+ subtitle_style = ParagraphStyle(
+ "ReportSubtitle",
+ parent=styles["Normal"],
+ fontSize=11,
+ leading=14,
+ textColor=colors.HexColor("#4b5563"),
+ spaceAfter=14,
+ )
+ section_style = ParagraphStyle(
+ "SectionHeading",
+ parent=styles["Heading2"],
+ fontSize=14,
+ leading=18,
+ textColor=colors.HexColor("#1f2937"),
+ spaceBefore=12,
+ spaceAfter=6,
+ )
+ cell_style = ParagraphStyle(
+ "CellNormal",
+ parent=styles["Normal"],
+ fontSize=9,
+ leading=11,
+ )
+ header_cell_style = ParagraphStyle(
+ "HeaderCell",
+ parent=styles["Normal"],
+ fontSize=9,
+ leading=11,
+ textColor=colors.white,
+ fontName="Helvetica-Bold",
+ )
+
+ elements = []
+
+ # Header
+ elements.append(Paragraph("FireForm Periodic Report", title_style))
+ gen_str = report.generated_at.strftime("%Y-%m-%d %H:%M UTC")
+ elements.append(
+ Paragraph(f"Period: {report.period_label} | Generated on {gen_str}", subtitle_style)
+ )
+ elements.append(Spacer(1, 10))
+
+ # Executive Summary Table
+ elements.append(Paragraph("Executive Summary", section_style))
+ summary_data = [
+ [
+ Paragraph("Metric", header_cell_style),
+ Paragraph("Value", header_cell_style),
+ Paragraph("Metric", header_cell_style),
+ Paragraph("Value", header_cell_style),
+ ],
+ [
+ Paragraph("Total Incidents", cell_style),
+ Paragraph(str(report.summary.total_incidents), cell_style),
+ Paragraph("Forms Generated", cell_style),
+ Paragraph(str(report.summary.forms_generated), cell_style),
+ ],
+ [
+ Paragraph("Avg Completeness", cell_style),
+ Paragraph(f"{report.summary.avg_completeness_score}%", cell_style),
+ Paragraph("Avg Processing Time", cell_style),
+ Paragraph(f"{report.summary.avg_processing_time_seconds}s", cell_style),
+ ],
+ ]
+ summary_table = Table(summary_data, colWidths=[130, 130, 130, 130])
+ summary_table.setStyle(
+ TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#b91c1c")),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f9fafb")]),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
+ ])
+ )
+ elements.append(summary_table)
+ elements.append(Spacer(1, 12))
+
+ # Compliance Table
+ elements.append(Paragraph("Compliance Metrics (USFA / NERIS)", section_style))
+ compliance_data = [
+ [
+ Paragraph("Metric", header_cell_style),
+ Paragraph("Value", header_cell_style),
+ ],
+ [
+ Paragraph("NERIS Submission Rate", cell_style),
+ Paragraph(f"{report.compliance.neris_submission_rate}%", cell_style),
+ ],
+ [
+ Paragraph("Average Submission Delay", cell_style),
+ Paragraph(f"{report.compliance.average_submission_delay_days} days", cell_style),
+ ],
+ [
+ Paragraph("Overdue Incidents (>30 days unsubmitted)", cell_style),
+ Paragraph(str(report.compliance.overdue_incidents), cell_style),
+ ],
+ ]
+ comp_table = Table(compliance_data, colWidths=[260, 260])
+ comp_table.setStyle(
+ TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#374151")),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f9fafb")]),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
+ ])
+ )
+ elements.append(comp_table)
+ elements.append(Spacer(1, 12))
+
+ # Breakdowns: By Type and By Status
+ elements.append(Paragraph("Breakdown by Category & Status", section_style))
+ type_rows = [f"{k}: {v}" for k, v in report.summary.by_type.items()] or ["None"]
+ status_rows = [f"{k}: {v}" for k, v in report.summary.by_status.items()] or ["None"]
+ max_rows = max(len(type_rows), len(status_rows))
+
+ breakdown_data = [
+ [
+ Paragraph("Incident Category", header_cell_style),
+ Paragraph("Report Status", header_cell_style),
+ ]
+ ]
+ for i in range(max_rows):
+ t_val = type_rows[i] if i < len(type_rows) else ""
+ s_val = status_rows[i] if i < len(status_rows) else ""
+ breakdown_data.append([
+ Paragraph(t_val, cell_style),
+ Paragraph(s_val, cell_style),
+ ])
+
+ breakdown_table = Table(breakdown_data, colWidths=[260, 260])
+ breakdown_table.setStyle(
+ TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4b5563")),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f9fafb")]),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
+ ])
+ )
+ elements.append(breakdown_table)
+ elements.append(Spacer(1, 12))
+
+ # Incident List
+ elements.append(Paragraph("Incident Records", section_style))
+ inc_data = [
+ [
+ Paragraph("Incident #", header_cell_style),
+ Paragraph("Date", header_cell_style),
+ Paragraph("Type", header_cell_style),
+ Paragraph("Status", header_cell_style),
+ Paragraph("Forms", header_cell_style),
+ ]
+ ]
+ if not report.incidents:
+ inc_data.append([
+ Paragraph("No incidents in period", cell_style),
+ Paragraph("-", cell_style),
+ Paragraph("-", cell_style),
+ Paragraph("-", cell_style),
+ Paragraph("-", cell_style),
+ ])
+ else:
+ for inc in report.incidents[:50]: # Limit to 50 rows per report document
+ inc_data.append([
+ Paragraph(inc.incident_number or str(inc.incident_id)[:8], cell_style),
+ Paragraph(inc.incident_date.isoformat() if inc.incident_date else "-", cell_style),
+ Paragraph(inc.incident_type or "-", cell_style),
+ Paragraph(inc.status or "-", cell_style),
+ Paragraph(str(inc.forms_generated), cell_style),
+ ])
+
+ inc_table = Table(inc_data, colWidths=[120, 80, 120, 120, 80])
+ inc_table.setStyle(
+ TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1f2937")),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f9fafb")]),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("TOPPADDING", (0, 0), (-1, -1), 3),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
+ ])
+ )
+ elements.append(inc_table)
+
+ doc.build(elements)
diff --git a/app/tasks/report.py b/app/tasks/report.py
new file mode 100644
index 0000000..7476a7c
--- /dev/null
+++ b/app/tasks/report.py
@@ -0,0 +1,67 @@
+import logging
+from datetime import datetime, timezone
+from uuid import UUID
+
+from app.api.schemas.enums import JobStatus
+from app.core.celery import celery_app
+from app.db.database import get_session
+from app.db.repositories import get_job_by_celery_id, get_report, update_job, update_report
+from app.services.reporting import ReportingService
+
+logger = logging.getLogger(__name__)
+
+
+@celery_app.task(bind=True, name="generate_periodic_report")
+def generate_periodic_report(self, report_id_str: str) -> dict:
+ session = next(get_session())
+ try:
+ report_uuid = UUID(report_id_str)
+ job = get_job_by_celery_id(session, self.request.id)
+ if job:
+ job.status = JobStatus.processing
+ job.progress_percent = 20
+ job.updated_at = datetime.now(timezone.utc)
+ update_job(session, job)
+
+ report = get_report(session, report_uuid)
+ if report:
+ report.status = JobStatus.processing
+ report.updated_at = datetime.now(timezone.utc)
+ update_report(session, report)
+
+ # Run report generation pipeline
+ ReportingService().generate_report(session, report_uuid)
+
+ # Mark completed
+ report = get_report(session, report_uuid)
+ if report:
+ report.status = JobStatus.completed
+ report.updated_at = datetime.now(timezone.utc)
+ update_report(session, report)
+
+ if job:
+ job.status = JobStatus.completed
+ job.progress_percent = 100
+ job.result_url = f"/api/v1/reports/{report_id_str}"
+ job.updated_at = datetime.now(timezone.utc)
+ update_job(session, job)
+
+ return {"job_id": job.job_id if job else None, "report_id": report_id_str}
+
+ except Exception as e:
+ logger.exception("generate_periodic_report failed")
+ job = get_job_by_celery_id(session, self.request.id)
+ if job:
+ job.status = JobStatus.failed
+ job.error = {"error_code": "TASK_FAILED", "message": str(e)}
+ job.updated_at = datetime.now(timezone.utc)
+ update_job(session, job)
+
+ report = get_report(session, UUID(report_id_str))
+ if report:
+ report.status = JobStatus.failed
+ report.updated_at = datetime.now(timezone.utc)
+ update_report(session, report)
+ raise
+ finally:
+ session.close()
diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml
index ae2ad41..a9f54c6 100644
--- a/contracts/openapi.yaml
+++ b/contracts/openapi.yaml
@@ -29,6 +29,8 @@ tags:
description: Weather forecast lookup via external API
- name: zipcode
description: Address and postal code geocoding via external API
+ - name: reporting
+ description: Periodic aggregate report generation and statistics
paths:
# ── Layer 1: Input ──────────────────────────────────────────────
@@ -45,6 +47,12 @@ paths:
/api/v1/forms/jobs:
$ref: "path/forms.yaml#/async_fill"
+ # ── Layer 4: Reporting ──────────────────────────────────────────
+ /api/v1/reports/generate:
+ $ref: "path/reporting.yaml#/generate"
+ /api/v1/reports/{report_id}:
+ $ref: "path/reporting.yaml#/report_by_id"
+
# ── Layer 6: Templates & Configuration ─────────────────────────
/api/v1/templates:
$ref: "path/templates.yaml#/templates"
diff --git a/contracts/path/reporting.yaml b/contracts/path/reporting.yaml
new file mode 100644
index 0000000..ad10172
--- /dev/null
+++ b/contracts/path/reporting.yaml
@@ -0,0 +1,191 @@
+# Layer 5 Reporting & Analytics Endpoints
+# GET /api/v1/reports/summary
+# POST /api/v1/reports/generate
+# GET /api/v1/reports/{report_id}
+
+summary:
+ get:
+ operationId: getReportSummary
+ summary: Get aggregate incident statistics
+ description: |
+ Returns aggregate statistics for a dashboard view total incidents,
+ breakdown by type and status, forms generated, average completeness
+ scores, and average processing times. Supports filtering by date range
+ and grouping by time period or incident type.
+ tags:
+ - reporting
+ parameters:
+ - name: date_from
+ in: query
+ description: Start date (inclusive, ISO 8601)
+ schema:
+ type: string
+ format: date
+ - name: date_to
+ in: query
+ description: End date (inclusive, ISO 8601)
+ schema:
+ type: string
+ format: date
+ - name: group_by
+ in: query
+ description: Group results by time period or incident type
+ schema:
+ type: string
+ enum:
+ - day
+ - week
+ - month
+ - incident_type
+ default: month
+ responses:
+ "200":
+ description: Aggregate statistics
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/reporting.yaml#/ReportSummary"
+ example:
+ date_from: "2024-01-01"
+ date_to: "2024-07-15"
+ total_incidents: 157
+ by_type:
+ fire: 42
+ ems: 68
+ rescue: 12
+ hazardous_conditions: 8
+ service_call: 15
+ good_intent: 7
+ false_alarm: 5
+ by_status:
+ draft: 3
+ under_review: 2
+ approved: 12
+ submitted: 140
+ forms_generated: 489
+ avg_completeness_score: 88.3
+ avg_processing_time_seconds: 47.2
+ groups:
+ - period: "2024-07"
+ incident_count: 23
+ forms_generated: 71
+
+generate:
+ post:
+ operationId: generateReport
+ summary: Generate a periodic report
+ description: |
+ Generates a periodic (monthly, quarterly, or annual) aggregate report
+ as PDF or JSON. This includes statistics, incident summaries, and
+ compliance metrics required by agencies like USFA/NERIS. This is an
+ async operation returns a report_id for polling.
+
+ Note: USFA encourages monthly submission but requires quarterly at minimum.
+ This endpoint supports generating reports aligned with those cadences.
+ tags:
+ - reporting
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/reporting.yaml#/GenerateReportRequest"
+ example:
+ period_type: "monthly"
+ year: 2024
+ month: 7
+ format: "pdf"
+ responses:
+ "202":
+ description: Report generation job accepted
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/AsyncJobResponse"
+ example:
+ job_id: "550e8400-e29b-41d4-a716-446655440096"
+ job_type: "report_generation"
+ status: "processing"
+ estimated_seconds: 30
+ poll_url: "/api/v1/reports/550e8400-e29b-41d4-a716-446655440060"
+ report_id: "550e8400-e29b-41d4-a716-446655440060"
+ "422":
+ description: Invalid period or future date
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ examples:
+ future_period:
+ summary: Future period requested
+ value:
+ error_code: "FUTURE_PERIOD"
+ message: "Cannot generate a report for a future period"
+ invalid_quarter:
+ summary: Invalid quarter value
+ value:
+ error_code: "VALIDATION_ERROR"
+ message: "Invalid quarter value"
+ validation_errors:
+ - field: "quarter"
+ issue: "Must be 1, 2, 3, or 4"
+ value: 5
+
+report_by_id:
+ get:
+ operationId: getReport
+ summary: Retrieve a generated report
+ description: |
+ Returns a previously generated periodic report. If the report is still
+ being generated, returns 202 Accepted with a retry hint. When complete,
+ returns the report in the originally requested format (PDF binary or JSON).
+ tags:
+ - reporting
+ parameters:
+ - name: report_id
+ in: path
+ required: true
+ description: Unique identifier of the generated report
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Generated report (PDF or JSON depending on original request)
+ content:
+ application/pdf:
+ schema:
+ type: string
+ format: binary
+ application/json:
+ schema:
+ $ref: "../schemas/reporting.yaml#/PeriodicReport"
+ headers:
+ Content-Disposition:
+ description: Attachment filename for PDF downloads
+ schema:
+ type: string
+ example: 'attachment; filename="FireForm_Monthly_2024-07.pdf"'
+ "202":
+ description: Report still being generated
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ status:
+ type: string
+ retry_after_seconds:
+ type: integer
+ example:
+ message: "Report generation is still in progress"
+ status: "processing"
+ retry_after_seconds: 10
+ "404":
+ description: Report not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
diff --git a/contracts/schemas/reporting.yaml b/contracts/schemas/reporting.yaml
new file mode 100644
index 0000000..f352e70
--- /dev/null
+++ b/contracts/schemas/reporting.yaml
@@ -0,0 +1,112 @@
+# Reporting & Analytics schemas
+
+ReportSummary:
+ type: object
+ properties:
+ date_from:
+ type: string
+ format: date
+ date_to:
+ type: string
+ format: date
+ total_incidents:
+ type: integer
+ by_type:
+ type: object
+ additionalProperties:
+ type: integer
+ description: Incident counts grouped by category
+ by_status:
+ type: object
+ additionalProperties:
+ type: integer
+ description: Incident counts grouped by report status
+ forms_generated:
+ type: integer
+ avg_completeness_score:
+ type: number
+ avg_processing_time_seconds:
+ type: number
+ groups:
+ type: array
+ items:
+ type: object
+ properties:
+ period:
+ type: string
+ description: Period label (e.g. "2024-07" for monthly, "fire" for by type)
+ incident_count:
+ type: integer
+ forms_generated:
+ type: integer
+
+GenerateReportRequest:
+ type: object
+ required:
+ - period_type
+ - year
+ properties:
+ period_type:
+ $ref: "../schemas/enums.yaml#/PeriodType"
+ year:
+ type: integer
+ minimum: 2000
+ maximum: 2100
+ month:
+ type: integer
+ minimum: 1
+ maximum: 12
+ description: Required when period_type is "monthly"
+ quarter:
+ type: integer
+ minimum: 1
+ maximum: 4
+ description: Required when period_type is "quarterly"
+ format:
+ $ref: "../schemas/enums.yaml#/OutputFormat"
+
+PeriodicReport:
+ type: object
+ properties:
+ report_id:
+ type: string
+ format: uuid
+ period_type:
+ $ref: "../schemas/enums.yaml#/PeriodType"
+ period_label:
+ type: string
+ description: Human-readable period (e.g. "July 2024", "Q3 2024", "2024")
+ generated_at:
+ type: string
+ format: date-time
+ summary:
+ $ref: "#/ReportSummary"
+ incidents:
+ type: array
+ items:
+ type: object
+ properties:
+ incident_id:
+ type: string
+ format: uuid
+ incident_number:
+ type: string
+ incident_date:
+ type: string
+ format: date
+ incident_type:
+ type: string
+ status:
+ type: string
+ forms_generated:
+ type: integer
+ compliance:
+ type: object
+ properties:
+ neris_submission_rate:
+ type: number
+ description: Percentage of incidents with NERIS submitted
+ average_submission_delay_days:
+ type: number
+ overdue_incidents:
+ type: integer
diff --git a/requirements.txt b/requirements.txt
index 6d66287..e15bda6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -22,3 +22,4 @@ retry-requests
pandas
geopy
alembic
+reportlab
diff --git a/tests/test_api.py b/tests/test_api.py
index 06af417..de5c62f 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -11,9 +11,8 @@
from sqlmodel import select
from app.api.schemas.enums import InputStatus, InputType
-from app.models import Template, FormSubmission, Input
from app.core.config import API_PREFIX
-from app.models import FormSubmission, Template
+from app.models import FormSubmission, Input, Template
# ═══════════════════════════════════════════════════════════════════════════
# DB model sanity
@@ -375,6 +374,7 @@ def test_fill_form_uses_description_from_update(
"input_text": "Command uses channel 3 for the Oak Ridge fire.",
},
)
+ assert resp.status_code == 200
def test_fill_form_uses_description_from_create(self, client, mock_controller, tmp_path, monkeypatch):
"""The description given to POST /templates/create reaches the filler."""
monkeypatch.setattr("app.core.paths.PROJECT_ROOT", tmp_path)
diff --git a/tests/test_v1_reports.py b/tests/test_v1_reports.py
new file mode 100644
index 0000000..e303633
--- /dev/null
+++ b/tests/test_v1_reports.py
@@ -0,0 +1,472 @@
+"""Tests for periodic report generation API (Issue #557).
+
+Covers POST /api/v1/reports/generate and GET /api/v1/reports/{report_id},
+validation, aggregation logic, compliance metrics, ReportLab PDF generation,
+and Celery background worker execution.
+"""
+
+from datetime import date
+from unittest.mock import patch
+from uuid import UUID, uuid4
+
+from sqlmodel import Session, select
+
+from app.api.schemas.enums import (
+ FormStatus,
+ FormType,
+ JobStatus,
+ JobType,
+ OutputFormat,
+ PeriodType,
+ ReportStatus,
+)
+from app.core.config import API_PREFIX
+from app.db.repositories import create_job, create_report, get_report
+from app.models import Extraction, Form, Incident, Input, Job, Report
+from app.services.reporting import ReportingService
+from app.tasks.report import generate_periodic_report
+
+GENERATE_URL = f"{API_PREFIX}/reports/generate"
+REPORTS_URL = f"{API_PREFIX}/reports"
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _seed_incident_with_form(
+ db: Session,
+ incident_date: date,
+ incident_type: str = "fire",
+ status: ReportStatus = ReportStatus.draft,
+ incident_number: str = "INC-001",
+ form_type: FormType = FormType.neris,
+ form_status: FormStatus = FormStatus.completed,
+ processing_time: float = 12.5,
+) -> tuple[Incident, Form]:
+ inp = Input(
+ input_type="text",
+ transcript="Test narrative",
+ status="ready",
+ )
+ db.add(inp)
+ db.commit()
+ db.refresh(inp)
+
+ ext = Extraction(
+ input_id=inp.input_id,
+ status="completed",
+ processing_time_seconds=processing_time,
+ incident_contract={"incident": {"type": incident_type}},
+ )
+ db.add(ext)
+ db.commit()
+ db.refresh(ext)
+
+ inc = Incident(
+ extract_id=ext.extract_id,
+ incident_number=incident_number,
+ incident_type=incident_type,
+ incident_date=incident_date,
+ status=status,
+ )
+ db.add(inc)
+ db.commit()
+ db.refresh(inc)
+
+ frm = Form(
+ extract_id=ext.extract_id,
+ incident_id=inc.incident_id,
+ form_type=form_type,
+ status=form_status,
+ )
+ db.add(frm)
+ db.commit()
+ db.refresh(frm)
+
+ return inc, frm
+
+
+# ---------------------------------------------------------------------------
+# POST /api/v1/reports/generate
+# ---------------------------------------------------------------------------
+
+class TestGenerateReportEndpoint:
+
+ @patch("app.api.routes.reports.generate_periodic_report")
+ def test_generate_monthly_report_accepted(self, mock_task, client, db):
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "monthly",
+ "year": 2024,
+ "month": 7,
+ "format": "pdf",
+ },
+ )
+ assert resp.status_code == 202
+ data = resp.json()
+ assert "job_id" in data
+ assert "report_id" in data
+ assert data["job_type"] == "report_generation"
+ assert data["status"] == "processing"
+ assert data["estimated_seconds"] == 30
+ assert data["poll_url"] == f"{REPORTS_URL}/{data['report_id']}"
+
+ # Verify Report record created in DB
+ report = db.exec(select(Report)).first()
+ assert report is not None
+ assert str(report.report_id) == data["report_id"]
+ assert report.period_type == PeriodType.monthly
+ assert report.year == 2024
+ assert report.month == 7
+ assert report.output_format == OutputFormat.pdf
+ assert report.status == JobStatus.queued
+
+ # Verify Celery task dispatched with task_id matching job
+ job = db.exec(select(Job)).first()
+ assert job is not None
+ assert job.job_id == data["job_id"]
+ mock_task.apply_async.assert_called_once_with(
+ (data["report_id"],),
+ task_id=job.celery_task_id,
+ )
+
+ @patch("app.api.routes.reports.generate_periodic_report")
+ def test_generate_quarterly_report_accepted(self, mock_task, client, db):
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "quarterly",
+ "year": 2024,
+ "quarter": 2,
+ "format": "json",
+ },
+ )
+ assert resp.status_code == 202
+ data = resp.json()
+ report = db.exec(select(Report)).first()
+ assert data["report_id"] == str(report.report_id)
+ assert report.period_type == PeriodType.quarterly
+ assert report.quarter == 2
+ assert report.output_format == OutputFormat.json
+
+ @patch("app.api.routes.reports.generate_periodic_report")
+ def test_generate_annual_report_accepted(self, mock_task, client, db):
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "annual",
+ "year": 2024,
+ "format": "both",
+ },
+ )
+ assert resp.status_code == 202
+ report = db.exec(select(Report)).first()
+ assert report.period_type == PeriodType.annual
+ assert report.output_format == OutputFormat.both
+
+ def test_validation_error_monthly_missing_month(self, client):
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "monthly",
+ "year": 2024,
+ },
+ )
+ assert resp.status_code == 422
+ body = resp.json()
+ assert body["error_code"] == "VALIDATION_ERROR"
+
+ def test_validation_error_quarterly_missing_quarter(self, client):
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "quarterly",
+ "year": 2024,
+ },
+ )
+ assert resp.status_code == 422
+ body = resp.json()
+ assert body["error_code"] == "VALIDATION_ERROR"
+
+ def test_validation_error_invalid_month(self, client):
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "monthly",
+ "year": 2024,
+ "month": 13,
+ },
+ )
+ assert resp.status_code == 422
+
+ def test_validation_error_invalid_quarter(self, client):
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "quarterly",
+ "year": 2024,
+ "quarter": 5,
+ },
+ )
+ assert resp.status_code == 422
+
+ def test_future_period_rejected(self, client):
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "annual",
+ "year": 2099,
+ },
+ )
+ assert resp.status_code == 422
+ body = resp.json()
+ assert body["error_code"] == "FUTURE_PERIOD"
+
+ @patch("app.api.routes.reports.generate_periodic_report")
+ def test_celery_queue_unavailable_returns_503(self, mock_task, client, db):
+ mock_task.apply_async.side_effect = ConnectionError("Redis broker is down")
+ resp = client.post(
+ GENERATE_URL,
+ json={
+ "period_type": "monthly",
+ "year": 2024,
+ "month": 5,
+ },
+ )
+ assert resp.status_code == 503
+ body = resp.json()
+ assert body["error_code"] == "QUEUE_UNAVAILABLE"
+
+ job = db.exec(select(Job)).first()
+ assert job.status == JobStatus.failed
+ assert job.error["error_code"] == "QUEUE_UNAVAILABLE"
+
+ report = db.exec(select(Report)).first()
+ assert report.status == JobStatus.failed
+
+
+# ---------------------------------------------------------------------------
+# GET /api/v1/reports/{report_id}
+# ---------------------------------------------------------------------------
+
+class TestGetReportEndpoint:
+
+ def test_report_not_found(self, client):
+ resp = client.get(f"{REPORTS_URL}/{uuid4()}")
+ assert resp.status_code == 404
+ assert resp.json()["error_code"] == "REPORT_NOT_FOUND"
+
+ def test_report_still_processing_returns_202(self, client, db):
+ report = Report(
+ period_type=PeriodType.monthly,
+ year=2024,
+ month=7,
+ output_format=OutputFormat.json,
+ status=JobStatus.processing,
+ )
+ db.add(report)
+ db.commit()
+
+ resp = client.get(f"{REPORTS_URL}/{report.report_id}")
+ assert resp.status_code == 202
+ body = resp.json()
+ assert body["status"] == "processing"
+ assert body["retry_after_seconds"] == 10
+ assert "still in progress" in body["message"]
+
+ def test_report_failed_returns_500(self, client, db):
+ report = Report(
+ period_type=PeriodType.monthly,
+ year=2024,
+ month=7,
+ output_format=OutputFormat.json,
+ status=JobStatus.failed,
+ )
+ db.add(report)
+ db.commit()
+
+ resp = client.get(f"{REPORTS_URL}/{report.report_id}")
+ assert resp.status_code == 500
+ assert resp.json()["error_code"] == "REPORT_FAILED"
+
+ def test_report_completed_json(self, client, db):
+ _seed_incident_with_form(db, incident_date=date(2024, 7, 10), incident_type="fire")
+ report = Report(
+ period_type=PeriodType.monthly,
+ year=2024,
+ month=7,
+ output_format=OutputFormat.json,
+ status=JobStatus.queued,
+ )
+ db.add(report)
+ db.commit()
+
+ # Run report generation
+ svc = ReportingService()
+ svc.generate_report(db, report.report_id)
+
+ # Retrieve completed report
+ resp = client.get(f"{REPORTS_URL}/{report.report_id}")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["report_id"] == str(report.report_id)
+ assert body["period_type"] == "monthly"
+ assert body["period_label"] == "July 2024"
+ assert body["summary"]["total_incidents"] == 1
+ assert body["summary"]["by_type"] == {"fire": 1}
+ assert body["summary"]["forms_generated"] == 1
+ assert len(body["incidents"]) == 1
+ assert body["incidents"][0]["incident_type"] == "fire"
+ assert body["compliance"]["neris_submission_rate"] == 100.0
+
+ def test_report_completed_pdf(self, client, db, tmp_path):
+ _seed_incident_with_form(db, incident_date=date(2024, 7, 15), incident_type="ems")
+ report = Report(
+ period_type=PeriodType.monthly,
+ year=2024,
+ month=7,
+ output_format=OutputFormat.pdf,
+ status=JobStatus.queued,
+ )
+ db.add(report)
+ db.commit()
+
+ # Run report generation
+ svc = ReportingService()
+ svc.generate_report(db, report.report_id)
+
+ # Retrieve PDF
+ resp = client.get(f"{REPORTS_URL}/{report.report_id}")
+ assert resp.status_code == 200
+ assert resp.headers["content-type"] == "application/pdf"
+ assert 'attachment; filename="FireForm_Monthly_2024-07.pdf"' in resp.headers.get("content-disposition", "")
+ assert resp.content.startswith(b"%PDF")
+
+ def test_report_pdf_file_missing_returns_500(self, client, db):
+ report = Report(
+ period_type=PeriodType.monthly,
+ year=2024,
+ month=7,
+ output_format=OutputFormat.pdf,
+ status=JobStatus.completed,
+ pdf_path="/non/existent/path/report.pdf",
+ )
+ db.add(report)
+ db.commit()
+
+ resp = client.get(f"{REPORTS_URL}/{report.report_id}")
+ assert resp.status_code == 500
+ assert resp.json()["error_code"] == "PDF_NOT_FOUND"
+
+
+# ---------------------------------------------------------------------------
+# ReportingService Aggregation Logic
+# ---------------------------------------------------------------------------
+
+class TestReportingServiceAggregation:
+
+ def test_quarterly_aggregation_multiple_incidents(self, db):
+ # Q2 2024: April, May, June
+ _seed_incident_with_form(db, incident_date=date(2024, 4, 5), incident_type="fire", status=ReportStatus.submitted)
+ _seed_incident_with_form(db, incident_date=date(2024, 5, 20), incident_type="ems", status=ReportStatus.approved)
+ _seed_incident_with_form(db, incident_date=date(2024, 6, 12), incident_type="ems", status=ReportStatus.draft)
+ # Incident outside Q2 (in July)
+ _seed_incident_with_form(db, incident_date=date(2024, 7, 1), incident_type="rescue")
+
+ report = Report(
+ period_type=PeriodType.quarterly,
+ year=2024,
+ quarter=2,
+ output_format=OutputFormat.json,
+ status=JobStatus.queued,
+ )
+ db.add(report)
+ db.commit()
+
+ svc = ReportingService()
+ result = svc.generate_report(db, report.report_id)
+
+ assert result.summary.total_incidents == 3
+ assert result.summary.by_type == {"fire": 1, "ems": 2}
+ assert result.summary.by_status[ReportStatus.submitted.value] == 1
+ assert result.summary.by_status[ReportStatus.approved.value] == 1
+ assert result.summary.by_status[ReportStatus.draft.value] == 1
+ assert len(result.summary.groups) == 3
+ assert result.summary.groups[0].period == "2024-04"
+ assert result.summary.groups[0].incident_count == 1
+ assert result.summary.groups[1].period == "2024-05"
+ assert result.summary.groups[1].incident_count == 1
+ assert result.summary.groups[2].period == "2024-06"
+ assert result.summary.groups[2].incident_count == 1
+
+ def test_empty_period_generates_zero_counts(self, db):
+ report = Report(
+ period_type=PeriodType.monthly,
+ year=2024,
+ month=1,
+ output_format=OutputFormat.json,
+ status=JobStatus.queued,
+ )
+ db.add(report)
+ db.commit()
+
+ svc = ReportingService()
+ result = svc.generate_report(db, report.report_id)
+
+ assert result.summary.total_incidents == 0
+ assert result.summary.by_type == {}
+ assert result.summary.by_status == {}
+ assert result.summary.forms_generated == 0
+ assert result.compliance.neris_submission_rate == 0.0
+ assert result.incidents == []
+
+
+# ---------------------------------------------------------------------------
+# Celery Task: generate_periodic_report
+# ---------------------------------------------------------------------------
+
+class TestReportCeleryTask:
+
+ def test_task_completes_job_and_report(self, test_engine):
+ with Session(test_engine) as session:
+ job = Job(
+ job_type=JobType.report_generation,
+ celery_task_id="celery-report-task-1",
+ status=JobStatus.queued,
+ )
+ create_job(session, job)
+
+ report = Report(
+ period_type=PeriodType.monthly,
+ year=2024,
+ month=7,
+ output_format=OutputFormat.json,
+ status=JobStatus.queued,
+ job_id=UUID(job.job_id),
+ )
+ create_report(session, report)
+ rep_id = str(report.report_id)
+
+ def _test_session_gen():
+ with Session(test_engine) as s:
+ yield s
+
+ with patch("app.tasks.report.get_session", _test_session_gen):
+ res = generate_periodic_report.apply(
+ args=(rep_id,), task_id="celery-report-task-1"
+ )
+
+ assert res.result["report_id"] == rep_id
+
+ with Session(test_engine) as session:
+ updated_job = session.exec(select(Job).where(Job.celery_task_id == "celery-report-task-1")).first()
+ assert updated_job.status == JobStatus.completed
+ assert updated_job.progress_percent == 100
+ assert updated_job.result_url == f"/api/v1/reports/{rep_id}"
+
+ updated_report = get_report(session, UUID(rep_id))
+ assert updated_report.status == JobStatus.completed
+ assert updated_report.json_data is not None
+ assert updated_report.summary is not None