Skip to main content

Report

Purpose

report-svc builds a per-student exam report from backend data and renders it as a one-page PDF with ReportLab. Its main production path is asynchronous through RabbitMQ, but it also keeps a synchronous fallback route for single-report generation.

For shared worker conventions, see Workers Overview.

Source Paths

PathRole
lumie-worker/services/report/main.pyFastAPI app, lifespan, health route, sync fallback route
lumie-worker/services/report/src/schema.pyMQ command and callback payload models
lumie-worker/services/report/src/usecase.pyReport generation orchestration
lumie-worker/services/report/src/mq/consumer.pyRabbitMQ consumer and callback payload builders
lumie-worker/services/report/src/adapters/reportlab_renderer.pyReportLab PDF rendering
lumie-worker/services/report/src/domain/report_data.pyTyped report data model
lumie-worker/services/report/src/adapters/exam_api.pyBackend exam, result, rank, and statistics client
lumie-worker/services/report/src/adapters/student_api.pyBackend student profile client
lumie-worker/contracts/mq-schemas-v1.yamlHand-maintained report MQ contract reference

Public Surface

Operational routes:

  • GET /health
  • GET /metrics

Synchronous fallback route:

  • POST /v1/reports/students/{student_id}/exams/{exam_id}
  • required header: X-Tenant-Slug
  • response type: application/pdf

RabbitMQ surfaces:

  • Command queue: report.generation-request
  • Callback routing key: report.generation.callback

The queue payload is validated as ReportCommand in services/report/src/schema.py.

ReportCommand Example

{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"schemaVersion": 1
}

Success Callback Example

services/report/src/mq/consumer.py base64-encodes result["report_bytes"] into reportBytes, and backend ReportCallbackRequest accepts the same fields.

{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"success": true,
"error": null,
"reportBytes": "<base64-pdf>"
}

Failure Callback Example

{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"success": false,
"error": "404: exam result not found",
"reportBytes": null
}

Processing Flow

  1. FastAPI startup builds the dependency container, warms the ReportLab renderer, connects the callback publisher, and starts the RabbitMQ consumer.
  2. GenerateReportUseCase fans out five backend reads in parallel: student profile, exam statistics, stability index, exam result for the student, and student rank.
  3. Rank lookup is best-effort. If it fails, the report still renders.
  4. The worker fetches question-level results for the selected exam result.
  5. It assembles a typed ReportData object.
  6. ReportLab renders the report as a one-page PDF through ReportRenderer.generate_pdf(...).
  7. The MQ path base64-encodes the generated bytes into the callback field named reportBytes.

If the worker cannot find an exam result for the requested student and exam pair, it raises HTTPException(404).

Data The Worker Pulls From The Backend

The report worker does not query the database directly. It calls backend /internal/** routes through signed httpx adapters:

  • /internal/reports/students/<studentId>
  • /internal/reports/exams/<examId>/statistics
  • /internal/reports/students/<studentId>/stability
  • /internal/reports/students/<studentId>/rank
  • /internal/reports/students/<studentId>/results
  • /internal/reports/results/<resultId>/questions

ExamClient caches exam statistics for 60 seconds to avoid repeated reads during a large batch.

All /internal/** requests include:

  • X-Tenant-Slug
  • X-Signature
  • X-Timestamp

Those headers are produced through the shared HMAC signing helper. Do not add unsigned backend reads to this service.

Rendering Model

The current implementation renders a PDF:

  • ReportRenderer.generate_pdf(...) returns PDF bytes
  • the sync HTTP route responds with application/pdf
  • the MQ callback base64-encodes those PDF bytes into reportBytes

The renderer runs ReportLab work through asyncio.to_thread(...) so CPU-bound PDF generation does not block the event loop.

External Dependencies

DependencyWhy it exists
RabbitMQ via aio-pikaReceive report jobs and publish completion callbacks
Backend HTTP API via httpxFetch student, exam, result, rank, and stability data
ReportLabRender typed report data into a one-page PDF
MinIOStore generated PDFs for batch report jobs
OpenTelemetry and PrometheusMeasure latency and trace backend plus rendering work

Failure Semantics

FailureBehavior
Malformed MQ bodyRejected by shared MQ processing and sent toward the broker DLQ path
Backend rank lookup failsLogged and ignored; report still renders without rank
Backend exam result is missingHTTPException(404) and failure callback on MQ path
Backend required data call failsHandler failure metric and MQ retry path
Callback publish fails after retriesMQ message is nacked for broker retry

Queue processing treats callback delivery as part of job completion. A generated PDF without a delivered callback is not considered a completed report job.

Operational Notes

  • Default queue prefetch is 2, lower than grading because PDF rendering is CPU and memory sensitive.
  • GET /health returns both status and service name.
  • /metrics publishes report_generation_total, report_generation_duration_seconds, and report_generation_inflight.
  • The service closes the callback publisher, report renderer, and shared httpx.AsyncClient during shutdown.
  • LUMIE_BACKEND_URL and LUMIE_INTERNAL_HMAC_SECRET are required settings.
  • The sync fallback route builds a ReportCommand internally and returns the raw PDF bytes with media_type="application/pdf".

Backend Trigger Example

The backend batch entrypoint that creates report jobs is ReportController.createBatchReport(...) at POST /v1/exams/{examId}/reports/batch.

curl -i \
-X POST http://localhost:8080/v1/exams/42/reports/batch \
-H 'Content-Type: application/json' \
-H 'X-Tenant-Slug: demo' \
-H 'Cookie: lumie_access_token=<staff-session-cookie>' \
-d '{"studentIds":[1001,1002]}'

Expected backend response:

  • HTTP 202 Accepted
  • Location: /v1/exams/42/reports/jobs/{jobId}
  • JSON body with the accepted job id and status URL

Verification

cd lumie-worker
uv run pytest services/report/tests
cd /path/to/Lumie
rg -n "report.generation-request|report.generation.callback|reportBytes|generate_pdf" \
lumie-worker/services/report lumie-worker/contracts/mq-schemas-v1.yaml

Verification success signals:

  • pytest exits 0 and the services/report/tests/test_generate_report_usecase.py slice passes
  • the worker grep shows report.generation-request in services/report/src/config.py and report.generation.callback in services/report/src/adapters/callback_mq.py
  • a successful batch run eventually makes backend GET /v1/exams/{examId}/reports/jobs/{jobId} return processedReports == totalReports
  • when at least one report succeeded, zipFileKey becomes non-null in ReportJobStatusResponse