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| Declaration | Called | Use it for |
|---|---|---|
async def action_x(self) | once per selected record | per-record state changes; the common case |
async def action_x(cls, records) | once, with all of them | anything 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 records | a 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
notifywith counts, awizard, areport— 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. recordsarrives 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.
Refreshing the view
Section titled “Refreshing the view”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 type | Refreshes | How to change it |
|---|---|---|
(returns None) | Yes | — |
notify | Yes | reload: False when the method wrote nothing |
reload | Yes | — (that is its whole job) |
close | No | reload: True |
file_download | No | reload: True if the method also changed the record |
report | No | reload: True (survives the PDF replacing the response body) |
app_reload | Reboots 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.
Result Types
Section titled “Result Types”Closes the current modal/wizard and optionally reloads the parent view.
return {'type': 'close', 'reload': True}
# With notificationreturn { 'type': 'close', 'reload': True, 'notify': 'Operation completed successfully'}| Property | Type | Description |
|---|---|---|
reload | bool | Whether to reload the parent view |
notify | string | Optional success message to display |
notify
Section titled “notify”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.
color | Intent | Use it for |
|---|---|---|
green | Success | The thing the user asked for happened |
red | Error | It failed and they need to know why |
orange | Warning | It happened, but not cleanly — or it needs care |
blue (default) | Info | Something worth knowing, no action implied |
gray | Neutral | An 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.
Actionable notifications
Section titled “Actionable notifications”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"}file_download
Section titled “file_download”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', }| Property | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | The download filename |
content | string | Yes | Base64-encoded file content |
mimetype | string | No | MIME type (defaults to application/octet-stream) |
message | string | No | Toast text (defaults to “File downloaded successfully”) |
title | string | No | Toast title |
reload | bool | No | Refresh 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 filesapplication/json- JSON filesapplication/pdf- PDF filesapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet- Excel filestext/plain- Plain text files
The modal/wizard stays open after download, allowing users to perform additional actions.
wizard
Section titled “wizard”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], }}| Property | Type | Description |
|---|---|---|
model | string | The wizard model name |
title | string | Optional modal title |
method | string | Optional method name (defaults to action_confirm) |
ctx | dict | Context passed to the wizard |
close (with reload)
Section titled “close (with reload)”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}reload
Section titled “reload”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.'}| Property | Type | Description |
|---|---|---|
notify | string | Optional 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.
app_reload
Section titled “app_reload”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.
window_action
Section titled “window_action”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,}send_message
Section titled “send_message”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'}| Property | Type | Description |
|---|---|---|
model | string | Model name (auto-detected from context) |
record_id | int | Record ID (auto-detected from context) |
template_id | int | EmailTemplate ID to pre-select |
modal_type | string | message, note, or followers |
rendered_subject | string | Pre-rendered subject (for transient models without DB records) |
rendered_body | string | Pre-rendered body HTML (for transient models without DB records) |
report_id | int | ReportAction ID to attach as PDF |
contacts | array | Pre-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_urland 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.
report
Section titled “report”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}| Property | Type | Description |
|---|---|---|
report_id | int | ReportAction ID |
model | string | Model name |
instance_ids | array | Record IDs (record-based reports) |
report_data | object | Computed data dict (wizard-based reports) |
contact_id | int | Contact ID for customer-facing date formatting |
close | bool | Close the wizard that produced the report |
reload | bool | Refresh 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.
Examples
Section titled “Examples”Export Button on Form
Section titled “Export Button on Form”class Contact(Model): async def action_export_vcard(self): """Export contact as vCard file.""" vcard = f"""BEGIN:VCARDVERSION:3.0FN:{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', }Wizard with Download
Section titled “Wizard with Download”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, }Access control in action methods
Section titled “Access control in action methods”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.
Next Steps
Section titled “Next Steps”- Wizards - Transient models and wizard patterns
- UI Effects - Dynamic field updates