Skip to content

Wizards and Transient Models

Wizards are popup dialogs that collect user input before performing actions. They use transient models - models that exist only in memory without database storage.

An action that just needs “Are you sure?” doesn’t need a wizard at all — put confirm on the button that calls it:

- type: actionButton
properties:
label: Archive
method: action_archive_leads
confirm:
message: Archive {count} lead(s)?
label: Archive
variant: warning

{count} is replaced with the number of selected records. The method itself stays plain Python — it knows nothing about the prompt. See Confirmation prompts for the full grammar.

Reach for a wizard (below) only when the action needs input, not just assent.

For actions that need user input before executing:

class MarkLostWizard(Model):
_transient = True # No database table
reason = ManyToOne('LostReason', required=True)
notes = Text()
async def action_confirm(self):
"""Execute the wizard action."""
active_ids = self._ctx.get('active_ids', [])
for lead_id in active_ids:
lead = await CrmLead.get(lead_id)
await lead.mark_lost(self.reason, self.notes)
return {'type': 'close', 'reload': True}

Models with _transient = True are:

  • Excluded from database migrations - No table created
  • Registered in the model registry - Full ORM features available
  • In-memory only - State managed by frontend

Because state lives in the browser, pressing a footer button posts the whole form back and rebuilds the record from that payload. Field values still arrive as the Python types their fields declare, JSON’s smaller type set notwithstanding: a Date/Datetime posts as an ISO string and is parsed back on the way in, so self.expires_at.strftime(...), comparisons against date.today() and timedelta arithmetic all work in an action method exactly as they would on a persisted record.

class CreateInvoiceWizard(Model):
_transient = True
contact = ManyToOne('Contact', required=True)
currency = ManyToOne('Currency', required=True)
lines = OneToMany('CreateInvoiceWizardLine', related_name='wizard')
notes = Text()
async def _default_get(cls, context):
"""Compute defaults from context."""
defaults = await super()._default_get(context)
active_ids = context.get('active_ids', [])
if context.get('active_model') == 'SaleOrder' and active_ids:
order = await SaleOrder.get(active_ids[0])
defaults['contact'] = order.contact.id
defaults['currency'] = order.currency.id
return defaults
async def action_confirm(self):
invoice = await Invoice.create(
contact=self.contact,
currency=self.currency,
)
return {
'type': 'close',
'reload': True,
'notify': f'Invoice {invoice.name} created'
}

A wizard can present an editable grid of line rows — not just scalar fields and ManyToMany selections — by declaring a OneToMany to another transient model. Use this when each row carries its own values (a quantity, a price, a link back to a source line) that a ManyToMany (just a set of related records) can’t hold.

class SaleReturnWizard(Model):
_transient = True
order = ManyToOne('SaleOrder', related_name='return_wizards', on_delete='CASCADE')
reason = Text()
lines = OneToMany('SaleReturnWizardLine', related_name='wizard')
async def _default_get(cls, context):
defaults = await super()._default_get(context)
order = await get_model('SaleOrder').filter(id=context.get('active_id')).first()
WizardLine = get_model('SaleReturnWizardLine')
rows = []
await order.fetch_related('lines', 'lines__product_variant')
for line in order.lines or []:
rows.append(await WizardLine.new(
{'product_variant': line.product_variant.id, 'quantity': 0.0},
init_mode=False, _skip_defaults=True))
defaults['lines'] = rows # list of transient line instances
return defaults
async def action_confirm(self):
# self.lines is the in-memory grid the user edited — materialize real records
for row in self.lines or []:
if (row.quantity or 0) > 0:
... # create the persistent record from row
return {'type': 'close', 'reload': True}
class SaleReturnWizardLine(Model):
_transient = True # REQUIRED — see the rule below
wizard = ManyToOne('SaleReturnWizard', related_name='lines', on_delete='CASCADE')
product_variant = ManyToOne('ProductVariant', related_name='return_wizard_lines', on_delete='CASCADE')
quantity = Float(default=0.0)

Embed the grid in the wizard view with a List widget bound to a list view for the child model — the same way you embed child lines on a normal form:

- type: field
name: lines
properties:
widget: List
editable: true
view: sale_return_wizard_line_list_view

How it works — and the one rule: the wizard is transient, so there is no parent row and the lines are never written to the database. They live only in memory for the request: Model.new() hydrates the submitted rows into self.lines, your action method reads them and creates the real records itself. Because of that, the child line model MUST also be _transient = True — a persistent child would need a real foreign-key column back to a parent table that doesn’t exist. Pointing a transient parent’s OneToMany at a persistent model is rejected at startup with a clear error.

A row’s own relations (sale_order_line, lot, currency, …) travel with it: they are sent to the client and posted back, so your action reads row.lot exactly as _default_get set it. That is not true of a persisted record’s child rows — those carry only the fields the client asked for, because it can always fetch a relation by id. A transient row has no row to fetch, so its relations are part of the payload.

Two consequences worth knowing when you build a grid:

  • Hand the row the record when the cell must show a label. A row given {'lot': lot.id} carries the id and nothing else, so a relation cell has no name to render. Give it the record — {'lot': lot} — and the row serializes with the target’s values (including its display name) at no extra query cost, since you already had the record in hand.
  • Or denormalise the text you want to display — a Char on the row (lot_name, document_number) filled in _default_get, shown with a Text widget, and read server-side from the relation. Useful when the label is all the grid needs.

Use _default_get to compute defaults from context:

async def _default_get(cls, context):
"""Compute defaults from context."""
defaults = await super()._default_get(context)
# Context contains: active_model, active_ids
active_ids = context.get('active_ids', [])
if context.get('active_model') == 'SaleOrder' and active_ids:
order = await SaleOrder.get(active_ids[0])
await order.fetch_related('contact', 'currency')
defaults['contact'] = order.contact.id
defaults['currency'] = order.currency.id
# default_* values from context are auto-applied
# e.g., context['default_contact'] → defaults['contact']
return defaults

Return a wizard response from an action:

class SaleOrder(Model):
async def action_create_invoice(self):
return {
'type': 'wizard',
'model': 'CreateInvoiceWizard',
'title': 'Create Invoice',
'ctx': {
'default_contact': self.contact.id,
'active_model': 'SaleOrder',
'active_ids': [self.id],
}
}

Wizard views use the same arch format as Form views - an array of elements. The wizard footer buttons are defined using a footer element:

- data_type: UiView
identifier: create_invoice_wizard_view
type: Wizard
model: CreateInvoiceWizard
arch:
- type: row
content:
- type: column
span: 6
content:
- type: field
name: contact
properties:
widget: DataCombo
- type: column
span: 6
content:
- type: field
name: currency
properties:
widget: DataCombo
- type: field
name: notes
properties:
widget: TextArea
- type: footer
buttons:
- label: Create Invoice
method: action_confirm
variant: primary

Note: The arch array uses the same structure as Form views (rows, columns, fields, etc.). Action buttons (action_buttons type) are ignored in wizard modals - use footer buttons instead. The Cancel button is auto-generated - you only need to define your action buttons.

Wizard methods can return different result types.

A result that opens another overlay replaces the wizard. Chain to a second wizard, hand off to the Send Message composer, navigate to a window action — the wizard closes and the thing it opened takes its place. You never return a close alongside it, and the wizard never lingers behind what it opened. Results that produce something without opening an overlay (notify, report, the file/blob downloads) keep it open on purpose, so a wizard can generate several documents in a row.

return {'type': 'close', 'reload': True}
return {
'type': 'close',
'reload': True,
'notify': 'Operation completed successfully'
}
return {
'type': 'wizard',
'model': 'NextStepWizard',
'ctx': {**self._ctx, 'step1_data': data}
}
return {
'type': 'report',
'report_id': report_action.id,
'model': 'Lot',
'instance_ids': [lot.id],
'reload': True,
}

A report result — like notify, file_download, pdf_blob and file_blobleaves the wizard open once the document is generated, so one wizard can produce several documents in a row without being reopened each time.

Add 'close': True when that is not what you want — when the document is a by-product of work the wizard already finished rather than its whole purpose:

async def action_mark_all_done(self):
result = await self.transfer.action_validate() # may auto-print labels
if isinstance(result, dict):
return {**result, 'close': True}
return {'type': 'close', 'reload': True}

The flag applies to any result type and always wins: the wizard closes, the document is still generated, and the view behind it refetches when the result also carries reload. It is what lets a wizard forward an action produced by the method it called instead of replacing it — returning a plain close here would discard the print directive the operation handed back, and nothing would be produced.

A report result is rendered inline by the server, so the wizard receives the document rather than the dict it returned; close and reload survive that as response headers (see API Overview). Nothing to do for it — return the flags and the behavior follows.

return {
'type': 'notify',
'message': 'This is an informational message'
}
return {
'type': 'window_action',
'identifier': '<some_window_action_identifier>',
'record_id': created_invoice.id
}

Opens the built-in send message modal with optional pre-selected template:

return {
'type': 'send_message',
'model': 'Invoice', # Optional if called from form context
'record_id': self.id, # Optional if called from form context
'template_id': 5, # Optional - pre-select this template
'modal_type': 'message' # Optional - 'message' (default), 'note', or 'followers'
}
PropertyTypeRequiredDescription
typestringYesMust be "send_message"
modelstringNoModel name (auto-detected from context if omitted)
record_idintNoRecord ID (auto-detected from context if omitted)
template_idintNoEmailTemplate ID to pre-select and auto-render
modal_typestringNo"message" (send email), "note" (log note), or "followers" (invite)

Example: Button to send invoice with template

class Invoice(Model):
async def action_send_invoice(self):
"""Open send message modal with invoice template pre-selected."""
template = await get_model("EmailTemplate").filter(
identifier="invoice_send_template"
).first()
return {
"type": "send_message",
"template_id": template.id if template else None,
}

The modal will:

  • Fetch the record data automatically
  • Load and render the template if template_id is provided
  • Filter available templates to show only those for the current model (or generic templates)
  • Auto-populate recipients from the record’s followers (the customer is a follower)

For multi-step wizards, use a stepper element. Cancel, Back, and Next buttons are auto-generated - you only need to define the final action button:

- data_type: UiView
identifier: import_wizard_view
type: Wizard
model: ImportWizard
arch:
- type: stepper
steps:
- label: Upload File
content:
- type: field
name: file
properties:
widget: FileInput
- label: Map Columns
visible: Q(needs_mapping__eq=True)
content:
- type: field
name: mapping
properties:
widget: List
- label: Confirm
content:
- type: field
name: preview
properties:
widget: List
readonly: true
- type: footer
buttons:
- label: Import
method: action_import
variant: primary

The wizard automatically renders:

  • Cancel button (always visible)
  • Back button (visible from step 2 onwards)
  • Next button (visible until the last step)
  • Your action button(s) (visible only on the last step)

To customize navigation buttons, define them explicitly with action: "back" or action: "next". Cancel is always auto-generated.

Use Q expressions for button visibility and disabled states:

{
"buttons": [
{
"label": "Delete",
"method": "action_delete",
"variant": "danger",
"visible": "Q(can_delete__eq=True)"
},
{
"label": "Confirm",
"method": "action_confirm",
"variant": "primary",
"disabled": "Q(lines__isnull=True)"
}
]
}

An editable relation must declare its view:

Section titled “An editable relation must declare its view:”

A field holding a OneToMany/ManyToMany the user edits has to name the view its rows are rendered with — even when a widget draws them some other way:

- type: field
name: config_lines
properties:
widget: OptionGroups # draws the rows however it likes …
view: my_line_list_view # … but this is what declares the fields to fetch

The view: is not only about rendering. View composition derives the relation’s nested fetch paths (config_lines__value, config_lines__attribute_line, …) from that view’s columns, and serialization is path-aware: a relation comes back as {id, display_name} unless a path beneath it was requested.

Drop the view: and the wizard still renders, but every recomputation returns the child rows stripped of their own fields, and the client replaces its rows with the stripped ones. Anything the user had entered into those rows is discarded — silently, with no error, and only when something else on the wizard recalculates. If a wizard forgets an answer the user just gave, this is the first thing to check.

The columns a widget needs must therefore appear in the referenced view even if that widget never draws them as columns.

Wizards support UI effects just like regular models:

class CreateInvoiceWizard(Model):
_transient = True
contact = ManyToOne('Contact', required=True)
currency = ManyToOne('Currency')
@Model.ui_effect("contact")
async def on_contact_change(self):
if self.contact:
await self.fetch_related("contact")
self.currency = self.contact.default_currency
models/wizards.py
from fullfinity.engine.base import *
class SendEmailWizard(Model):
_transient = True
# Recipients
recipient = ManyToOne('Contact', required=True)
cc = ManyToMany('Contact', related_name='email_cc', through='fkemailcc')
# Content
subject = Char(max_length=255, required=True)
body = Text(required=True)
template = ManyToOne('EmailTemplate', related_name='wizard_uses')
# Attachments
attachments = ManyToMany('Attachment', related_name='wizard_uses', through='fkemailattach')
async def _default_get(cls, context):
defaults = await super()._default_get(context)
# Set recipient from active record
active_ids = context.get('active_ids', [])
if context.get('active_model') == 'Contact' and active_ids:
defaults['recipient'] = active_ids[0]
return defaults
@Model.ui_effect("template")
async def on_template_change(self):
"""Apply template content."""
if self.template:
await self.fetch_related("template")
self.subject = self.template.subject
self.body = self.template.body
async def action_send(self):
"""Send the email."""
# Email sending logic...
await send_email(
to=self.recipient.email,
subject=self.subject,
body=self.body,
)
return {
'type': 'close',
'notify': f'Email sent to {self.recipient.email}'
}
async def action_preview(self):
"""Preview email without sending."""
return {
'type': 'notify',
'message': f'Preview: {self.subject}'
}
views/send_email_wizard.yaml
- data_type: UiView
identifier: send_email_wizard_view
type: Wizard
model: SendEmailWizard
arch:
- type: field
name: template
properties:
widget: DataCombo
- type: field
name: recipient
properties:
widget: DataCombo
- type: field
name: cc
properties:
widget: MultiCombo
- type: field
name: subject
properties:
widget: TextInput
- type: field
name: body
properties:
widget: RichTextEditor
- type: field
name: attachments
properties:
widget: MultiCombo
- type: footer
buttons:
- label: Preview
method: action_preview
variant: outline
- label: Send
method: action_send
variant: primary
POST /api/action-meta/{model}/{method}
{
"context": {"active_ids": [1]}
}

Returns:

{
"defaults": {"contact": 1},
"view": {...},
"fields": {...}
}
POST /api/execute/{model}/{method}
{
"inputs": {"contact": 1, "subject": "Hello"},
"context": {"active_ids": [1], "active_model": "Contact"}
}

The Configuration model is a special transient model for managing module settings. It uses company_scoped=True fields that store values in CompanyConfig per company.

Extend the base Configuration model to add your module’s settings:

from fullfinity.engine.base import *
class ConfigurationCRM(Model):
__inherit__ = "Configuration"
# Company-scoped settings - use company_scoped=True
crm_auto_assign = Boolean(
default=False,
description="Auto-assign Leads",
hint="Automatically assign new leads to sales team members",
company_scoped=True
)
crm_default_stage = ManyToOne(
"CrmStage",
description="Default Lead Stage",
company_scoped=True
)
crm_default_tags = ManyToMany(
"CrmTag",
through="FkConfigCrmTag",
description="Default Tags",
company_scoped=True
)
Field AttributeStorageScope
company_scoped=TrueCompanyConfigPer-company
(no attribute)ParamsSystem-wide (global)

Create a view that inherits from the base configuration view:

- data_type: UiView
identifier: configuration_form_view_crm
inherited_view: configuration_form_view
type: Form
model: Configuration
arch:
operations:
- action: add
target:
type: pageTabs
position: inside
value:
type: tab
name: crm
title: CRM
anchor: crm
properties:
icon: Users
content:
- type: field
name: crm_auto_assign
properties:
widget: Switch
- type: field
name: crm_default_stage
properties:
widget: DataCombo

See Field Types - Company-Scoped Fields for more details.

Report wizards collect user input and compute aggregated data for PDF reports. Unlike record-based reports that render existing database records, report wizards compute data on-the-fly.

class AgedReceivablesWizard(Model):
_transient = True
_verbose_name = "Aged Receivables Report"
as_of_date = Date(required=True, description="As of Date")
aging_intervals = Char(default="30,60,90,120", description="Aging Intervals")
async def action_generate_report(self):
"""Generate the report PDF with computed data."""
# Compute report data
data = await self._get_report_data()
# Get the report action
report_action = await get_model("ReportAction").filter(
identifier="aged_receivables_report"
).first()
if not report_action:
raise UserError("Report template not found")
# Return with computed data (not instance_ids)
return {
"type": "report",
"report_id": report_action.id,
"model": "AgedReceivablesWizard",
"report_data": data, # Pass computed data directly
}
async def _get_report_data(self):
"""Compute the report data."""
intervals = [int(x) for x in self.aging_intervals.split(",")]
# Query and compute aging data
receivables = []
Invoice = get_model("Invoice")
invoices = await Invoice.filter(
type="out_invoice",
state="posted",
payment_state__in=["not_paid", "partial"]
).all()
for invoice in invoices:
days_overdue = (self.as_of_date - invoice.invoice_date).days
bucket = self._get_aging_bucket(days_overdue, intervals)
receivables.append({
"contact": invoice.contact.name,
"invoice": invoice.name,
"amount": invoice.amount_residual,
"bucket": bucket,
})
return {
"as_of_date": self.as_of_date.isoformat(),
"aging_intervals": intervals,
"receivables": receivables,
"totals": self._compute_totals(receivables, intervals),
}
AspectRegular WizardReport Wizard
Return type{'type': 'close', ...}{'type': 'report', ...}
Data sourceactive_ids from contextComputed from wizard fields
ResultAction on recordsPDF download
report_dataNot usedContains computed data
return {
"type": "report",
"report_id": report_action.id, # ReportAction record ID
"model": "WizardModelName", # Wizard model name
"report_data": { # Computed data for template
"field1": value1,
"field2": value2,
# ... any data structure your template needs
},
}

The report_data dictionary is passed directly to the Jinja2 template context. Company information is automatically added:

<h1>Aged Receivables as of {{ as_of_date }}</h1>
<p>Company: {{ company.name }}</p>
{% for item in receivables %}
<tr>
<td>{{ item.contact }}</td>
<td>{{ item.invoice }}</td>
<td>{{ item.amount | currency }}</td>
</tr>
{% endfor %}
  1. Use transient models for all wizards - Consistent behavior
  2. Keep wizards focused - One task per wizard
  3. Validate in action methods - Clear error messages
  4. Return appropriate result types - Reload when needed
  5. Use _default_get for smart defaults - Better UX
  6. Use company_scoped=True for configuration fields - Per-company settings
  7. For report wizards, compute all data in the wizard - Don’t rely on saving to database