Report Generation
Reports are generated on-demand via GET /api/user/report. All generation is entirely in-memory — no files are written to disk or stored in blob storage at any point. The response is a binary file stream (PDF, XLSX) or an HTML string returned directly with the appropriate Content-Type header.
The pipeline has three stages: request parsing → data retrieval (via ReportService) → formatting and rendering (via ReportFormatter). Every report type and format passes through this same pipeline; only the report class, the Jinja2 template, and the final converter differ.
GET /api/user/report
GET /api/user/report?reportType=daily&reportFormat=pdf&service=water&startDate=09/11/2024
Authorization: Bearer <access_token>
Browser-direct download — JWT may be passed as a query parameter instead of a header for direct browser download links:
GET https://prod-aquagen.azurewebsites.net/api/user/report?jwt=<access_token>&reportType=granular&reportFormat=xlsx&service=water&startDate=09/11/2024
Request Parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
startDate | No | current_user['nowUTCWithShiftDateTime'] formatted as DD/MM/YYYY | Start of the report window in DD/MM/YYYY format. Defaults to the industry's current date (shift-adjusted) if not provided. |
endDate | No (required for some types) | Auto-calculated per reportType — see table below | End of the report window in DD/MM/YYYY format. The exact behaviour depends on reportType. |
unitId | No | — | Filter the report to a single unit. Typically used for hourly and granular reports where per-unit breakdown is the primary view. |
unitIds | No | — | Comma-separated list of unit IDs to include. Split into a list on the server. If the list resolves to ['null'], it is converted to None (all units). |
useShift | No | true | When true, aligns the date range to the industry's configured shift boundaries rather than calendar midnight. |
reportType | No | daily | The period/aggregation type for the report. Accepts values from the ReportType enum. |
reportFormat | No | html | The output format. Accepts values from the ReportFormat enum. |
service | No | water | Selects which report class runs. Accepts values from the ServiceType enum. |
subCategories | No | '' (empty, split into list) | Comma-separated subcategory IDs. Only used by smart_city — the server filters current_user['categories'][sourceCategory]['subCategories'] down to only those present in this list before any processing begins. |
divisionFactor | No | 1000 | Integer divisor applied to raw device readings before display. Default of 1000 converts litre readings to kilolitres. |
format | No | v1 | Internal version flag for the report template. Passed through to ReportRequestData and used by some report classes to select between legacy and updated template layouts. |
POST /api/user/report
Accepts the same parameters as GET but additionally takes a JSON request body. The body is passed as additional_data to the report class — used primarily for custom report configurations that cannot be expressed as simple query parameters.
POST https://prod-aquagen.azurewebsites.net/api/user/report
Authorization: Bearer <access_token>
Content-Type: application/json
{
"reportType": "custom",
"reportFormat": "xlsx",
"service": "custom_water",
"startDate": "01/11/2024",
"endDate": "30/11/2024"
}
A schema validation failure on the request body returns error 1430.
startDate and endDate Behaviour
startDate defaults to the industry's current shift-adjusted datetime (current_user['nowUTCWithShiftDateTime'].strftime('%d/%m/%Y')) if not provided by the caller.
endDate is not simply passed through — the route recalculates it based on reportType before building ReportRequestData. Two internal date values are computed:
end_date— the exclusive upper bound used in database queries (e.g. the day after the last data day)end_date_actual— the display end date shown in the report header (the last data day itself)
reportType | endDate required | end_date (query bound) | end_date_actual (display) |
|---|---|---|---|
daily | No | startDate + 1 day | startDate + 1 day |
monthly | No (auto-calculated); yes if service is smart_city or consolidated and caller provides it | Auto: startDate + monthrange(year, month)[1] days (last day of month). Caller-provided: taken as-is. | Auto: last day of the start month. Caller-provided: same as end_date. |
hourly | Yes — not auto-calculated | endDate param | endDate param |
granular | Yes | endDate + timedelta(days=1) | endDate param |
weekly | No | startDate + 1 day | startDate + 1 day |
daily_range | Yes | endDate param | endDate param |
monthly_range | Yes | endDate param | endDate param |
custom | No — optional | If provided: endDate param. If not: startDate + 1 day. | Same as end_date. |
summary | No | startDate + 1 day | startDate + 1 day |
aqua_gpt | No | startDate + 1 day | startDate + 1 day |
Note on granular: The caller always passes the last data day as endDate. The server internally adds one day so that the database query captures the full final day of data. The displayed end date in the report remains the caller-provided value.
Note on monthly for smart_city/consolidated: When the caller explicitly provides endDate for these services, the server uses it verbatim. Otherwise, the server computes the number of days in the start month via calendar.monthrange(year, month)[1] and sets end_date to startDate + that many days.
unitIds Handling
unitIds is accepted as a comma-separated string and split into a list on the server. If the resolved list equals ['null'] (i.e. the caller passed unitIds=null), it is normalised to None, which causes all units to be included in the report. This is the standard way to pass "no filter" explicitly.
Passing unitIds=null explicitly is the correct way to signal "include all units" when you need to override a previously scoped request.
Service Types
Each service value maps to a report class that extends GenericReport. The class is responsible for all data retrieval and business logic for that domain.
service | Report class | Description |
|---|---|---|
water | FlowReport | Water flow and consumption reporting |
energy | EnergyReport | Energy consumption reporting |
level | LevelReport | Water level and tank fill readings |
quality | QualityReport | Water quality parameters (pH, TDS, turbidity, etc.) |
borewell | BorewellReport | Groundwater borewell extraction data |
rain_water | RainWaterReport | Rainfall collection and volumes |
summary | SummaryReport | Cross-category aggregated summary |
smart_city | SmartCityReport | Smart city multi-subcategory monitoring. Overrides get_report() directly rather than dispatching through individual type methods. subCategories parameter filters active subcategories before any processing. |
water_balance | WaterBalanceReport | Inflow vs outflow analysis with loss calculation |
alert | AlertReport | Alert and breach event history |
consolidated | ConsolidatedReport | Monthly consolidated report across multiple categories |
custom_water | CustomFlowReport | Custom water flow configuration. XLSX uses HTML-to-XLSX pipeline. |
custom_energy | CustomEnergyReport | Custom energy configuration |
custom_level | CustomLevelReport | Custom level configuration. XLSX uses HTML-to-XLSX pipeline. |
custom_quality | CustomQualityReport | Custom quality configuration |
custom_withdrawal | CustomWithdrawalReport | Custom withdrawal report. XLSX uses HTML-to-XLSX pipeline. |
ground_water_withdrawal | GroundWaterWithdrawalReport | Groundwater extraction and withdrawal data |
executive | ExecutiveReport | Executive summary with complex chart layouts. PDF uses Playwright instead of xhtml2pdf. |
aqua_gpt | AquaGPTReport | AI-generated natural-language report summary |
Report Types
reportType | Description | Supported service values |
|---|---|---|
daily | Single-day aggregated totals for all units | water, energy, level, quality, borewell, rain_water, summary, custom_water, custom_energy, custom_level, custom_quality, custom_withdrawal, ground_water_withdrawal, aqua_gpt |
monthly | Month-level aggregation. Covers one calendar month from startDate. | water, energy, level, quality, borewell, rain_water, water_balance, consolidated, custom_water, custom_withdrawal, ground_water_withdrawal, executive |
hourly | Breakdown by hour within the day. | water, energy, level, quality, borewell, custom_water, custom_energy, ground_water_withdrawal |
granular | Raw or fine-grained readings within the specified date range | water, energy, level, quality, borewell, rain_water, custom_water, ground_water_withdrawal, alert |
weekly | Aggregated weekly view | rain_water |
daily_range | Daily totals across a multi-day range (startDate to endDate) | water, energy, level, quality, borewell |
monthly_range | Monthly totals across a multi-month range | water, energy |
custom | Flexible range — respects caller-provided endDate or defaults to startDate + 1 day | water_balance, executive |
summary | Aggregated summary view | rain_water |
aqua_gpt | Produces an AI-generated narrative summary of the data window | aqua_gpt |
Report Formats
reportFormat | Backend | Output Content-Type |
|---|---|---|
pdf | xhtml2pdf for all services except executive. Playwright headless Chromium for executive. | application/pdf |
xlsx | xlsxwriter standard pipeline. Exception: custom_water, custom_level, custom_withdrawal use the HTML-to-XLSX pipeline (see below). | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
html | Jinja2 template rendered and returned as a string. No conversion step. | text/html |
All formats are generated entirely in memory (BytesIO objects) — nothing is written to disk at any point. The binary stream is returned directly as the HTTP response body.
XLSX Pipeline — Custom Services
When reportFormat=xlsx and service is one of custom_water, custom_level, or custom_withdrawal, the standard xlsxwriter path is bypassed. Instead:
ReportFormatter(report_request, report_data).get_formatted_html()renders the Jinja2 template to an HTML stringget_xlsx_file_for_custom_monthly(html_data, report_data['fileName'])converts that HTML to an XLSX workbook
This HTML-to-XLSX path exists because these custom report templates have complex cell-spanning layouts that are defined in HTML and cannot be reproduced easily with the xlsxwriter programmatic API.
PDF Backends
xhtml2pdf — used for all services except executive. Converts inline-styled HTML+CSS to PDF. External stylesheets and complex layout models (multi-column flexbox, CSS Grid) are not supported. All templates targeting xhtml2pdf must have fully inline styles.
Playwright — used exclusively for the executive service. Launches a headless Chromium browser instance, loads the rendered HTML, and exports to PDF. This path supports the full modern CSS and JavaScript feature set, which the executive report requires for its embedded chart components.
Execution Flow
1. Request parsing — ReportGetHandler.get()
The route handler uses requestParser to extract and type-coerce all parameters:
startDate,endDate— strings inDD/MM/YYYYunitId— single stringunitIds— string, split on comma into listuseShift— boolean, defaultTruereportType— validated againstReportTypeenum, defaultdailyreportFormat— validated againstReportFormatenum, defaulthtmlservice— validated againstServiceTypeenum, defaultwatersubCategories— string, split into list, default''divisionFactor— integer, default1000format— string, defaultv1
2. smart_city subcategory pre-filter
If service == SMART_CITY, the handler immediately narrows the user context before any further processing:
current_user['categories'][sourceCategory]['subCategories'] = [
sc for sc in current_user['categories'][sourceCategory]['subCategories']
if sc in args['subCategories']
]
This filter happens at the route level, before ReportRequestData is constructed, so the report class and all downstream logic only ever see the filtered subcategory list.
3. startDate defaulting
If startDate was not provided by the caller:
start_date = current_user['nowUTCWithShiftDateTime'].strftime('%d/%m/%Y')
This uses the industry's shift-adjusted current datetime, not plain UTC, ensuring the default date matches the operator's logical "today."
4. endDate computation
end_date and end_date_actual are computed per the table in the startDate and endDate Behaviour section above.
5. unitIds normalisation
if unit_ids == ['null']:
unit_ids = None
6. ReportRequestData construction
All resolved parameters are assembled into a ReportRequestData dataclass:
ReportRequestData(
report_type=report_type,
report_format=report_format,
industry_id=industry_id,
use_shift=use_shift,
timezone=industry_timezone,
start_date=start_date,
unit_id=unit_id,
unit_ids=unit_ids,
end_date=end_date,
end_date_actual=end_date_actual,
service_type=service_type,
start_time=start_time,
end_time=end_time,
division_factor=division_factor,
format=format,
additional_data=additional_data, # POST body, if any
)
7. ReportService(request).get_report()
ReportService uses the service_type field of ReportRequestData to look up the correct report class in its dispatcher dict and instantiate it. It then calls get_report() on the instance, which dispatches to the method matching report_type:
report_type | Method called |
|---|---|
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() |
Exception: SmartCityReport overrides get_report() directly and does not use the per-type method dispatch.
Inside each report class method:
- Raw device data is fetched via
DatabaseSupporter - For multi-unit or multi-date queries, fetches are parallelised with
ThreadPoolExecutor - Business logic runs: aggregations, hourly breakdowns, threshold comparisons, timezone conversions
- A structured
datadict is returned with all keys the Jinja2 template expects
8. report_generation_time injection
After get_report() returns, the route adds the current local time to the data dict:
report_data['report_generation_time'] = DateTimeUtil().convert_date_from_utc_to_date(
utc_now, ..., t_zone=industry_timezone
).strftime("%I:%M %p")
This is the timestamp displayed in report footers as the generation time in the industry's local timezone.
9. Format routing
XLSX + custom service path:
if report_format == XLSX and service in (CUSTOM_WATER, CUSTOM_LEVEL, CUSTOM_WITHDRAWAL):
html_data = ReportFormatter(report_request, report_data).get_formatted_html()
return get_xlsx_file_for_custom_monthly(html_data, report_data['fileName'])
Standard path:
return ReportFormatter(report_request, report_data).get_file()
ReportFormatter.get_file():
- Selects the Jinja2 template path based on
service_typeandreport_type - Instantiates the formatter class for the service (e.g.
FlowReportFormatter) to transform the rawdatadict into a template-ready structure (number formatting, colour thresholds, date formatting, unit labels) - Renders the Jinja2 template with the formatted context
- Passes the rendered HTML to the appropriate converter:
html: returns the string directlypdf: passes toxhtml2pdf(or Playwright forexecutive)xlsx: passes toxlsxwriter
- Returns the binary stream
10. HTTP response
The file stream is returned with the appropriate Content-Type header and Content-Disposition: attachment; filename="{report_data['fileName']}".
Example Requests
Daily water consumption report (PDF)
GET https://prod-aquagen.azurewebsites.net/api/user/report?reportType=daily&reportFormat=pdf&service=water&startDate=09/11/2024
Authorization: Bearer <access_token>
Granular water report for a single unit (XLSX)
GET https://prod-aquagen.azurewebsites.net/api/user/report?reportType=granular&reportFormat=xlsx&service=water&startDate=09/11/2024&endDate=09/11/2024&unitId=U001
Authorization: Bearer <access_token>
Monthly water report (auto end date)
GET https://prod-aquagen.azurewebsites.net/api/user/report?reportType=monthly&reportFormat=pdf&service=water&startDate=01/11/2024
Authorization: Bearer <access_token>
endDate is not required — the server calculates it as the last day of November 2024.
Daily range report across multiple days
GET https://prod-aquagen.azurewebsites.net/api/user/report?reportType=daily_range&reportFormat=xlsx&service=water&startDate=01/11/2024&endDate=30/11/2024
Authorization: Bearer <access_token>
Smart city report with subcategory filter
GET https://prod-aquagen.azurewebsites.net/api/user/report?reportType=daily&reportFormat=pdf&service=smart_city&startDate=09/11/2024&subCategories=SC001,SC002
Authorization: Bearer <access_token>
Executive report (Playwright PDF)
GET https://prod-aquagen.azurewebsites.net/api/user/report?reportType=custom&reportFormat=pdf&service=executive&startDate=01/11/2024&endDate=30/11/2024
Authorization: Bearer <access_token>
Browser-direct download (JWT in query param)
GET https://prod-aquagen.azurewebsites.net/api/user/report?jwt=<access_token>&reportType=granular&reportFormat=xlsx&service=water&startDate=09/11/2024&endDate=09/11/2024
No Authorization header needed — used when constructing download URLs for the browser to navigate to directly.
Custom report via POST with additional config
POST https://prod-aquagen.azurewebsites.net/api/user/report
Authorization: Bearer <access_token>
Content-Type: application/json
{
"reportType": "custom",
"reportFormat": "xlsx",
"service": "custom_water",
"startDate": "01/11/2024",
"endDate": "30/11/2024"
}
The full JSON body is passed as additional_data to the report class.
Edge Cases
| Scenario | Behaviour |
|---|---|
startDate not provided | Defaults to current_user['nowUTCWithShiftDateTime'].strftime('%d/%m/%Y') — the industry's shift-adjusted current date |
unitIds=null passed | Server converts ['null'] list to None, treating it as "all units" |
reportType=granular with endDate | Server internally adds +1 day to endDate for the DB query; end_date_actual remains the original caller value |
reportType=monthly, auto end date | calendar.monthrange(year, month)[1] determines the number of days; end_date = startDate + that count; end_date_actual = last day of month |
reportType=monthly with service=smart_city or consolidated and endDate provided | Server uses the caller's endDate verbatim instead of auto-calculating |
reportFormat=xlsx with service=custom_water/custom_level/custom_withdrawal | HTML-to-XLSX pipeline via get_formatted_html() + get_xlsx_file_for_custom_monthly(). Standard xlsxwriter is not used. |
service=executive with reportFormat=pdf | Playwright headless Chromium renders the HTML; xhtml2pdf is not used |
service=smart_city | get_report() is overridden directly; per-type method dispatch does not apply |
| No data found for the given parameters | Returns error 1401 |
endDate required but not provided (e.g. reportType=hourly) | The query runs with end_date=None. Callers must provide endDate for hourly, granular, daily_range, and monthly_range. |
| Schema validation failure on POST body | Returns error 1430 |
| JWT missing or expired | Returns error 401 |
subCategories filter provided with non-smart_city service | Parameter is parsed and stored in ReportRequestData but the pre-filter logic only runs for SMART_CITY — other services ignore it |
Error Reference
| Code | Meaning |
|---|---|
401 | Missing or expired JWT |
1401 | No data found for the given parameters |
1403 | Missing required field (service, reportType, reportFormat, or startDate) |
1430 | Schema validation failure on POST request body |
For adding a new report service to the codebase, see Adding a Report Service.