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.

For actions that just need “Are you sure?” before executing:

class CrmLead(Model):
async def action_archive_leads(self):
"""Archive selected leads."""
for lead in self:
lead.active = False
await lead.save()
return {'type': 'close', 'reload': True}
# Attach confirmation metadata
action_archive_leads._confirm = {
'title': 'Archive Leads',
'message': 'Are you sure you want to archive {count} lead(s)?',
'confirm_label': 'Archive',
'confirm_variant': 'warning', # primary, danger, warning
}

The {count} placeholder is replaced with the number of selected records.

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
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'
}

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:

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': 'update_parent',
'values': {
'lines': [('C', {'product': self.product.id, 'qty': self.qty})],
}
}
return {
'type': 'notify',
'message': 'This is an informational message'
}
return {
'type': 'window_action',
'action': created_invoice.id,
'model': 'Invoice'
}

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 collaborate_contact fields if configured

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__len__eq=0)"
}
]
}

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.json
{
"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"}
]
}
]
}
GET /api/action-meta/{model}/{method}?context={"active_ids": [1]}

Returns:

{
"confirm": null,
"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": "tabs"},
"position": "inside",
"value": {
"type": "tab",
"name": "crm",
"title": "CRM",
"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