From 476746f3fe5824fe97f8afdbcf3660084f035ebd Mon Sep 17 00:00:00 2001 From: Aliussman Date: Sun, 25 Oct 2026 00:12:54 +0530 Subject: [PATCH] feat(reporting): implement periodic report generation API (#557) --- app/api/router.py | 15 +- app/api/routes/reports.py | 149 +++++++++ app/api/schemas/common.py | 1 + app/api/schemas/reporting.py | 79 +++++ app/core/celery.py | 7 +- app/core/config.py | 3 + app/db/repositories.py | 63 +++- app/services/reporting.py | 558 +++++++++++++++++++++++++++++++ app/tasks/report.py | 67 ++++ contracts/openapi.yaml | 8 + contracts/path/reporting.yaml | 191 +++++++++++ contracts/schemas/reporting.yaml | 112 +++++++ requirements.txt | 1 + tests/test_api.py | 4 +- tests/test_v1_reports.py | 472 ++++++++++++++++++++++++++ 15 files changed, 1723 insertions(+), 7 deletions(-) create mode 100644 app/api/routes/reports.py create mode 100644 app/api/schemas/reporting.py create mode 100644 app/services/reporting.py create mode 100644 app/tasks/report.py create mode 100644 contracts/path/reporting.yaml create mode 100644 contracts/schemas/reporting.yaml create mode 100644 tests/test_v1_reports.py diff --git a/app/api/router.py b/app/api/router.py index 0515c63c..dba865fb 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 00000000..8141f099 --- /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 8d29d318..145724a6 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 00000000..8214ed4d --- /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 8e7d9bba..623c8829 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 83eaa1af..5fd6f4e4 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 51f62824..3cbe3bbf 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 00000000..833b7a59 --- /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 00000000..7476a7c5 --- /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 ae2ad41f..a9f54c6f 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 00000000..ad101723 --- /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 00000000..f352e709 --- /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 6d662870..e15bda68 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 30882157..d810129b 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 00000000..e3036335 --- /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