Skip to content

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 a Formatter itself, but a helper that yields the standard (subscription_id, description, title) rows for a related subscription; handy to reuse from your own formatters (as notification_summary above could, if its field also embedded a related subscription). For example, an L2VPN endpoint holds a sap field whose port is a reference to the port subscription it’s attached to (see example-orchestrator for more on the L2VPN product and its SAPs) — build a formatter for it that re-uses subscription_summary_fields for 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

Bases: typing.TypedDict

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
class SummaryOptions(TypedDict, total=False):
    product_name: Required[str]
    """Title of the generated summary `FormPage`."""

    product: Required[TableOptions]

    after_product: dict[str, tuple]
    """Extra fields merged in after the product table, e.g. to show a callout."""

    tables: Sequence[TableOptions]
    """Any number of extra tables, e.g. one per endpoint a workflow is creating."""

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
class TableOptions(BaseOptions, total=False):
    name: Required[str]
    """Table title, and field name prefix on the generated form."""

    header: Callable[[Any], str]
    """Computes a column header from its 1-based index."""

    data: Required[TableData]
    """Sequence[(new: dict, old: dict | None)] - the rows to render."""

    empty_message: str
    """Message shown when `data` is empty."""

    single_column: bool
    """Set True to render one table per item, instead of one combined table."""

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

Bases: typing.TypedDict

Source code in .venv/lib/python3.14/site-packages/orchestrator/core/forms/summary_form/summary_form.py
38
39
40
class BaseOptions(TypedDict, total=False):
    formatter: dict[str, Formatter]
    """Per-field `Formatter` overrides of `DEFAULT_FORMATTERS`."""

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
def base_summary(
    product_name: str,
    new_data: dict,
    old_data: dict | None = None,
    tables: Sequence[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.
    """
    summary_options = SummaryOptions(
        product_name=product_name,
        product=TableOptions(
            name=PRODUCT_SUMMARY_TABLE_NAME,
            data=[(new_data, old_data)],
        ),
        tables=tables,
    )

    return generate_summary_form(summary_options)

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
def generate_summary_form(options: 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.
    """

    class SummaryFormPage(SubmitFormPage):
        model_config = ConfigDict(title=f"{options['product_name']} Summary")

    product_fields = dict(_table_fields(options["product"], 0))
    after_product = options.get("after_product", {})  # e.g. show callout
    remaining_tables = options.get("tables", [])
    remaining_fields = dict(
        chain.from_iterable(_table_fields(table, index) for index, table in enumerate(remaining_tables, start=1))
    )
    all_fields = product_fields | after_product | remaining_fields
    yield create_model("SummaryFormPage", __base__=SummaryFormPage, **all_fields)  # type: ignore

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
def create_table(options: 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.
    """

    def header(index: int) -> str:
        return options.get("header", str)(index)

    items = options["data"]
    first_item_data, _ = first(items)

    labels = _get_summary_labels(first_item_data, options)
    columns = [_get_column_values(item, options) for item, _ in items]
    headers = [header(index) for index in range(1, len(items) + 1)] if show_headers else []
    summary_data = SummaryData(labels=labels, columns=columns, headers=headers)  # type: ignore

    return migration_summary(data=summary_data)

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
def make_table_data(
    new_data: Sequence[dict], old_data: 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)]
    """
    return list(zip(new_data, old_data if old_data else repeat(None)))

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
def extract_user_input(form: BaseModel, *, exclude_types: Iterable[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).
    """
    excluded_formats = {fmt for t in (*DEFAULT_EXCLUDED_TYPES, *exclude_types) if (fmt := _format_marker(t))}

    def is_excluded(field: FieldInfo) -> bool:
        return _field_format(field.json_schema_extra) in excluded_formats

    exclude = {name for name, field in type(form).model_fields.items() if is_excluded(field)}
    return form.model_dump(exclude=exclude)

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
def exclude_summary_fields(data: dict, fields: 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.
    """
    fields = set(fields)
    return {key: value for key, value in data.items() if key not in fields}

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
def customer_name_summary_field(
    get_customer_name_fn: Callable[[UUID | 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')]
    """

    def _customer_name_summary_field(customer_id: UUIDstr) -> RowGenerator:
        """Formatter for showing customer name with the customer_id."""
        customer_name = get_customer_name_fn(customer_id)
        yield "customer_id", customer_name or f"Customer name not found for {customer_id}"

    return _customer_name_summary_field

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
def 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', '')]
    """

    def _select_list_summary(_list: list[str]) -> RowGenerator:
        """Formatter for IPV X prefix list."""
        yield field_name, ", ".join(_list)

    return _select_list_summary

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
def subscription_summary_fields(subscription_id: 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.
    """
    subscription = SubscriptionModel.from_subscription(subscription_id)
    block_name = first(subscription._product_block_fields_.keys())
    block = getattr(subscription, block_name, None)
    block_title = getattr(block, "title", "-") if block else "-"

    yield "subscription_id", str(subscription.subscription_id)
    yield "description", subscription.description
    yield "title", block_title