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”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.
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
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' }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": "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.
Action Results
Section titled “Action Results”Wizard methods can return different result types:
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}}Update Parent Form
Section titled “Update Parent Form”return { 'type': 'update_parent', 'values': { 'lines': [('C', {'product': self.product.id, 'qty': self.qty})], }}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', 'action': created_invoice.id, 'model': 'Invoice'}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
collaborate_contactfields if configured
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": "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.
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__len__eq=0)" } ]}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": "primary"} ] } ]}API Reference
Section titled “API Reference”Get Wizard Metadata
Section titled “Get Wizard Metadata”GET /api/action-meta/{model}/{method}?context={"active_ids": [1]}Returns:
{ "confirm": null, "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": "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
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
- Computed Fields - Automatic calculations
- Field Types - All available field types