# Reports *Layer: Backend + render sidecar · the reports platform* The reports platform turns a resource's list query into downloadable exports (CSV / JSON / PDF) and lets users save their own report definitions — reach for it for a "download this table" button, code-defined canned exports, or async generation of files too large for a request. ## How it works Opting a project in scaffolds `_generated/reports.py`: a system-report registry, one in-process handler per opted-in list op, the built-in CSV/JSON renderers, and pre-bound `generate()` (sync) / `enqueue_run()` + `dispatch` (async pgqueuer worker). A list-op source reuses the resource's own list-query builder, so a report inherits its filters, sort, search, and OPA row-level visibility for free. You supply the storage writer/reader, any custom sources or `post_process` transforms, and non-default renderers. ## The config you write **You write** — `be/config/project.jsonnet` (the `reports.platform` block): ```jsonnet local reports = import "be/reports/reports.libsonnet"; // ... reports: reports.platform({ definition_model: "fsh.models.ReportDefinition", // ReportDefinitionMixin subclass (saved defs) run_model: "fsh.models.ReportRun", // ReportRunMixin subclass (run rows) storage_writer: "fsh.reports_storage.write_blob", // persists rendered bytes storage_reader: "fsh.reports_storage.presigned_download_url", // (key) -> download URL // The async worker runs in the pgqueuer process, not the web one; it rebuilds the // requester's session from the run's owner_id so async reports enforce the SAME OPA // row-level visibility as the sync path. Omit it and async runs lose row filtering. session_loader: "auth.load_session", // CSV / JSON are built in; register a renderer only for extra formats. PDF here // delegates to the codegen-render sidecar (the service comms renders through). renderers: { pdf: "inventory.reports.render_pdf" }, }), ``` **You write** — `be/config/project.jsonnet` (saved-definition CRUD + run polling, inside `fsh.app(...)`, via `be/resources/presets.libsonnet`): ```jsonnet fsh.app({ // ... // Owner-guarded get/create/update/delete + a list filterable by resource_type. report_definitions: { owner_guard: "fsh.guards.is_report_owner", owner_field: "owner_id" }, // get + list only — runs come from the platform; users only poll them. Row read // visibility is the OPA `view` filter keyed off owner_field, not the object guard. report_runs: { owner_guard: "fsh.guards.is_run_owner", owner_field: "owner_id" }, }), ``` **You write** — `be/config/inventory_resources/asset.jsonnet` (`reports.opt_in` with `system_reports` — the whole range in one resource): ```jsonnet local reports = import "be/reports/reports.libsonnet"; // ... reports: reports.opt_in({ types: ["csv", "json", "pdf"], // formats a *user* definition may pick (system reports aren't gated) system_reports: [ // Plain list-op export: reuses asset.list, so it inherits its filters + OPA visibility. reports.system({ slug: "export", name: "Export asset", report_type: "csv", source: reports.list_op_source({ base_fn: "asset.list" }), // "{resource}.{op}" }), // Transform: post_process gets the already-visibility-scoped query and narrows it. reports.system({ slug: "monitors", name: "Assets in Monitors", report_type: "csv", source: reports.list_op_source({ base_fn: "asset.list", post_process: "inventory.reports.assets_in_monitors_category", }), }), // Parameterized transform: the fn's 2nd-arg annotation IS the payload_schema; // the FE renders a form from it before generating. reports.system({ slug: "by_category", name: "Assets by category", report_type: "csv", source: reports.list_op_source({ base_fn: "asset.list", post_process: "inventory.reports.assets_in_category", }), }), // PDF via the render sidecar. object_action synthesizes a per-object `download_asset_pdf` // action in the resource's catalog (no new route — reuses generate scoped by an id filter). reports.system({ slug: "asset_pdf", name: "Asset summary (PDF)", report_type: "pdf", object_action: true, source: reports.list_op_source({ base_fn: "asset.list" }), }), // Custom source: builds its own aggregate query — does NOT inherit filters/visibility. reports.system({ slug: "status_summary", name: "Asset status summary", report_type: "csv", source: reports.custom_source({ fn: "inventory.reports.asset_status_counts" }), }), ], }), ``` **You write** — `be/inventory/reports.py` (the source callables + PDF renderer): ```python # A post_process composes onto the resource's already-projected, already- # visibility-scoped list query: (stmt) -> Select. def assets_in_monitors_category(stmt: Select) -> Select: return stmt.where( Asset.category_id.in_(select(Category.id).where(Category.name == "Monitors")), ) # Parameterized variant: the second arg's Pydantic annotation becomes payload_schema. def assets_in_category(stmt: Select, payload: CategoryFilterPayload) -> Select: return stmt.where( Asset.category_id.in_( select(Category.id).where(Category.name == payload.category_name), ), ) # A custom source ignores the list query and builds its own; called (db, session, payload), # so it takes typed run-time input too but owns any row scoping itself. def asset_status_counts(_db: AsyncSession, _session: object, payload: StatusSummaryParams) -> Select: stmt = select(Asset.status.label("status"), func.count().label("count")).group_by(Asset.status) if payload.category_name is not None: stmt = stmt.where( Asset.category_id.in_( select(Category.id).where(Category.name == payload.category_name), ), ) return stmt # A renderer is (rows, ctx) -> bytes, called SYNCHRONOUSLY inside the pipeline — so use a # blocking client. This one POSTs columns + rows to the render sidecar's report_pdf template # (declared as a comm type with output: "pdf" — see emails-and-templates.md); a raise here # surfaces as the run's error. def render_pdf(rows: Iterable[Mapping[str, Any]], ctx: ReportContext) -> bytes: columns = list(ctx.columns) document = { "template": "report_pdf", "context": { "name": ctx.name, "generated_at": ctx.generated_at.isoformat(), "columns": columns, "rows": [[_pdf_cell(row.get(n)) for n in columns] for row in rows], }, } base_url = os.environ.get("RENDER_URL", "http://localhost:8200") with httpx.Client(base_url=base_url, timeout=30.0) as client: response = client.post("/v1/render", json=document) response.raise_for_status() return base64.b64decode(response.json()["pdf_base64"]) ``` ## The frontend codegen emits Codegen emits one report catalog per opted-in resource, bound to three SDK calls: **Generated** — `fe/src/_generated/reports/inventory/asset.gen.ts` (do not edit): ```typescript import { defineReportCatalog } from "@fsh/components-library"; import { generateReportAsset, listReportsAsset, pollReportRunAsset, } from "../../sdk/inventory/inventory.gen"; export const assetReportCatalog = defineReportCatalog({ scopeKey: "asset", slugs: ["export", "monitors", "by_category", "asset_pdf", "status_summary"] as const, sdk: { list: listReportsAsset, generate: generateReportAsset, poll: pollReportRunAsset }, }); ``` **Generated** — `fe/src/_generated/types/common.gen.ts` (do not edit): ```typescript // listReportsAsset() → ReportListing: system + definitions split so system reports // render without edit/delete. type ReportListing = { system: SystemReportDescriptor[]; definitions: ReportDefinitionDescriptor[] }; // generateReportAsset({ path: { report_id }, body }). Pass the grid's LIVE filter/sort/q // so the export matches the current view; report_payload feeds a parameterized report. type GenerateRequest = { definition_id?: string | null; system_slug?: string | null; filter?: {...} | null; sort?: {...}[] | null; q?: string | null; report_payload?: {...} | null; execution?: "sync" | "async"; // sync vs async is chosen PER REQUEST, not in config }; // Returns LiveResponse ({ columns, rows } inline, for `live` reports) OR a RunDescriptor. type RunDescriptor = { id: string; status: "pending" | "running" | "success" | "failed"; download_url?: string | null; // once success, from storage_reader /* ...definition_id, system_slug, owner_id, report_type, error... */ }; ``` Run flow: call `generate` → on a `LiveResponse` render inline; otherwise poll `pollReportRunAsset({ path: { run_id } })` until `status === "success"`, then follow `download_url`. A `object_action: true` report also appears as `download_` in the resource's action catalog ([actions](actions.md)); row visibility on definitions/runs is the OPA `view` filter ([permissions](permissions.md)). Wire the catalog into the grid's `⋯` menu with `assetReportCatalog.props("all", { resolvePayload })` on `` — see [table configuration](table-configuration.md).