Skip to main content

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

FileAction
app/enum/service_type.pyAdd MY_SERVICE = "my_service"
app/enum/report_type.pyAdd new type (only if introducing a new aggregation period)
app/services/report/report_types/my_service_report.pyCreate — extend GenericReport; return templateName
app/services/report/report.pyRegister in SERVICE_CLASS_MAP
app/formatters/report/my_service_formatter.pyCreate — transform data dict to template context
app/templates/my_service/daily.htmlCreate 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),
}
Required keys in the data dict
  • templateName — path from Templates class; read by ReportFormatter immediately on construction — missing it raises KeyError
  • fileName — used as the Content-Disposition filename
  • 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_actual
  • self.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_span must match the number of <th> columns exactly
  • | decimal_format rounds 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
Test checklist
  • HTML renders without template errors before testing binary formats
  • report_generation_time appears in the footer (PDF)
  • fileName produces a sensible Content-Disposition value
  • divisionFactor applied correctly in formatter — raw values divided before display
  • unitIds=null handled: self.request.unit_ids will be None — guard with or all_units