Skip to main content

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 / MethodWhat it gives you
self.requestReportRequestData dataclass — all resolved request parameters
self.industry_dataFull industry document from Cosmos DB
self.unit_idsResolved list of unit IDs to include (request.unit_ids, or all units if None)
self.units_mappingdictunitId → unit document for fast lookups
self.timezoneIndustry timezone string (e.g. "Asia/Kolkata")
get_report()Dispatcher — reads self.request.report_type and calls the matching method

get_report() dispatch table

report_typeMethod called on your class
dailyget_daily_report()
monthlyget_monthly_report()
hourlyget_hourly_report()
granularget_granular_report()
weeklyget_weekly_report()
daily_rangeget_daily_range_report()
monthly_rangeget_monthly_range_report()
customget_custom_report()
summaryget_summary_report()
aqua_gptget_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)

FieldTypeDescription
industry_idstrIndustry being reported on
start_datestrDD/MM/YYYY — start date
end_datestrExclusive upper DB query bound
end_date_actualstrDisplay end date — use this in "endDate" key returned to formatter
unit_idstr | NoneSingle unit filter
unit_idslist | NoneMulti-unit filter; None = all units
use_shiftboolWhether to align dates to industry shift
timezonestrIndustry timezone
division_factorintDivisor applied to raw readings before display
start_timestr | NoneCustom shift start override
end_timestr | NoneCustom shift end override
formatstrTemplate version flag ("v1", "v2")
additional_datadict | NonePOST body, if request was a POST
report_typeReportTypeThe requested period type
report_formatReportFormatThe requested output format
service_typeServiceTypeThe 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:

KeyRequiredDescription
fileNameYesUsed as Content-Disposition filename
templateNameYesPath relative to app/templates/ — looked up from Templates constants
startDateRecommendedDisplay start date
endDateRecommendedUse self.request.end_date_actual here, not end_date
(service-specific keys)As neededAny 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_flowflow/water_consumption_daily_report.html
daily_energyenergy/energy_daily_report.html
daily_levellevel/level_daily_report.html
daily_qualityquality/quality_daily_report.html
daily_borewellborewell/water_borewell_daily_report.html
daily_summarysummaryv2/daily_summary.html
daily_flow_rangeflow/water_consumption_daily_custom_range_report.html
daily_energy_rangeenergy/energy_daily_custom_range_report.html
daily_level_rangelevel/level_daily_custom_range_report.html
daily_borewell_rangeborewell/borewell_daily_custom_range_report.html
daily_quality_rangequality/quality_daily_custom_range_report.html
daily_water_balancewater_balance/water_balance_daily_report.html
daily_gwgw/gw_daily_report.html
daily_rain_waterrain_water/rain_water_daily_report.html
monthly_flowflow/water_consumption_monthly_report.html
monthly_energyenergy/energy_monthly_report.html
monthly_levellevel/level_monthly_report.html
monthly_qualityquality/quality_monthly_report.html
monthly_borewellborewell/water_borewell_monthly_report.html
monthly_consolidatedconsolidated/monthly_consolidated.html
monthly_water_balancewater_balance/water_balance_monthly_report.html
monthly_flow_rangeflow/water_consumption_monthly_custom_range_report.html
monthly_energy_rangeenergy/energy_monthly_custom_range_report.html
custom_water_balancewater_balance/water_balance_custom_report.html
monthly_gwgw/gw_monthly_report.html
monthly_rain_waterrain_water/rain_water_monthly_report.html
monthly_smart_city_reportsmart_city/smart_city_monthly_report.html
hourly_flowflow/water_consumption_hourly_report.html
hourly_energyenergy/energy_hourly_report.html
hourly_qualityquality/quality_hourly_report.html
hourly_borewellborewell/water_borewell_hourly_report.html
hourly_levellevel/level_hourly_report.html
hourly_rain_waterrain_water/rain_water_hourly_report.html
granular_flowflow/water_consumption_fivem_report.html
granular_qualityquality/quality_fivem_report.html
granular_borewellborewell/water_borewell_fivem_report.html
granular_energyenergy/energy_fivem_report.html
granular_alertalert/alert_fivem_report.html
granular_levellevel/level_fivem_report.html
granular_rain_waterrain_water/rain_water_fivem_report.html
summary_rain_waterrain_water/rain_water_summary_report.html
weakly_rain_waterrain_water/rain_water_weakly_report.html
aqua_gptaqua_gpt/aqua_gpt_report.html
executiveexecutive/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

dailyget_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),
}

monthlyget_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.


hourlyget_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}.


granularget_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_rangeget_daily_range_report()

Per-day totals across a multi-day range. Both startDate and endDate required.


monthly_rangeget_monthly_range_report()

Per-month totals across a multi-month range. Both dates required.


customget_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

serviceClassSupported types
waterFlowReportdaily, monthly, hourly, granular, daily_range, monthly_range
energyEnergyReportdaily, monthly, hourly, granular, daily_range, monthly_range
levelLevelReportdaily, monthly, hourly, granular, daily_range
qualityQualityReportdaily, monthly, hourly, granular, daily_range
borewellBorewellReportdaily, monthly, hourly, granular, daily_range
rain_waterRainWaterReportdaily, monthly, granular, weekly, summary
summarySummaryReportdaily
smart_citySmartCityReportmonthly — overrides get_report() directly
water_balanceWaterBalanceReportmonthly, custom
alertAlertReportgranular
consolidatedConsolidatedReportmonthly
custom_waterCustomFlowReportdaily, monthly, hourly, granular — XLSX via HTML-to-XLSX
custom_energyCustomEnergyReportdaily, monthly, hourly
custom_levelCustomLevelReportdaily, monthly — XLSX via HTML-to-XLSX
custom_qualityCustomQualityReportdaily, monthly
custom_withdrawalCustomWithdrawalReportdaily, monthly — XLSX via HTML-to-XLSX
ground_water_withdrawalGroundWaterWithdrawalReportdaily, monthly, hourly, granular
executiveExecutiveReportmonthly, custom — PDF via Playwright
aqua_gptAquaGPTReportdaily, 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/th padding and borders)
  • @page CSS rule for PDF page size and margins
  • Optional @page second_template for templates that mix two page orientations
  • .heading_td and .red-text utility classes
  • A @frame footer_frame that 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:

VariableDefaultEffect
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_bottom0.5Bottom margin in cm — increase when footer overlaps content
second_templateNoneWhen truthy, emits the @page second_template rule
tip

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:

VariableRequiredDescription
header_range_textYesThe date or range string shown in the header (e.g. data.startDate, or data.startDate ~ ' to ' ~ data.endDate)
col_spanYesHow many columns the header spans. Must match your table's actual column count

Variables you can set to customise:

VariableDefaultDescription
text_align'center'Alignment of all header rows
line_spaceNoneWhen truthy, the blank spacer row below the header is suppressed
warning

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.


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.

note

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.*TypeDescription
industryIdstrIndustry ID
logostrIndustry logo — either a URL or Base64 data URI
industryNamestrFull industry name
datestrstart_date in DD/MM/YYYY
isShiftboolTrue if using the industry default shift; False if caller provided start_time/end_time
formattedShiftTimestrIndustry default shift in HH:MM AM–PM to HH:MM AM/PM
shiftstrActual shift/time shown in the header — may be caller-provided range
isHtmlboolTrue for both HTML and PDF formats (i.e. whenever rendering HTML)
isPdfboolTrue only for PDF format — use to gate footer include and page-break logic
downloadImagesboolTrue only for XLSX format — use to gate XLSX-specific image handling
monthStrstrAbbreviated month name (e.g. 'Nov')
yearStrstr4-digit year string (e.g. '2024')
report_generation_timestrGeneration time in industry local timezone (e.g. '10:30 AM')
(all keys from your data dict)variesEverything 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 %}).

note

<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.last to prevent a blank page after the final unit
warning

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-name on the <div> tells the XLSX formatter to name the current sheet
  • class="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
warning

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.logo is a URL (fetched by the browser/xhtml2pdf) — rendered as <img src="...">
  • For XLSX: data.logo is a Base64-encoded image string — the XLSX formatter reads the ignore-length:true style 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-all to td in your template's <style> block
  • Truncate in the formatter before the value reaches the template

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)