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.
Types of Confirmations
Section titled “Types of Confirmations”1. Simple Confirmation
Section titled “1. Simple Confirmation”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.
2. Input Wizard
Section titled “2. Input Wizard”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}Transient Models
Section titled “Transient Models”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' }Editable line grids (OneToMany)
Section titled “Editable line grids (OneToMany)”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_viewHow 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.
Relations on a grid row
Section titled “Relations on a grid row”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
Charon the row (lot_name,document_number) filled in_default_get, shown with aTextwidget, and read server-side from the relation. Useful when the label is all the grid needs.
Default Values
Section titled “Default Values”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 defaultsOpening Wizards
Section titled “Opening Wizards”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
Section titled “Wizard Views”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: primaryNote: 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.
Action Results
Section titled “Action Results”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.
Close and Reload
Section titled “Close and Reload”return {'type': 'close', 'reload': True}Close with Notification
Section titled “Close with Notification”return { 'type': 'close', 'reload': True, 'notify': 'Operation completed successfully'}Open Another Wizard (Chaining)
Section titled “Open Another Wizard (Chaining)”return { 'type': 'wizard', 'model': 'NextStepWizard', 'ctx': {**self._ctx, 'step1_data': data}}Generate a Report or File
Section titled “Generate a Report or File”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_blob — leaves 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.
Show Notification Only
Section titled “Show Notification Only”return { 'type': 'notify', 'message': 'This is an informational message'}Navigate to Window Action
Section titled “Navigate to Window Action”return { 'type': 'window_action', 'identifier': '<some_window_action_identifier>', 'record_id': created_invoice.id}Open Send Message Modal
Section titled “Open Send Message Modal”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'}| Property | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Must be "send_message" |
model | string | No | Model name (auto-detected from context if omitted) |
record_id | int | No | Record ID (auto-detected from context if omitted) |
template_id | int | No | EmailTemplate ID to pre-select and auto-render |
modal_type | string | No | "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_idis 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)
Multi-Step Wizards
Section titled “Multi-Step Wizards”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: primaryThe 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.
Conditional Buttons
Section titled “Conditional Buttons”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 fetchThe 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.
UI Effects in Wizards
Section titled “UI Effects in Wizards”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_currencyComplete Example
Section titled “Complete Example”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}' }- 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: primaryAPI Reference
Section titled “API Reference”Get Wizard Metadata
Section titled “Get Wizard Metadata”POST /api/action-meta/{model}/{method}{ "context": {"active_ids": [1]}}Returns:
{ "defaults": {"contact": 1}, "view": {...}, "fields": {...}}Execute Wizard
Section titled “Execute Wizard”POST /api/execute/{model}/{method}{ "inputs": {"contact": 1, "subject": "Hello"}, "context": {"active_ids": [1], "active_model": "Contact"}}Configuration Model
Section titled “Configuration Model”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.
Adding Module Settings
Section titled “Adding Module Settings”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 )Storage Types
Section titled “Storage Types”| Field Attribute | Storage | Scope |
|---|---|---|
company_scoped=True | CompanyConfig | Per-company |
| (no attribute) | Params | System-wide (global) |
Adding a Configuration Tab
Section titled “Adding a Configuration Tab”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: DataComboSee Field Types - Company-Scoped Fields for more details.
Report Wizards
Section titled “Report Wizards”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.
Pattern
Section titled “Pattern”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), }Key Differences from Regular Wizards
Section titled “Key Differences from Regular Wizards”| Aspect | Regular Wizard | Report Wizard |
|---|---|---|
| Return type | {'type': 'close', ...} | {'type': 'report', ...} |
| Data source | active_ids from context | Computed from wizard fields |
| Result | Action on records | PDF download |
report_data | Not used | Contains computed data |
Report Response Format
Section titled “Report Response Format”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 },}Template Access
Section titled “Template Access”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 %}Best Practices
Section titled “Best Practices”- Use transient models for all wizards - Consistent behavior
- Keep wizards focused - One task per wizard
- Validate in action methods - Clear error messages
- Return appropriate result types - Reload when needed
- Use _default_get for smart defaults - Better UX
- Use company_scoped=True for configuration fields - Per-company settings
- For report wizards, compute all data in the wizard - Don’t rely on saving to database
Next Steps
Section titled “Next Steps”- UI Effects - Dynamic field updates
- Calculated Fields - Automatic calculations
- Field Types - All available field types