Report Template System
This page covers everything you need to write and maintain report HTML templates in AquaGen API: the GenericReport base class design, all report types and how each one works, the shared template includes (common/head_content.html, common/header.html, footer), custom data context variables, page numbering, page splitting, XLSX sheet naming, and known xhtml2pdf quirks.
GenericReport — Base Class
File: app/services/report/generic_report_template.py
Every report service class extends GenericReport. It provides a consistent dispatch interface and shared helpers so individual report classes only implement the data logic for the period types they support.
from app.services.report.generic_report_template import GenericReport
class MyServiceReport(GenericReport):
def get_daily_report(self): ...
def get_monthly_report(self): ...
def get_hourly_report(self): ...
What GenericReport provides
| Attribute / Method | What it gives you |
|---|---|
self.request | ReportRequestData dataclass — all resolved request parameters |
self.industry_data | Full industry document from Cosmos DB |
self.unit_ids | Resolved list of unit IDs to include (request.unit_ids, or all units if None) |
self.units_mapping | dict — unitId → unit document for fast lookups |
self.timezone | Industry timezone string (e.g. "Asia/Kolkata") |
get_report() | Dispatcher — reads self.request.report_type and calls the matching method |
get_report() dispatch table
report_type | Method called on your class |
|---|---|
daily | get_daily_report() |
monthly | get_monthly_report() |
hourly | get_hourly_report() |
granular | get_granular_report() |
weekly | get_weekly_report() |
daily_range | get_daily_range_report() |
monthly_range | get_monthly_range_report() |
custom | get_custom_report() |
summary | get_summary_report() |
aqua_gpt | get_aqua_gpt_report() |
If your class does not implement a method for a requested report_type, it raises NotImplementedError. Only implement the types your service supports.
Exception — SmartCityReport: It overrides get_report() directly and bypasses this dispatch table entirely.
self.request fields (ReportRequestData)
| Field | Type | Description |
|---|---|---|
industry_id | str | Industry being reported on |
start_date | str | DD/MM/YYYY — start date |
end_date | str | Exclusive upper DB query bound |
end_date_actual | str | Display end date — use this in "endDate" key returned to formatter |
unit_id | str | None | Single unit filter |
unit_ids | list | None | Multi-unit filter; None = all units |
use_shift | bool | Whether to align dates to industry shift |
timezone | str | Industry timezone |
division_factor | int | Divisor applied to raw readings before display |
start_time | str | None | Custom shift start override |
end_time | str | None | Custom shift end override |
format | str | Template version flag ("v1", "v2") |
additional_data | dict | None | POST body, if request was a POST |
report_type | ReportType | The requested period type |
report_format | ReportFormat | The requested output format |
service_type | ServiceType | The requested service |
Data dict contract
Each get_*_report() method must return a dict. It is passed to the formatter class and then merged into the Jinja2 context as data. Required keys:
| Key | Required | Description |
|---|---|---|
fileName | Yes | Used as Content-Disposition filename |
templateName | Yes | Path relative to app/templates/ — looked up from Templates constants |
startDate | Recommended | Display start date |
endDate | Recommended | Use self.request.end_date_actual here, not end_date |
| (service-specific keys) | As needed | Any key your template references via data.* |
report_generation_time is injected by the route after get_report() returns — you do not set it.
templateName — use Templates constants
File: app/constants/templates.py
Template paths are never hardcoded as strings in report classes. Always use the Templates class:
from app.constants.templates import Templates
def get_daily_report(self):
...
return {
'templateName': Templates.daily_flow, # 'flow/water_consumption_daily_report.html'
'fileName': f'water_daily_{self.request.start_date}',
...
}
All registered template paths:
Templates.* | Template file |
|---|---|
daily_flow | flow/water_consumption_daily_report.html |
daily_energy | energy/energy_daily_report.html |
daily_level | level/level_daily_report.html |
daily_quality | quality/quality_daily_report.html |
daily_borewell | borewell/water_borewell_daily_report.html |
daily_summary | summaryv2/daily_summary.html |
daily_flow_range | flow/water_consumption_daily_custom_range_report.html |
daily_energy_range | energy/energy_daily_custom_range_report.html |
daily_level_range | level/level_daily_custom_range_report.html |
daily_borewell_range | borewell/borewell_daily_custom_range_report.html |
daily_quality_range | quality/quality_daily_custom_range_report.html |
daily_water_balance | water_balance/water_balance_daily_report.html |
daily_gw | gw/gw_daily_report.html |
daily_rain_water | rain_water/rain_water_daily_report.html |
monthly_flow | flow/water_consumption_monthly_report.html |
monthly_energy | energy/energy_monthly_report.html |
monthly_level | level/level_monthly_report.html |
monthly_quality | quality/quality_monthly_report.html |
monthly_borewell | borewell/water_borewell_monthly_report.html |
monthly_consolidated | consolidated/monthly_consolidated.html |
monthly_water_balance | water_balance/water_balance_monthly_report.html |
monthly_flow_range | flow/water_consumption_monthly_custom_range_report.html |
monthly_energy_range | energy/energy_monthly_custom_range_report.html |
custom_water_balance | water_balance/water_balance_custom_report.html |
monthly_gw | gw/gw_monthly_report.html |
monthly_rain_water | rain_water/rain_water_monthly_report.html |
monthly_smart_city_report | smart_city/smart_city_monthly_report.html |
hourly_flow | flow/water_consumption_hourly_report.html |
hourly_energy | energy/energy_hourly_report.html |
hourly_quality | quality/quality_hourly_report.html |
hourly_borewell | borewell/water_borewell_hourly_report.html |
hourly_level | level/level_hourly_report.html |
hourly_rain_water | rain_water/rain_water_hourly_report.html |
granular_flow | flow/water_consumption_fivem_report.html |
granular_quality | quality/quality_fivem_report.html |
granular_borewell | borewell/water_borewell_fivem_report.html |
granular_energy | energy/energy_fivem_report.html |
granular_alert | alert/alert_fivem_report.html |
granular_level | level/level_fivem_report.html |
granular_rain_water | rain_water/rain_water_fivem_report.html |
summary_rain_water | rain_water/rain_water_summary_report.html |
weakly_rain_water | rain_water/rain_water_weakly_report.html |
aqua_gpt | aqua_gpt/aqua_gpt_report.html |
executive | executive/executive_report.html |
When you add a new service, add a new constant to Templates and reference it from your report class.
All Report Types — How They Work
daily → get_daily_report()
Single-day totals. self.request.start_date is the date; self.request.end_date is start_date + 1 day (exclusive DB query bound).
Pattern: fetch all units' processed daily docs for start_date, format each unit's values, return a row list.
def get_daily_report(self):
with ThreadPoolExecutor() as executor:
futures = {
uid: executor.submit(
DatabaseSupporter.get_daily_data,
self.request.industry_id, uid, self.request.start_date
)
for uid in self.unit_ids
}
raw = {uid: f.result() for uid, f in futures.items()}
return {
'templateName': Templates.daily_flow,
'fileName': f'my_service_daily_{self.request.start_date}',
'startDate': self.request.start_date,
'endDate': self.request.end_date_actual,
'rows': self._build_rows(raw),
}
monthly → get_monthly_report()
Full calendar month. start_date is always the 1st of the month (the route ensures this). end_date is the last day of the month + 1 (exclusive), auto-calculated unless the service is smart_city or consolidated and the caller provided endDate.
Pattern: fetch each unit's monthly processed doc directly.
hourly → get_hourly_report()
Per-hour breakdown. endDate is required (not auto-calculated). The hourly array from the unit's daily processed doc (doc['hourly']) is the source — each element is {x: timestamp, y: value}.
granular → get_granular_report()
Raw 5-minute readings across a date range. The caller passes the last data day as endDate; the route adds +1 day to end_date for DB queries. Always use self.request.end_date (not end_date_actual) as the DB query upper bound.
daily_range → get_daily_range_report()
Per-day totals across a multi-day range. Both startDate and endDate required.
monthly_range → get_monthly_range_report()
Per-month totals across a multi-month range. Both dates required.
custom → get_custom_report()
Flexible range — endDate is optional (defaults to startDate + 1 day). Used by water_balance and executive.
weekly / summary / aqua_gpt
Service-specific — only implemented on services that need them (rain_water for weekly/summary, aqua_gpt service for aqua_gpt).
All Service Types
service | Class | Supported types |
|---|---|---|
water | FlowReport | daily, monthly, hourly, granular, daily_range, monthly_range |
energy | EnergyReport | daily, monthly, hourly, granular, daily_range, monthly_range |
level | LevelReport | daily, monthly, hourly, granular, daily_range |
quality | QualityReport | daily, monthly, hourly, granular, daily_range |
borewell | BorewellReport | daily, monthly, hourly, granular, daily_range |
rain_water | RainWaterReport | daily, monthly, granular, weekly, summary |
summary | SummaryReport | daily |
smart_city | SmartCityReport | monthly — overrides get_report() directly |
water_balance | WaterBalanceReport | monthly, custom |
alert | AlertReport | granular |
consolidated | ConsolidatedReport | monthly |
custom_water | CustomFlowReport | daily, monthly, hourly, granular — XLSX via HTML-to-XLSX |
custom_energy | CustomEnergyReport | daily, monthly, hourly |
custom_level | CustomLevelReport | daily, monthly — XLSX via HTML-to-XLSX |
custom_quality | CustomQualityReport | daily, monthly |
custom_withdrawal | CustomWithdrawalReport | daily, monthly — XLSX via HTML-to-XLSX |
ground_water_withdrawal | GroundWaterWithdrawalReport | daily, monthly, hourly, granular |
executive | ExecutiveReport | monthly, custom — PDF via Playwright |
aqua_gpt | AquaGPTReport | daily, aqua_gpt |
Shared Template Infrastructure
All report HTML templates live in app/templates/. Templates are standalone HTML files — they do not use {% extends %}. Instead they use {% include %} to pull in shared components.
common/head_content.html
File: app/templates/common/head_content.html
Included at the top of every template. Renders the <head> block with:
- Global table CSS (
border-collapse,table-layout: fixed,td/thpadding and borders) @pageCSS rule for PDF page size and margins- Optional
@page second_templatefor templates that mix two page orientations .heading_tdand.red-textutility classes- A
@frame footer_framethat pins the footer<div id="footer_content">to the bottom of every page
{% set size = 'A4 landscape' %}
{% set margin_bottom = 1 %}
{% include 'common/head_content.html' %}
Variables you can set before the include:
| Variable | Default | Effect |
|---|---|---|
size | 'A4 portrait' | @page { size: ... } — controls page size and orientation |
page_2_size | 'A4 portrait' | Second page orientation, only if second_template is truthy |
margin_bottom | 0.5 | Bottom margin in cm — increase when footer overlaps content |
second_template | None | When truthy, emits the @page second_template rule |
Set margin_bottom = 1 for standard reports. For reports with large tables that run close to the footer, set it to 1.5 or more to prevent overlap.
common/header.html
File: app/templates/common/header.html
Included inside the main <table> (not outside it — the header rows are <tr> elements). Renders:
- Industry logo row
- Industry name row
- Report title row (
data.title) - Date range row (
header_range_text) - Shift/time row (
data.shift) - SI unit line (
data.siUnit) or custom header line (data.headerLine)
<table>
{% set header_range_text = data.startDate %}
{% set col_span = 4 %}
{% include 'common/header.html' %}
<!-- your table rows follow here -->
</table>
Variables you must set before the include:
| Variable | Required | Description |
|---|---|---|
header_range_text | Yes | The date or range string shown in the header (e.g. data.startDate, or data.startDate ~ ' to ' ~ data.endDate) |
col_span | Yes | How many columns the header spans. Must match your table's actual column count |
Variables you can set to customise:
| Variable | Default | Description |
|---|---|---|
text_align | 'center' | Alignment of all header rows |
line_space | None | When truthy, the blank spacer row below the header is suppressed |
col_span must match the number of <th> / <td> columns in your table below. If it mismatches, the header will be cut off or overflow. Count your columns and set this explicitly.
Footer (footer_content.html / bharatGen_footer_content.html)
Files: app/templates/footer_content.html, app/templates/bharatGen_footer_content.html
The footer is a <div id="footer_content"> element that is pinned to the bottom of every PDF page via the @frame footer_frame rule in head_content.html. It renders:
- Page number using
<pdf:pagenumber>and<pdf:pagecount>— xhtml2pdf-specific tags - "Report generated by AquaGen Water Monitoring Systems at
{{ data.report_generation_time }}" - AquaGen logo with a link
How to include it:
<body>
{% if data.isPdf %}
{% include 'footer_content.html' %}
{% endif %}
<table>
...
</table>
</body>
The {% if data.isPdf %} guard ensures the footer div is only emitted for PDF rendering. For HTML and XLSX output, the footer is skipped — it only makes sense as a pinned PDF element.
Use bharatGen_footer_content.html instead for industries branded as BharatGen. The only difference is the branding name and logo in the footer text.
data Context — What Every Template Receives
ReportFormatter.get_formatted_html() merges the following into the data dict before passing it to render_template(..., data=report_data):
data.* | Type | Description |
|---|---|---|
industryId | str | Industry ID |
logo | str | Industry logo — either a URL or Base64 data URI |
industryName | str | Full industry name |
date | str | start_date in DD/MM/YYYY |
isShift | bool | True if using the industry default shift; False if caller provided start_time/end_time |
formattedShiftTime | str | Industry default shift in HH:MM AM–PM to HH:MM AM/PM |
shift | str | Actual shift/time shown in the header — may be caller-provided range |
isHtml | bool | True for both HTML and PDF formats (i.e. whenever rendering HTML) |
isPdf | bool | True only for PDF format — use to gate footer include and page-break logic |
downloadImages | bool | True only for XLSX format — use to gate XLSX-specific image handling |
monthStr | str | Abbreviated month name (e.g. 'Nov') |
yearStr | str | 4-digit year string (e.g. '2024') |
report_generation_time | str | Generation time in industry local timezone (e.g. '10:30 AM') |
| (all keys from your data dict) | varies | Everything your report class and formatter returned |
Page Numbers
Page numbers use xhtml2pdf-specific HTML tags — not CSS counters. The footer template renders them as:
Page <pdf:pagenumber> of <pdf:pagecount>
<pdf:pagenumber> is replaced with the current page number; <pdf:pagecount> with the total page count. These tags are processed by xhtml2pdf during PDF conversion and have no effect in HTML or XLSX output (they appear as raw text, which is fine because the footer is guarded by {% if data.isPdf %}).
<pdf:pagenumber> and <pdf:pagecount> are xhtml2pdf's own extension tags. They are not standard HTML and will not work in browsers or in Playwright. For Playwright-rendered reports (i.e. executive), page numbers must be handled differently — either via Playwright's headerTemplate/footerTemplate in the PDF print options, or by not showing page numbers at all.
Page Splitting
Per-unit page breaks (the standard pattern)
When a report has multiple units and each unit should start on its own page, close and reopen the <table> between units:
{% for unit in data.flow %}
<!-- ... unit's rows ... -->
{% if data.isPdf and not loop.last %}
</table>
<div style="display:block; clear:both; page-break-after:always;"></div>
<table>
{% endif %}
{% endfor %}
This is the pattern used in water_consumption_hourly_report.html and water_consumption_monthly_report.html. Key points:
- Close
</table>, insert the page-break<div>, then reopen<table>— xhtml2pdf handles the break cleanly - Guard with
{% if data.isPdf %}so the break only appears in PDF output, not HTML - Use
not loop.lastto prevent a blank page after the final unit
Do not put a page-break div inside an open <table>. xhtml2pdf will silently ignore it or produce garbled output. Always close the table first, insert the break, then open a new table.
XLSX sheet-per-unit
For XLSX output, each unit's data can appear on a separate Excel sheet. Use a <div style="sheet-name:..."> element to name the sheet:
{% for unit in data.flow %}
{% set sanitised_name = unit.unitName | replace('\\', ' ') | replace('/', ' ') | replace('?', ' ') | replace('*', ' ') | replace('[', ' ') | replace(']', ' ') | truncate(30, True, '') %}
<div style="sheet-name:{{ sanitised_name }}"></div>
<!-- ... unit's rows ... -->
{% if data.downloadImages and not loop.last %}
</table>
<div style="display:none;" class="new-sheet"></div>
<table>
{% include 'common/header.html' %}
{% endif %}
{% endfor %}
sheet-nameon the<div>tells the XLSX formatter to name the current sheetclass="new-sheet"triggers a new sheet in the XLSX formatter controller- The header is re-included after the sheet break so each Excel sheet has the header rows
- Always sanitise the sheet name — Excel sheet names cannot contain
\,/,?,*,[,]and are limited to 31 characters. Use the Jinja2 filter chain shown above
The sheet name must be at most 31 characters (Excel limit). The truncate(30, True, '') call in the filter chain ensures this. If you skip sanitisation, the XLSX generator will silently produce a corrupt workbook or raise an exception.
Custom data.logo Handling — downloadImages Flag
The logo displays differently depending on output format. The header template handles this:
{% if not data.downloadImages %}
<td ...><img src="{{ data.logo }}" style="max-width:150px; max-height:60px"/></td>
{% else %}
<td style="ignore-length:true; ...">{{ data.logo }}</td>
{% endif %}
- For PDF and HTML:
data.logois a URL (fetched by the browser/xhtml2pdf) — rendered as<img src="..."> - For XLSX:
data.logois a Base64-encoded image string — the XLSX formatter reads theignore-length:truestyle hint and embeds it as an image directly into the cell
downloadImages is True only when report_format == XLSX. You do not need to handle this in your formatter — it is injected automatically by ReportFormatter.get_formatted_html().
Custom Jinja2 Template Filters
Two custom filters are registered in app/__init__.py:
decimal_format
Rounds a number to 2 decimal places and returns an integer if the value is whole:
@app.template_filter('decimal_format')
def format_time_filter(value, precision=2):
if not value:
return value
if int(trunc(value, ndigits=precision)) == trunc(value, ndigits=precision):
return int(value)
return trunc(value, ndigits=precision)
Use it in templates to format numeric values:
{{ unit.meta.ir | decimal_format }}
{{ unit.value1 if unit.value1 != None else 'NA' }}
Note: decimal_format returns value unchanged if it is falsy (zero, None, empty string). Always guard None values with an if before piping to decimal_format if you need 0 to display as 0 rather than the raw falsy value.
date_time_to_date
Converts a datetime string to a DD/MM/YYYY display string:
@app.template_filter('date_time_to_date')
def format_date_time_filter(value, output_format='%d/%m/%Y'):
if not value:
return ''
...
ignore-length Style Attribute
xhtml2pdf and the XLSX formatter both check for ignore-length:true on a cell's style attribute. For XLSX, it signals that the cell content length should not be used for column-width auto-sizing. It is also used on <td> elements where a colspan attribute value is duplicated in the style (a workaround for a known xhtml2pdf colspan parsing quirk):
<td colspan="3" style="ignore-length:true; colspan:3; text-align: left; ...">...</td>
The colspan:3 inside the style attribute is not valid CSS — it is a hint read directly by the XLSX formatter to set the merge range. Always pair it with the real colspan="3" HTML attribute.
xhtml2pdf Known Issues and Workarounds
Table column widths must be explicit
xhtml2pdf does not reliably compute auto column widths. Always set explicit widths:
<table style="width: 100%; table-layout: fixed;">
<colgroup>
<col style="width: 40%;">
<col style="width: 20%;">
<col style="width: 20%;">
<col style="width: 20%;">
</colgroup>
...
</table>
The global CSS in head_content.html already sets table-layout: fixed and width: 100% on all tables. Set width on <th> or <col> for any column that needs a specific size.
<thead> does not repeat on page 2+
xhtml2pdf does not repeat <thead> automatically when a table continues to the next page. Workaround: split the table in Python (chunk the rows) or use the close/reopen <table> pattern (see Page Splitting above) and repeat the header inside each new <table> block with {% include 'common/header.html' %}.
No CSS Grid or multi-column flexbox
xhtml2pdf does not support display: grid or multi-column display: flex. Use <table> with multiple <td> cells for any side-by-side layout.
Inline CSS only — no external stylesheets
xhtml2pdf does not fetch external resources during rendering. All CSS must be inline or in a <style> block. The head_content.html include provides the global styles. Add any per-template styles in the template's own <style> block (not a <link> tag).
border-radius is silently ignored
Use square borders. Any border-radius in your styles will be silently stripped.
Long unbroken strings overflow cells
Unit IDs or values like NORTH_BLOCK_PUMP_STATION_12 will overflow their column and collapse adjacent columns. Options:
- Add
word-break: break-alltotdin your template's<style>block - Truncate in the formatter before the value reaches the template
PDF margin and footer overlap
If table content runs to the bottom of the page, it can overlap the pinned footer frame. Increase margin_bottom (set it before including head_content.html) to 1 or 1.5 cm:
{% set margin_bottom = 1.5 %}
{% include 'common/head_content.html' %}
text-align on <th> sometimes ignored with fixed layout
Apply the alignment to both <th> and the matching <td> explicitly:
<th style="text-align: right;">Value</th>
...
<td style="text-align: right;">{{ row.value }}</td>
Complete Design Pattern Summary
HTTP Request
│
▼
ReportGetHandler (route)
├── Parses params, resolves dates, normalises unitIds
├── Builds ReportRequestData
│
▼
ReportService
├── Looks up class in SERVICE_CLASS_MAP
├── Instantiates MyServiceReport(request, current_user)
├── Calls get_report() → dispatches to get_daily_report() etc.
│ ├── Fetches raw data via DatabaseSupporter (ThreadPoolExecutor)
│ └── Returns raw data dict { templateName, fileName, ... }
│
▼
Route injects report_generation_time into data dict
│
▼
ReportFormatter.get_file()
├── get_formatted_html():
│ ├── Builds base report_data dict (logo, industryName, isPdf, downloadImages, ...)
│ ├── Merges with data dict from report class
│ └── render_template(data['templateName'], data=report_data)
│ ├── {% include 'common/head_content.html' %} → <head>, @page, CSS
│ ├── {% if data.isPdf %}{% include 'footer_content.html' %}{% endif %}
│ ├── <table>
│ │ {% include 'common/header.html' %} → logo, title, shift rows
│ │ ... service-specific rows ...
│ └── </table>
│
├── XLSX → get_xlsx_file(html, fileName) or XLSX_report_generator (format=v2)
├── CSV → get_csv_file(html, fileName)
├── PDF → get_pdf_file(html, fileName) or get_pdf_file_playwright (executive)
└── HTML → return html string directly
│
▼
HTTP Response (binary stream or HTML string, Content-Disposition: attachment)