Summary Forms¶
New in orchestrator-core 5.2.0
orchestrator.core.forms.summary_form is a new module — there is no equivalent toolkit in
earlier versions. Before 5.2.0, workflows built their own recap page by hand-writing a
FormPage with MigrationSummary fields.
Before a create or modify workflow submits its input, it is common to show the user a
read-only recap of everything that is about to change: the values just entered, and — for modify
workflows — what they looked like before. orchestrator.core.forms.summary_form provides a
small toolkit for building this recap page (a “summary form”) without having to hand-write a
FormPage for every workflow.
The recap is rendered by the frontend as one or more read-only tables, using the same
MigrationSummary field type.
Quick start¶
For the common case — a single product table, optionally compared against its previous values —
use base_summary. It is a generator, so it must be delegated to with yield from at the end of
an initial_input_form_generator:
from orchestrator.core.forms.summary_form import base_summary, extract_user_input
def initial_input_form_generator(product: UUID, product_name: str) -> FormGenerator:
class InputForm(FormPage):
model_config = ConfigDict(title=product_name)
speed: int
vlan: int
exclude_from_summary: str
user_input = yield InputForm
user_input_data = extract_user_input(user_input)
summary_data = exclude_summary_fields(user_input_data, {"exclude_from_summary"})
yield from base_summary(product_name, user_input_data)
return user_input_data
This yields a FormPage titled f"{product_name} Summary" containing one table with a row per
field of user_input.
Non-data fields in a form’s dump
A form’s dump generally includes non-data fields too, e.g. Divider and Label fields used
to visually group the form. Use extract_user_input(form) instead of form.model_dump()
everywhere a form is dumped — it drops any Divider/Label field automatically (matched by
field type, not by name, so a real data field happening to be called e.g. label_color is
never affected). Pass exclude_types for other display-only field types defined the same way.
Fields with a real value that need more context (e.g. showing customer_id as a customer name
instead of a raw UUID) should use a formatter instead, see below.
Fields that belong in state but not in the summary
Some fields must stay in the data returned from extract_user_input (the workflow still needs
them, e.g. a per-item action-choice field driving what happens next) but shouldn’t be shown to
the user in the summary table. Apply exclude_summary_fields as a second step, only on the
data passed into base_summary/generate_summary_form/TableOptions, not on the data
returned from the step:
form_data = exclude_summary_fields(user_input_data, {"peer_action_choice_1"})
yield from base_summary(product_name, form_data)
For a modify workflow, pass the subscription’s current values as old_data
to render a before / after table instead:
yield from base_summary(product_name, new_data=user_input_data, old_data=previous_values)
Multiple tables: generate_summary_form and SummaryOptions¶
base_summary is a thin wrapper around generate_summary_form, which accepts a SummaryOptions
dict describing the product table plus any number of extra tables (for example, one table per
item a workflow is creating):
from orchestrator.core.forms.summary_form import (
SummaryOptions,
TableOptions,
extract_user_input,
generate_summary_form,
make_table_data,
)
summary_options = SummaryOptions(
product_name=product_name,
product=TableOptions(
name="product_summary",
data=[(extract_user_input(user_input), None)],
),
tables=[
TableOptions(
name="item",
data=make_table_data(new_items, old_items),
empty_message="No items",
),
],
)
yield from generate_summary_form(summary_options)
make_table_data(new_data, old_data=None) zips a list of new items with the corresponding list of
old items (or None for every item, if there is nothing to compare against) into the
(new, old) tuples that TableOptions["data"] expects.
How a table is rendered¶
For each TableOptions, generate_summary_form picks the table layout based on the data it is
given:
| Situation | Layout |
|---|---|
data is empty |
A single read-only row showing empty_message (default "no data") |
No item has an old value |
One table with one column per item |
An item has an old value |
One Before / After table per item |
single_column=True |
One single-column table per item, instead of one combined table |
Use single_column=True when items don’t share a natural “before/after” or “side by side”
relationship (for example, a list of dissimilar ports).
Before/after table example¶
Passing a truthy old value alongside a new item renders a Before/After table for that item
(one table per item, not one combined table). A full modify workflow comparing a list of ports
before and after the user edits them:
from pydantic import ConfigDict
from pydantic_forms.types import FormGenerator
from orchestrator.core.forms import FormPage
from orchestrator.core.forms.summary_form import (
SummaryOptions,
TableOptions,
extract_user_input,
generate_summary_form,
make_table_data,
)
def modify_ports_generator(ports: list[Port]) -> FormGenerator:
old_ports = [{"description": port.description, "vlan": port.vlan} for port in ports]
new_ports = []
for port in ports:
class EditPortForm(FormPage):
model_config = ConfigDict(title=f"Edit port {port.vlan}")
description: str = port.description
vlan: int = port.vlan
port_input = yield EditPortForm
new_ports.append(extract_user_input(port_input))
summary_options = SummaryOptions(
product_name="Ports",
product=TableOptions(
name="ports_summary",
data=make_table_data(new_ports, old_ports),
),
)
yield from generate_summary_form(summary_options)
If the user changes port 1’s vlan from 100 to 150 and leaves port 2
untouched, the result is two separate before/after tables:
ports_summary_1 ports_summary_2
| field | before | after | | field | before | after |
|-------------|--------|----------| |-------------|---------|---------|
| description | Port 1 | Port 1 | | description | port 2 | port 2 |
| vlan | 100 | 150 | | vlan | 200 | 200 |
generate_summary_form doesn’t diff field-by-field or hide unchanged rows - it renders whatever
old/new data you give it, so an untouched port’s table still shows identical before/after
columns. If you only want to show items that actually changed, filter ports (or new_ports/
old_ports) down to the changed ones before calling make_table_data.
Per-item tables are numbered by their position in data by default (item_1, item_2, …). If
items carry their own identity that predates the summary (for example, numbered slots where one
can be removed), set TABLE_NUMBER_FIELD ("__table_number") on an item’s new dict to keep its
original number instead of renumbering by position:
tables = [
TableOptions(
name="item",
data=make_table_data(
[{**item, TABLE_NUMBER_FIELD: original_index} for original_index, item in surviving_items]
),
),
]
Custom field formatters¶
By default, a field is shown as its field name and str(value); the frontend translates the field
name the same way it translates any other form field (falling back to the raw name if no
translation exists).
For fields that need custom rendering, for example a subscription ID that
shows the linked subscription’s description or a composite value that expands into
several rows, a Formatter can be used:
from collections.abc import Generator
RowGenerator = Generator[tuple[str, str], None, None]
def notification_summary(notification: dict) -> RowGenerator:
"""Expand a single `notification` field into two summary rows."""
enabled = notification["enabled"]
yield "Notifications enabled", str(enabled)
yield "Channel", notification["channel"] if enabled else "N/A"
A Formatter is any Callable[[Any], RowGenerator] that given the field’s value yields one or more (label, value) pairs.
There are two ways to use a formatter:
- Per table, via
TableOptions(formatter={"notification": notification_summary, ...})— only applies to that table. - Globally, by adding it to
DEFAULT_FORMATTERS— applies to every summary table in your workflows, keyed by field name:
from orchestrator.core.forms.summary_form import DEFAULT_FORMATTERS
DEFAULT_FORMATTERS.update({"notification": notification_summary})
DEFAULT_FORMATTERS is a plain, shared, mutable dict, so downstream applications typically extend
it once at import time — for example in a module that every workflow package is guaranteed to
import before it builds its first summary form — rather than passing formatter= everywhere a
field shows up. A handful of general-purpose formatters ship out of the box:
customer_name_summary_field(get_customer_name_fn)— returns a formatter that resolves a customer id to its display name via the callable you provide.select_list_summary(field_name)— returns a formatter that joins a list field into a single, comma-separated row.subscription_summary_fields(subscription_id)— not aFormatteritself, but a helper that yields the standard(subscription_id, description, title)rows for a related subscription; handy to reuse from your own formatters (asnotification_summaryabove could, if its field also embedded a related subscription). For example, an L2VPN endpoint holds asapfield whoseportis a reference to the port subscription it’s attached to (seeexample-orchestratorfor more on the L2VPN product and its SAPs) — build a formatter for it that re-usessubscription_summary_fieldsfor the port’s own rows, then adds the SAP’s VLAN range:
from orchestrator.core.forms.summary_form import DEFAULT_FORMATTERS, RowGenerator, subscription_summary_fields
def sap_summary(sap: dict) -> RowGenerator:
owner_subscription_id = sap.get("port", {}).get("owner_subscription_id")
if owner_subscription_id:
yield from subscription_summary_fields(owner_subscription_id)
yield "vlan", sap["vlanrange"]
DEFAULT_FORMATTERS.update({"sap": sap_summary})
With sap_summary registered, a table of endpoints where each item has a sap field renders the
port’s subscription_id, description and title plus vlan for every endpoint, instead of a
single row with the raw sap dict:
tables = [
TableOptions(
name="endpoint",
data=make_table_data([{"sap": endpoint.sap} for endpoint in new_endpoints]),
),
]
API summary¶
All of the above is importable from orchestrator.core.forms.summary_form:
SummaryOptions
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
60 61 62 63 64 65 66 67 68 69 70 | |
after_product
instance-attribute
after_product: dict[str, tuple]
Extra fields merged in after the product table, e.g. to show a callout.
product_name
instance-attribute
product_name: typing.Required[str]
Title of the generated summary FormPage.
tables
instance-attribute
tables: collections.abc.Sequence[orchestrator.core.forms.summary_form.summary_form.TableOptions]
Any number of extra tables, e.g. one per endpoint a workflow is creating.
TableOptions
Bases: orchestrator.core.forms.summary_form.summary_form.BaseOptions
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | |
data
instance-attribute
data: typing.Required[orchestrator.core.forms.summary_form.summary_form.TableData]
Sequence[(new: dict, old: dict | None)] - the rows to render.
empty_message
instance-attribute
empty_message: str
Message shown when data is empty.
header
instance-attribute
header: collections.abc.Callable[[typing.Any], str]
Computes a column header from its 1-based index.
name
instance-attribute
name: typing.Required[str]
Table title, and field name prefix on the generated form.
single_column
instance-attribute
single_column: bool
Set True to render one table per item, instead of one combined table.
BaseOptions
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
38 39 40 | |
formatter
instance-attribute
formatter: dict[str, orchestrator.core.forms.summary_form.formatters.Formatter]
Per-field Formatter overrides of DEFAULT_FORMATTERS.
base_summary
base_summary(product_name: str, new_data: dict, old_data: dict | None = None, tables: collections.abc.Sequence[orchestrator.core.forms.summary_form.summary_form.TableOptions] = ()) -> Generator
Build a summary form for a single product table, optionally with extra tables.
This is a shortcut for generate_summary_form(): it wraps new_data/old_data into a single
“product_summary” table (before/after if old_data is given) and appends any extra tables.
Use generate_summary_form() directly when you need more control or no product summary table.
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | |
generate_summary_form
generate_summary_form(options: orchestrator.core.forms.summary_form.summary_form.SummaryOptions) -> Generator
Generate summary form for a workflow.
Expects atleast a “product” to create the product summary table. May contain extra summary tables for additional forms such as created endpoints.
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | |
create_table
create_table(options: orchestrator.core.forms.summary_form.summary_form.TableOptions, show_headers: bool = True) -> type[MigrationSummary]
Creates a summary table that can be added as a field to the summary form.
The table has columns with items of the same type.
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | |
make_table_data
make_table_data(new_data: collections.abc.Sequence[dict], old_data: collections.abc.Iterable[dict | None] | None = None) -> Sequence[tuple[dict, dict | None]]
Pair up new_data items with the corresponding old_data items for use as TableOptions["data"].
Use this to build “data” for a before/after table, e.g. when modifying a list of endpoints. If
old_data is omitted or empty, every item is paired with None (a plain, non-before/after table).
Note: if old_data is shorter than new_data, zip truncates and the extra new_data
items are silently dropped — make sure both sequences have matching lengths when both are given.
make_table_data([{“a”: 1}, {“a”: 2}]) [({‘a’: 1}, None), ({‘a’: 2}, None)] make_table_data([{“a”: 1}], [{“a”: 0}]) [({‘a’: 1}, {‘a’: 0})] make_table_data([{“a”: 1}, {“a”: 2}], [{“a”: 0}]) [({‘a’: 1}, {‘a’: 0})] make_table_data([{“a”: 1}, {“a”: 2}], []) [({‘a’: 1}, None), ({‘a’: 2}, None)]
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 | |
extract_user_input
extract_user_input(form: pydantic.BaseModel, *, exclude_types: collections.abc.Iterable[typing.Any] = ()) -> dict
model_dump() a form, dropping its Label/Divider/summary-table/callout fields.
Use this instead of form.model_dump() wherever a form is dumped: these field types are
UI-only display elements - their model value is always None (the displayed content lives in
the type’s schema, not the field’s value) - so they never carry real data, in a summary table
or anywhere else. Pass exclude_types for other display-only field types defined the same way
(an Annotated[..., Field(json_schema_extra=...)] alias).
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | |
exclude_summary_fields
exclude_summary_fields(data: dict, fields: collections.abc.Iterable[str]) -> dict
Drop fields from data before it’s used to build a summary table.
Use this for fields that must stay in the workflow’s persisted state (so keep them out of
extract_user_input) but shouldn’t be shown to the user in a summary table - e.g. a workflow’s
own action-choice fields.
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
127 128 129 130 131 132 133 134 135 | |
customer_name_summary_field
customer_name_summary_field(get_customer_name_fn: collections.abc.Callable[[uuid.UUID | pydantic_forms.types.UUIDstr], str]) -> Callable[[UUIDstr], RowGenerator]
Build a Formatter for a customer id field that shows the resolved customer name instead of the raw id.
Pass a lookup function that resolves a customer id to a name; register the returned formatter under
the relevant field key, e.g. DEFAULT_FORMATTERS["customer_id"] = customer_name_summary_field(...).
list(customer_name_summary_field(lambda customer_id: “ACME”)(“cust-1”)) [(‘customer_id’, ‘ACME’)] list(customer_name_summary_field(lambda customer_id: None)(“cust-2”)) [(‘customer_id’, ‘Customer name not found for cust-2’)]
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/formatters.py
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | |
select_list_summary
select_list_summary(field_name: str) -> Callable[[list], RowGenerator]
Build a Formatter for a multi-select list field that joins the selected values into one row.
Register the returned formatter under the relevant field key, e.g.
DEFAULT_FORMATTERS["tags"] = select_list_summary("tags").
list(select_list_summary(“prefixes”)([“1.1.1.1/32”, “2.2.2.2/32”])) [(‘prefixes’, ‘1.1.1.1/32, 2.2.2.2/32’)] list(select_list_summary(“prefixes”)([])) [(‘prefixes’, ‘’)]
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/formatters.py
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | |
subscription_summary_fields
subscription_summary_fields(subscription_id: uuid.UUID) -> RowGenerator
Formatter that yields subscription id, description, and block title rows for a linked subscription.
Use as a Formatter (in DEFAULT_FORMATTERS or a table’s options["formatter"]) for a field that
holds another subscription’s id, e.g. one subscription referencing another it depends on. Falls
back to “-” for the title when the subscription’s first product block has no title attribute.
Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/formatters.py
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | |