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):
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):
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):
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):
# 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):
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):
// 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_<slug> in the resource’s action catalog (actions); row
visibility on definitions/runs is the OPA view filter (permissions).
Wire the catalog into the grid’s ⋯ menu with
assetReportCatalog.props("all", { resolvePayload }) on <FSHTableView reports={…}> —
see table configuration.