Skip to content

Action Results

Model methods (button actions, wizard confirmations, etc.) can return action result dictionaries that instruct the frontend how to respond.

One record or the whole selection — the signature decides

Section titled “One record or the whole selection — the signature decides”

A button on a list can be clicked with thirty rows ticked. How your method is called then follows from how you declared it, and there is no flag or property to set: you already said which one you meant when you wrote the first parameters.

class Coupon(Model):
async def action_cancel(self):
"""ONE record. Called once for each row the user selected."""
self.state = "Cancelled"
await self.save()
async def action_activate(cls, records):
"""THE SELECTION. Called ONCE, with every selected record."""
for record in records:
if record.state != "Draft":
raise UserError("Only draft coupons can be activated.")
await cls.update(records, state="Active") # one write, not one per row
DeclarationCalledUse it for
async def action_x(self)once per selected recordper-record state changes; the common case
async def action_x(cls, records)once, with all of themanything that must see the set: one bulk write, a merge, a total, a wizard/report over the selection
async def action_x(cls) / async def action_x(cls, other_arg)once, with no recordsa class-level action that needs no selection

Two consequences worth knowing:

  • A per-record action returns per record, and only the last one is kept. If your method returns a result — a notify with counts, a wizard, a report — and it can be clicked on a multi-row selection, declare it (cls, records). Otherwise it reports on one record out of thirty and the rest is silently dropped.
  • records arrives in the order the user selected, and every record is loaded in one query. An id that no longer exists is a 404 and nothing runs — a set action never acts on part of what was selected.

A list button whose method needs a selection is withheld by the toolbar until rows are ticked; that too is read from the signature, so both self-first and (cls, records) buttons behave correctly with nothing selected.

Most actions change the records the user is looking at, so the view refreshes by default — that is what an action returning nothing does, and what notify does. Reporting an outcome must never cost you that refresh, or the toast announces a change the screen is still not showing and the user has to reload the page to see it.

Two result types are the exception, because producing a document changes nothing on its own: they refresh only when you ask.

Result typeRefreshesHow to change it
(returns None)Yes
notifyYesreload: False when the method wrote nothing
reloadYes— (that is its whole job)
closeNoreload: True
file_downloadNoreload: True if the method also changed the record
reportNoreload: True (survives the PDF replacing the response body)
app_reloadReboots the client

The test for a notify is did this method write anything — not whether the message sounds eventful. A “nothing to do” branch that still stamps a state has written, and must refresh; see notify.

Whatever the action returns, the button keeps the final say: auto_refetch: false (refetch: false on a form button) suppresses the refresh.

Closes the current modal/wizard and optionally reloads the parent view.

return {'type': 'close', 'reload': True}
# With notification
return {
'type': 'close',
'reload': True,
'notify': 'Operation completed successfully'
}
PropertyTypeDescription
reloadboolWhether to reload the parent view
notifystringOptional success message to display

Shows a notification without closing the modal.

color is the only thing you choose about the toast. It names an intent, and the intent decides the whole appearance — the accent colour, the icon, and how long the toast stays up. There is no way to pass an icon, a border or a duration, and that is deliberate: it is what keeps a “saved” toast in one app identical to a “saved” toast in another.

colorIntentUse it for
greenSuccessThe thing the user asked for happened
redErrorIt failed and they need to know why
orangeWarningIt happened, but not cleanly — or it needs care
blue (default)InfoSomething worth knowing, no action implied
grayNeutralAn acknowledgement with nothing to report

teal reads as success and yellow as warning, so older code keeps working — but write one of the five above.

An action’s outcome should be reportable, not just success-or-raise: a button whose only outcomes are silence and an exception cannot express partial, so return a notify carrying the counts rather than completing quietly. Keep exceptions for genuine misuse.

return {
'type': 'notify',
'message': 'This is an informational message',
'color': 'green',
}

The view refreshes unless you say otherwise

Section titled “The view refreshes unless you say otherwise”

Set reload: False when the method genuinely changed nothing:

async def action_test_connection(self):
ok = await self._provider().ping() # asks a remote service; stores nothing
return {
'type': 'notify',
'color': 'green' if ok else 'red',
'message': 'Connection works.' if ok else 'Could not reach the service.',
'reload': False,
}

This is not just a saved request. A refresh re-reads the record from the database and drops unsaved edits — so a connectivity test that refreshes wipes the API key the admin had just typed in and had not saved yet. The rule is simply: did this method write anything? If yes, let it refresh. If it only inspected, previewed, validated, or bailed out early with “nothing to do”, declare reload: False.

Watch the early-return branches of a method that does write elsewhere — a “nothing matched” branch that returns before any write wants reload: False even though its siblings do not. Check that the branch really is inert first: a method that stamps a state before reporting “nothing to do” has still written, and must refresh.

A notification may carry an action — any action dict (e.g. a window_action or url) — which is rendered as a button on the toast that navigates there. Use it to turn a notification into a one-click next step (“Invoice posted → View Invoice”). This is the same mechanism that powers actionable errors (see Exceptions), so notifications of any colour can deep-link, not just errors.

return {
'type': 'notify',
'message': f'Invoice {invoice.number} posted.',
'color': 'green',
'action': {
'type': 'window_action',
'identifier': 'financial_document_customer_invoice_action',
'record_id': invoice.id,
},
'action_label': 'View Invoice', # optional; defaults to the action's label or "Open"
}

Downloads a file to the user’s device. The file content is base64-encoded.

import base64
async def action_export_csv(self):
csv_content = "Name,Email\nJohn,john@example.com"
csv_bytes = csv_content.encode('utf-8')
csv_base64 = base64.b64encode(csv_bytes).decode('utf-8')
return {
'type': 'file_download',
'filename': 'export.csv',
'content': csv_base64,
'mimetype': 'text/csv',
}
PropertyTypeRequiredDescription
filenamestringYesThe download filename
contentstringYesBase64-encoded file content
mimetypestringNoMIME type (defaults to application/octet-stream)
messagestringNoToast text (defaults to “File downloaded successfully”)
titlestringNoToast title
reloadboolNoRefresh the view — set it if the method also changed the record

The content is base64-encoded into the JSON result, which inflates it by about a third and holds it all in memory — right for a CSV, an export, a bank file or a label, wrong for a large archive. For something already stored on disk, or a file that needs its own URL, serve it from a route instead: see Returning a file.

A download on its own changes nothing on screen, so this type does not refresh by default. An action that both produces a file and updates the record — booking a shipment that returns a label and stores the tracking number — needs reload: True, or the view keeps showing its pre-action state. Give it a message too: “File downloaded successfully” is no answer to “did the shipment book, and what is the tracking number?”.

Common MIME types:

  • text/csv - CSV files
  • application/json - JSON files
  • application/pdf - PDF files
  • application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - Excel files
  • text/plain - Plain text files

The modal/wizard stays open after download, allowing users to perform additional actions.

Opens a wizard modal. Used for chaining wizards or opening a wizard from a button action.

return {
'type': 'wizard',
'model': 'CreateInvoiceWizard',
'title': 'Create Invoice',
'ctx': {
'default_contact': self.contact.id,
'active_model': 'SaleOrder',
'active_ids': [self.id],
}
}
PropertyTypeDescription
modelstringThe wizard model name
titlestringOptional modal title
methodstringOptional method name (defaults to action_confirm)
ctxdictContext passed to the wizard

To close a wizard and have the parent form pick up changes the wizard made to the parent record, return a close result with reload: True:

return {'type': 'close', 'reload': True}

Refreshes the current view’s records and shows a confirmation. Use it when an action changed data the user is looking at and there is nothing else to say — no modal to close, nowhere to navigate.

return {'type': 'reload', 'notify': 'Rates updated.'}
PropertyTypeDescription
notifystringOptional message (defaults to “Operation executed successfully”)

A plain notify already refreshes, so reach for this type only when the refresh is the point. It refetches records — not the app’s definitions; if you changed those, see app_reload below.

Reloads the whole client, then shows notify once it is back.

Reserve this for an action that changes what the application is rather than what is in it — installing, updating or removing a module. Those rewrite the model registry, the menus, the views and the access rules, and the running client holds cached copies of all of them from the moment the page loaded. reload (refetch the current view’s records) cannot pick any of that up, so the user would keep working against the old application until they happened to refresh by hand.

return {
'type': 'app_reload',
'notify': 'Point of Sale has been installed.',
}

The confirmation is deliberately shown after the reload rather than before it, so it survives the navigation instead of flashing for an instant.

An action that merely changed records wants reload or close + reload:True; reaching for app_reload there throws away the user’s place in the app for no reason.

Navigates to a record’s form view. Identify the target WindowAction by its identifier and pass the record_id to open a specific record (omit record_id to open the action’s default view):

return {
'type': 'window_action',
'identifier': 'financial_document_customer_invoice_action',
'record_id': created_invoice.id,
}

Opens the send message modal with optional pre-selected template.

return {
'type': 'send_message',
'template_id': 5, # Optional - pre-select template
'modal_type': 'message' # 'message', 'note', or 'followers'
}
PropertyTypeDescription
modelstringModel name (auto-detected from context)
record_idintRecord ID (auto-detected from context)
template_idintEmailTemplate ID to pre-select
modal_typestringmessage, note, or followers
rendered_subjectstringPre-rendered subject (for transient models without DB records)
rendered_bodystringPre-rendered body HTML (for transient models without DB records)
report_idintReportAction ID to attach as PDF
contactsarrayPre-populated recipients: [{id, name, email}]

Pre-rendered templates: the modal renders template_id itself against model/record_id, which needs both a database record and a body whose placeholders all resolve from it. Pre-render on the backend and pass rendered_subject + rendered_body when either is missing:

  • a transient wizard (_transient = True) has no record to render against at all;
  • a record-bound action whose body needs context the record doesn’t carry — a payment link’s URL, say, assembled from the install’s base_url and the link’s access token, reachable by no field chain.

Pre-rendered content wins. Supplying rendered_subject + rendered_body stands the modal’s own render down, so you can still pass template_id (to name the template in the picker) and model/record_id (the record the sent message is logged against) alongside your body. Render with EmailTemplate.render_template(template, model_name, record_id, extra_context={...})extra_context keys land in the Jinja context next to the record’s own fields. See CustomerStatementWizard.action_send_statement() and PaymentLink.build_send_message_action().

A template that can only be rendered this way should ship as system: true with no bound model: it is fired programmatically against a synthetic context, so it is neither composable from the message picker nor validatable against a model’s fields.

Generates and downloads a PDF report.

return {
'type': 'report',
'report_id': report_action.id,
'model': 'Invoice',
'instance_ids': [self.id], # Records to render
}
# Or with computed data (for report wizards)
return {
'type': 'report',
'report_id': report_action.id,
'model': 'ReportWizard',
'report_data': computed_data, # Passed to template
'contact_id': contact_id, # Optional - for customer language formatting
}
PropertyTypeDescription
report_idintReportAction ID
modelstringModel name
instance_idsarrayRecord IDs (record-based reports)
report_dataobjectComputed data dict (wizard-based reports)
contact_idintContact ID for customer-facing date formatting
closeboolClose the wizard that produced the report
reloadboolRefresh the view behind it

Like file_download, a report does not refresh by default. Set close/reload when the report accompanies a change — labels auto-printed as a transfer is validated. Both survive the PDF replacing the response body, so they work exactly as they read; without them such a wizard downloads its document and then sits open over a record it has already processed, inviting a second run.

class Contact(Model):
async def action_export_vcard(self):
"""Export contact as vCard file."""
vcard = f"""BEGIN:VCARD
VERSION:3.0
FN:{self.name}
EMAIL:{self.email}
TEL:{self.phone}
END:VCARD"""
import base64
content = base64.b64encode(vcard.encode('utf-8')).decode('utf-8')
return {
'type': 'file_download',
'filename': f'{self.name}.vcf',
'content': content,
'mimetype': 'text/vcard',
}
class ExportWizard(Model):
_transient = True
format = Selection([
('csv', 'CSV'),
('json', 'JSON'),
], default='csv')
async def action_download(self):
"""Generate and download export file."""
active_ids = self._ctx.get('active_ids', [])
records = await get_model("Contact").filter(id__in=active_ids).all()
if self.format == 'csv':
content = self._generate_csv(records)
mimetype = 'text/csv'
ext = 'csv'
else:
content = self._generate_json(records)
mimetype = 'application/json'
ext = 'json'
import base64
encoded = base64.b64encode(content.encode('utf-8')).decode('utf-8')
return {
'type': 'file_download',
'filename': f'contacts.{ext}',
'content': encoded,
'mimetype': mimetype,
}

When a user triggers an action method, it runs as that user — model access and record rules are enforced on everything the method reads or writes, exactly as they are for the user’s normal operations. An action is not implicitly privileged.

If a method legitimately needs to touch data the acting user can’t (a cross-model write into a model they have no write access to — e.g. stamping attribution onto a related record they don’t own), wrap only that operation in elevate():

from fullfinity.engine.base import * # exports elevate
class Campaign(Model):
async def action_send(self):
recipients = await self._resolve_recipients() # read as the user — ACL enforced
for record in recipients:
with elevate(): # narrow, deliberate escalation
await record.update(last_campaign=self.id)

Keep the elevate() block as small as possible: escalate the single write that needs it, not the whole method. A blanket escalation hides which operation actually required elevated rights and re-opens the access checks for everything else the method does.

Only public methods are invokable as actions. A method whose name starts with an underscore (_helper) is internal and cannot be called from the client — keep sequence-sensitive or privileged internals private so they can only run through the public method that guards them.