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
| Path | Role |
|---|---|
lumie-worker/services/report/main.py | FastAPI app, lifespan, health route, sync fallback route |
lumie-worker/services/report/src/schema.py | MQ command and callback payload models |
lumie-worker/services/report/src/usecase.py | Report generation orchestration |
lumie-worker/services/report/src/mq/consumer.py | RabbitMQ consumer and callback payload builders |
lumie-worker/services/report/src/adapters/reportlab_renderer.py | ReportLab PDF rendering |
lumie-worker/services/report/src/domain/report_data.py | Typed report data model |
lumie-worker/services/report/src/adapters/exam_api.py | Backend exam, result, rank, and statistics client |
lumie-worker/services/report/src/adapters/student_api.py | Backend student profile client |
lumie-worker/contracts/mq-schemas-v1.yaml | Hand-maintained report MQ contract reference |
Public Surface
Operational routes:
GET /healthGET /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
- FastAPI startup builds the dependency container, warms the ReportLab renderer, connects the callback publisher, and starts the RabbitMQ consumer.
GenerateReportUseCasefans out five backend reads in parallel: student profile, exam statistics, stability index, exam result for the student, and student rank.- Rank lookup is best-effort. If it fails, the report still renders.
- The worker fetches question-level results for the selected exam result.
- It assembles a typed
ReportDataobject. - ReportLab renders the report as a one-page PDF through
ReportRenderer.generate_pdf(...). - 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-SlugX-SignatureX-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
| Dependency | Why it exists |
|---|---|
RabbitMQ via aio-pika | Receive report jobs and publish completion callbacks |
Backend HTTP API via httpx | Fetch student, exam, result, rank, and stability data |
| ReportLab | Render typed report data into a one-page PDF |
| MinIO | Store generated PDFs for batch report jobs |
| OpenTelemetry and Prometheus | Measure latency and trace backend plus rendering work |
Failure Semantics
| Failure | Behavior |
|---|---|
| Malformed MQ body | Rejected by shared MQ processing and sent toward the broker DLQ path |
| Backend rank lookup fails | Logged and ignored; report still renders without rank |
| Backend exam result is missing | HTTPException(404) and failure callback on MQ path |
| Backend required data call fails | Handler failure metric and MQ retry path |
| Callback publish fails after retries | MQ 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 /healthreturns both status and service name./metricspublishesreport_generation_total,report_generation_duration_seconds, andreport_generation_inflight.- The service closes the callback publisher, report renderer, and shared
httpx.AsyncClientduring shutdown. LUMIE_BACKEND_URLandLUMIE_INTERNAL_HMAC_SECRETare required settings.- The sync fallback route builds a
ReportCommandinternally and returns the raw PDF bytes withmedia_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:
pytestexits0and theservices/report/tests/test_generate_report_usecase.pyslice passes- the worker grep shows
report.generation-requestinservices/report/src/config.pyandreport.generation.callbackinservices/report/src/adapters/callback_mq.py - a successful batch run eventually makes backend
GET /v1/exams/{examId}/reports/jobs/{jobId}returnprocessedReports == totalReports - when at least one report succeeded,
zipFileKeybecomes non-null inReportJobStatusResponse