Adding a Report Service
Quick steps to add a new report service end-to-end. For the full template system — shared includes, page numbers, page splitting, XLSX sheets, decimal_format, xhtml2pdf quirks, and all data context variables — see Report Template System.
Files to create / modify
| File | Action |
|---|---|
app/enum/service_type.py | Add MY_SERVICE = "my_service" |
app/enum/report_type.py | Add new type (only if introducing a new aggregation period) |
app/services/report/report_types/my_service_report.py | Create — extend GenericReport; return templateName |
app/services/report/report.py | Register in SERVICE_CLASS_MAP |
app/formatters/report/my_service_formatter.py | Create — transform data dict to template context |
app/templates/my_service/daily.html | Create Jinja2 template(s) |
Step 1 — Add the ServiceType enum value
File: app/enum/service_type.py
MY_SERVICE = "my_service"
Step 2 — Add a ReportType enum value (if needed)
File: app/enum/report_type.py
Only required when introducing a new aggregation type. If your service uses an existing type (daily, monthly, etc.), skip this.
MY_TYPE = "my_type"
If added, handle it in ReportService.get_report() with a new dispatch branch.
Step 3 — Create the report service class
File: app/services/report/report_types/my_service_report.py
from app.services.report.generic_report_template import GenericReport
from app.constants.templates import Templates
from concurrent.futures import ThreadPoolExecutor
class MyServiceReport(GenericReport):
def get_daily_report(self):
with ThreadPoolExecutor() as executor:
futures = {
uid: executor.submit(
db.get_device_data,
uid, self.request.start_date, self.request.end_date
)
for uid in self.request.unit_ids or self.all_unit_ids
}
raw = {uid: f.result() for uid, f in futures.items()}
return {
'templateName': Templates.daily_flow, # ← required; use a Templates constant
'fileName': f'my_service_daily_{self.request.start_date}',
'startDate': self.request.start_date,
'endDate': self.request.end_date_actual, # ← use end_date_actual for display
'rows': self._build_rows(raw),
}
templateName— path fromTemplatesclass; read byReportFormatterimmediately on construction — missing it raisesKeyErrorfileName— used as theContent-Dispositionfilename- Any keys your Jinja2 template references via
data.*
self.request exposes: industry_id, start_date, end_date, end_date_actual, unit_id, unit_ids, use_shift, timezone, division_factor, start_time, end_time, format, additional_data.
end_date vs end_date_actualself.request.end_date— exclusive upper bound for DB queries (one day past last data day)self.request.end_date_actual— display date for the report header
Always use end_date_actual in "endDate" returned to the template.
Step 4 — Register in ReportService
File: app/services/report/report.py
from app.services.report.report_types.my_service_report import MyServiceReport
SERVICE_CLASS_MAP = {
...
ServiceType.MY_SERVICE: MyServiceReport,
}
Step 5 — Create the data formatter
File: app/formatters/report/my_service_formatter.py
class MyServiceFormatter:
def __init__(self, request, data):
self.request = request
self.data = data
def get_formatted_data(self):
rows = []
for row in self.data['rows']:
rows.append({
'unit_name': row['label'],
'value': round(row['raw_value'] / self.request.division_factor, 2),
'si_unit': row['si_unit'],
'date': row['date'],
})
return {
'rows': rows,
'fileName': self.data['fileName'],
'startDate': self.data['startDate'],
'endDate': self.data['endDate'],
}
Step 6 — Create the Jinja2 HTML template
File: app/templates/my_service/daily.html
Every template uses {% include %} — not {% extends %}. See Report Template System for all shared includes, page numbers, XLSX sheet naming, and xhtml2pdf constraints.
<!DOCTYPE html>
<html lang="en">
{% set size = 'A4 portrait' %}
{% set margin_bottom = 1 %}
{% include 'common/head_content.html' %}
<body>
{% if data.isPdf %}
{% include 'footer_content.html' %}
{% endif %}
<table>
{% set header_range_text = data.startDate %}
{% set col_span = 3 %}
{% include 'common/header.html' %}
<tr>
<th style="text-align:left;">Unit</th>
<th>Date</th>
<th>Value</th>
</tr>
{% for row in data.my_service.rows %}
<tr>
<td style="text-align:left;">{{ row.unit_name }}</td>
<td>{{ row.date }}</td>
<td>{{ row.value | decimal_format }}</td>
</tr>
{% endfor %}
</table>
</body>
</html>
col_spanmust match the number of<th>columns exactly| decimal_formatrounds to 2dp, returns int if whole number- For multi-unit page breaks and XLSX sheet splitting, see Report Template System — Page Splitting
Step 7 — Verify ReportFormatter wiring
File: app/formatters/report/report.py
ReportFormatter has no TEMPLATE_MAP. It reads data['templateName'] directly — the path you set in Step 3 is what gets rendered. Nothing to add here for a standard service.
The only exception: if your service needs Playwright for PDF instead of xhtml2pdf, add an or condition to the existing check:
# Already in ReportFormatter.get_file():
if self.reportTemplateName == Templates.executive: # ← or add your template here
return get_pdf_file_playwright(html_data, self.fileName)
Step 8 — Test
# 1. Verify HTML rendering first
GET /api/user/report?reportType=daily&reportFormat=html&service=my_service&startDate=09/11/2024
Authorization: Bearer <access_token>
# 2. Test PDF
GET /api/user/report?reportType=daily&reportFormat=pdf&service=my_service&startDate=09/11/2024
# 3. Test XLSX
GET /api/user/report?reportType=daily&reportFormat=xlsx&service=my_service&startDate=09/11/2024
- HTML renders without template errors before testing binary formats
-
report_generation_timeappears in the footer (PDF) -
fileNameproduces a sensibleContent-Dispositionvalue -
divisionFactorapplied correctly in formatter — raw values divided before display -
unitIds=nullhandled:self.request.unit_idswill beNone— guard withor all_units