Skip to main content

Task 05 — Add a New Report Type

Difficulty: Intermediate
Goal: Add a new report service (e.g. a "Pump Runtime Report") end-to-end — enum, service class, formatter, Jinja2 template, and dispatcher registration.


What you'll learn
  • The full 7-step report pipeline (see Adding a Report Service)
  • How GenericReport structures daily/monthly/yearly methods
  • How the formatter transforms raw data into a Jinja2 context
  • PDF vs HTML rendering constraints

Your task: "Pump Runtime Report"

Build a report that shows how many hours each pump unit was running per day in a given date range.

Service name: pump_runtime


Files to create / edit

FileAction
app/enum/service_type.pyAdd PUMP_RUNTIME = "pump_runtime"
app/services/report/report_types/pump_runtime_report.pyExtend GenericReport
app/services/report/report.pyRegister in SERVICE_CLASS_MAP
app/formatters/report/pump_runtime_formatter.pyTransform raw data
app/formatters/report/report.pyAdd to TEMPLATE_MAP and FORMATTER_MAP
app/templates/pump_runtime/daily.htmlJinja2 HTML template

Report class skeleton

# app/services/report/report_types/pump_runtime_report.py
from app.services.report.generic_report_template import GenericReport
from app.database.database_supporter import DatabaseSupporter
from app.constants.templates import Templates
from concurrent.futures import ThreadPoolExecutor

class PumpRuntimeReport(GenericReport):

def get_daily_report(self):
with ThreadPoolExecutor() as executor:
futures = {
uid: executor.submit(
DatabaseSupporter.get_pump_runtime_by_unit_and_date,
uid,
self.request.start_date,
self.request.end_date,
)
for uid in self.request.unit_ids
}
raw = {uid: f.result() for uid, f in futures.items()}

return {
'templateName': Templates.daily_pump_runtime, # add this constant to Templates
'rows': raw,
'fileName': f'pump_runtime_daily_{self.request.start_date}',
'startDate': self.request.start_date,
'endDate': self.request.end_date_actual,
}

Formatter skeleton

# app/formatters/report/pump_runtime_formatter.py
class PumpRuntimeFormatter:
def __init__(self, request, data):
self.request = request
self.data = data

def get_formatted_data(self):
rows = []
for unit_id, records in self.data['rows'].items():
for rec in records:
rows.append({
'unit_name': self.request.additional_data.get('unitsMapping', {}).get(unit_id, {}).get('unitName', unit_id),
'date': rec['date'],
'runtime_hrs': round(rec.get('runtimeMinutes', 0) / 60, 2),
})
return {
'rows': rows,
'fileName': self.data['fileName'],
'startDate': self.data['startDate'],
'endDate': self.data['endDate'],
}

Template skeleton (app/templates/pump_runtime/daily.html)

All templates use {% include %} — not {% extends %}. The shared includes provide the <head>, page CSS, header rows, and footer. See Report Template System for full details.

<!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>Runtime (hrs)</th>
</tr>

{% for row in data.pump_runtime.rows %}
<tr>
<td style="text-align: left;">{{ row.unit_name }}</td>
<td>{{ row.date }}</td>
<td>{{ row.runtime_hrs | decimal_format }}</td>
</tr>
{% endfor %}

</table>
</body>
</html>

Key things to get right:

  • col_span = 3 must match the number of <th> columns exactly
  • data.pump_runtime.rows — the formatter puts your data under a service-specific key inside data
  • | decimal_format — rounds to 2 dp, returns int if whole number
  • The footer only appears in PDF ({% if data.isPdf %})
  • For multi-unit reports, add the page-break pattern between units (see Report Template System — Page Splitting)

Testing sequence

# 1. Check HTML renders correctly
GET /api/user/report?reportType=daily&reportFormat=html&service=pump_runtime&startDate=01/07/2025
Authorization: Bearer <token>

# 2. Generate PDF
GET /api/user/report?reportType=daily&reportFormat=pdf&service=pump_runtime&startDate=01/07/2025
Authorization: Bearer <token>

Done when
  • pump_runtime appears as a valid service param
  • HTML report renders with data for each unit
  • PDF generates without errors
  • fileName in the response has a sensible name

Edge cases to verify

Test these scenarios before shipping
ScenarioWhat to verify
startDate not providedServer defaults to the industry's shift-adjusted current date (current_user['nowUTCWithShiftDateTime']) — your report class must handle this cleanly
unitIds=null in the requestThe server normalises ['null']None (all units); self.request.unit_ids will be None, not a list
reportFormat=html renders before testing PDFAlways test HTML first — template errors surface immediately without the PDF conversion step obscuring them
Formatter receives division_factorApply round(raw_value / self.request.division_factor, 2) in the formatter — never assume the caller sends the correct scale
end_date_actual vs end_date in templateUse self.request.end_date_actual for the display date in the report header; use self.request.end_date as the DB query bound
Report class method for daily type is get_daily_report()The dispatcher maps reportType=dailyget_daily_report(). If you name the method differently, it will not be called
Service not registered in TEMPLATE_MAPReportFormatter will raise a KeyError — verify the service is added to both TEMPLATE_MAP and FORMATTER_MAP
Template references a key not in the formatter outputJinja2 will raise UndefinedError at render time — confirm every template variable is present in get_formatted_data() return dict