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 writebe/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 writebe/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 writebe/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 writebe/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:

Generatedfe/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 },
});

Generatedfe/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.