Configuration Forms
All application settings live in one Form view on the Configuration model:
configuration_form_view. It is a vertically-tabbed settings page — each app adds its
own tab by inheriting that view and inserting a tab into the shared
pageTabs container. Inside a tab, settings are laid out with a small purpose-built
DSL (settingsPanel / settingsRow / settingsLink / settingsDivider) rather than
the ordinary 12-column form grid.
This is the mandated home for configuration and reference data. A module’s top menu
gets exactly one “Setup” (or “Configuration”) leaf menu that opens this form on the
module’s tab; per-config-model menus are an anti-pattern. Config models (stages, types,
categories, plans, rules, …) are reached from the tab via a settingsLink, not their
own menu item.
The settings DSL
Section titled “The settings DSL”These element types are only meaningful inside a Configuration tab. Each carries its
display config under properties, and most wrap child elements via content.
settingsPanel
Section titled “settingsPanel”A titled card grouping related settings. A panel is homogeneous — it holds either
editable settings (settingsRow) or navigation settingsLinks, never both (see
Layout rules below).
- type: settingsPanel properties: title: Lead Management content: - type: settingsRow ...| Property | Description |
|---|---|
title | Panel heading (a properties key) |
anchor | Stable handle (required on the tab’s Setup link panel, so add-ons can target it). Set it as a top-level key on the settingsPanel node — sibling to type/properties, not inside properties |
settingsRow
Section titled “settingsRow”A single labelled setting: a label + description on the left, the input field on the
right. The bound field goes in content and is rendered with nolabel: true (the
row supplies the label).
- type: settingsRow properties: label: Default Stage description: Initial stage for new leads content: - type: field name: crm_default_stage properties: widget: DataCombo nolabel: true| Property | Description |
|---|---|
label | Setting label (left column) — required (S4) |
description | Help text under the label |
visible | Q(...) condition. Put it here, not on the field (S6) |
The field inside the row is an ordinary view field — use any input
widget (Switch, DataCombo, NumberInput, ColorInput, …) and the
field must be a real column on the Configuration model.
settingsLink
Section titled “settingsLink”A navigation row that opens another window action — the way config/reference models are
reached from the settings form (instead of a standalone menu). No bound field; it just
fires an action.
- type: settingsLink properties: label: Stages description: Configure pipeline stages for leads action: crm_stage_action| Property | Description |
|---|---|
label | Link label |
description | Help text under the label |
action | Identifier of the WindowAction to open |
All of a tab’s navigation links live in one panel titled “Setup”, carrying a
stable anchor and placed last in the tab — it is the list of the module’s config
models. This is a hard rule, not a convention (see Layout rules).
Because a config model is reached through a link rather than a menu, the global command
palette indexes these links separately and lists them under Settings, so a user who
searches “warehouses” lands on it without knowing which app’s setup screen holds it.
Selecting one opens the link’s action under the app whose Setup menu owns the tab. You
get this by authoring the link — nothing to declare. It honors the same gates the screen
does: the target model’s ACL, groups, and any visible/visible_setting expression, so
a link behind a feature toggle that is off is not findable either.
settingsDivider
Section titled “settingsDivider”A sub-heading inside a panel — splits a long panel into labelled sections without starting a new card.
- type: settingsDivider properties: title: Reports| Property | Description |
|---|---|
title | Section sub-heading |
Adding a tab to the Configuration form
Section titled “Adding a tab to the Configuration form”Inherit configuration_form_view and add a tab inside the pageTabs
container. Real settings go in settingsPanel/settingsRow (fields on
Configuration); each former config-model menu becomes a settingsLink.
- data_type: UiView name: configuration_form_view_crm 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 anchor: crm title: CRM properties: icon: Filter groups: [sales_manager_group] content: - type: settingsPanel properties: title: Lead Management content: - type: settingsRow properties: label: Default Stage description: Initial stage for new leads content: - type: field name: crm_default_stage properties: widget: DataCombo nolabel: true - type: settingsRow properties: label: Use Leads description: Enable lead qualification before converting to opportunity content: - type: field name: crm_use_leads properties: widget: Switch nolabel: true - type: settingsPanel anchor: crm_setup # stable handle; add-ons route links in here properties: title: Setup # exactly "Setup"; must be the tab's LAST panel content: - type: settingsLink properties: label: Stages description: Configure pipeline stages for leads action: crm_stage_action - type: settingsLink properties: label: Sources description: Manage lead sources action: crm_source_actionThe tab’s name is the routing key the Setup action lands on (default_tab); title
is the displayed label; properties.icon is a Lucide icon and properties.groups
gates the whole tab to a group.
:::note Extending another module’s tab
An add-on drops its content into the host tab’s existing panels, never a new panel of
its own. Route a link into the host’s Setup panel by its anchor; insert a field
panel before that anchor so the Setup links stay last:
# A navigation link → into the host Setup panel- action: add target: {anchor: helpdesk_setup} # the host tab's Setup panel position: inside value: type: settingsLink properties: label: SLA Policies action: helpdesk_sla_policy_action
# A settings field → its own field panel, before Setup- action: add target: {anchor: helpdesk_setup} position: before value: type: settingsPanel properties: {title: Satisfaction} content: - type: settingsRow properties: {label: Request a Rating on Close} content: - type: field name: helpdesk_rating_on_close properties: {widget: Switch, nolabel: true}Adding a second link panel, or a field panel after Setup, is rejected by the gate.
:::
Layout rules (enforced)
Section titled “Layout rules (enforced)”Because every app extends the same Settings form, the per-tab layout is a mechanical
contract — enforced at YAML save time and by ./fullfinity-server check --only views (CI +
pre-commit), so a third-party module cannot lay its tab out arbitrarily:
- S1 — Homogeneous panels. A
settingsPanelholds onlysettingsRow/fieldor onlysettingsLinks, never a mix. Put links in the tab’sSetuppanel, not beside a field. - S2 — One
Setuppanel, last. A tab has at most one link panel; it is titledSetupand is the tab’s last panel (navigation lives at the bottom, after all settings). Sub-group many links withsettingsDividerinsideSetup— never a second link panel. - S3 — The
Setuppanel carries ananchor. So add-ons route their links into it (target: {anchor: <tab>_setup}, position: inside) instead of bolting on their own panel. - F1 — One
Featurespanel for standalone toggles. A panel whose fields are allSwitchtoggles (a group of on/off capability enables) must be titledFeatures, carry ananchor(<tab>_features), and be the tab’s only such panel — sub-group many toggles withsettingsDividerinside it, never a second toggle panel. Add-ons route toggles in viatarget: {anchor: <tab>_features}, position: inside. - F2 —
Featuresis reserved. A panel titledFeaturesholds onlySwitchtoggles. A toggle that reveals dependent fields, or a value/defaults setting, is not a Features item — it lives in a descriptively-titled domain panel (Invoicing,Default Taxes,Lead Management). - S4 — every
settingsRowcarriesproperties.label. The row draws the caption column; without one the control floats against an empty column. - S5 — the
fieldinside a row carriesnolabel: true. The row already drew the label, so a control that draws its own names the setting twice on the same line. (A field withvisible: falseis exempt — that is a hidden carrier for other nodes’ conditions, not a control.) - S6 — a conditional setting puts
visible:on the ROW, not on the field. Hiding only the field leaves its label and description above an empty slot, which reads as broken rather than as not-applicable.
All three render without an error of any kind — the form loads, the toggle works, and only the layout is wrong — which is why they are gated rather than merely described.
Settings a user sees the effect of: pageBuilderPanel
Section titled “Settings a user sees the effect of: pageBuilderPanel”A visual setting — whether a blog post shows a sidebar, how a section is laid out — is better
edited where its effect is visible than on a Setup screen. Declare it as a pageBuilderPanel
and the website’s page builder renders it in the chosen tab, beside Theme, Brand, Header and
Footer:
- data_type: UiView identifier: website_form_view_blog inherited_view: website_form_view # the view of the model whose fields it edits model: Website arch: operations: - action: add target: {anchor: action_open_builder} position: after value: type: pageBuilderPanel anchor: website_blog properties: tab: website # which builder surface (B1 rejects an unknown one) title: Blog # titled after your app, not "Features" icon: Newspaper schema: # the builder's own field dialect (B1 requires one) - type: switch # each type names the control it draws id: blog_show_sidebar # the field on the model, and the stored key label: Blog Sidebar info: The column beside a post- It is a distinct node type, not a flag on
settingsPanel: a panel that renders in a different place should not be one indistinguishable line away from one that does not. - The Settings form skips it — one home per setting.
- Storage is the record the view belongs to. Per-site settings are fields on
Website, saved by the builder’s own Save alongside the brand and chrome settings. Putting them onConfigurationwould store them per COMPANY, so two websites on one install could not differ. - Your app not being installed means the panel is absent from the composed view, so the builder needs no “is it installed?” check and neither do you.
- Fields are declared in
schema, not assettingsRows — the same dialect the builder already uses for a theme’s header, footer and section settings, drawn by the builder’s own renderer. So your panel looks like every other panel in that tab, and gains new field types as the builder does. Each entry is{type, id, label}plusinfofor a hint line, whereidis the field on the model. Types:text,textarea,richtext,select(+options),switch,checkbox,number,color,image,icon,url,repeater. A boolean has two types because they are two different controls:switchfor a setting you turn on and off,checkboxfor one you tick. - The
settingsRow/S4/S5/S6 rules are for the Settings form and do not apply here.
The result is one skeleton for every tab: descriptive domain panels → one Features → one
Setup. Field panels are titled by concern; the single Features and Setup panels are the
two reserved, structural roles. To place a new domain/field panel, target the Setup anchor
with position: before.
Wiring the Setup menu and action
Section titled “Wiring the Setup menu and action”The tab is reached through one leaf menu → one window action onto Configuration,
landing on the tab via action_ctx.default_tab.
Action (views/actions.yaml):
- data_type: WindowAction name: CRM Setup identifier: configuration_crm_setup_action model: Configuration default_view: configuration_form_view modes: Form action_ctx: default_tab: crm views: - - R - - configuration_form_viewMenu (views/menus.yaml) — a single leaf at sequence: 100, no children:
- data_type: UiMenu name: Setup identifier: core_pipeline_config_menu sequence: 100 parent: core_crm_menu action: configuration_crm_setup_actionInstall-on-toggle settings (installs_app)
Section titled “Install-on-toggle settings (installs_app)”When a feature is delivered as a separate installable module (an add-on app), don’t ship
a redundant “enable” switch inside that module — the switch would only appear after the
module is already installed. Instead put one switch on the parent app’s Configuration that
installs and uninstalls the add-on. Declare it with installs_app on a Boolean:
class ConfigurationInventory(Model): __inherit__ = "Configuration"
enable_barcode_picking = Boolean( installs_app="barcode_picking", # module identifier to install/uninstall description="Barcode Picking", hint="Fullscreen barcode scanner app for warehouse operations.", )That single declaration is all you need — the Configuration model handles the rest centrally:
- On load the switch is mirrored from the module’s real install state (source of truth), so it reads correctly however the app was installed — through this form or the Apps screen.
- On save, flipping it on installs the app, off uninstalls it. There is nothing to
store (the value is derived), and no
_default_get/action_saveoverride to write. - If the target is a licensed (enterprise) app and no valid license is active, enabling it raises the standard license error, which the client turns into the upgrade prompt — the app is not installed.
Render it as an ordinary Switch in a settingsRow. Once the add-on is installed, its own
Configuration panel contributes any genuine sub-settings (which naturally appear only when it’s
present) — keep those in the add-on; keep only the install switch on the parent.
Settings are overrides — declare a resolver, don’t store a default
Section titled “Settings are overrides — declare a resolver, don’t store a default”A relational setting starts blank on every fresh install: data/ has loaded, but nobody
has opened this form. So a setting is an override, and blank has to keep meaning “work
the real value out at the point of use”.
Declare that fallback on the field itself, as an ordinary default=:
barcode_rule_set = ManyToOne( "BarcodeRuleSet", description="Barcode Rule Set", company_scoped=True, # Blank resolves to the seeded GS1 rule set. default=lambda self: self._resolve_default_rule_set(),)
async def _resolve_default_rule_set(self): return await get_model("BarcodeRuleSet").filter(active=True).order_by("id ASC").first()Consumers then just read the setting, and get a real value whether or not anyone configured one:
rule_set_id = await Configuration.get_config("barcode_rule_set", company_id)On a relational setting a callable default= is a resolver, which behaves differently
from an ordinary default in one way that matters: it is never written to storage. It is
evaluated by get_config on every read instead. That is what makes the three properties
below hold at once — none of which a stored default can offer:
| Stored default | Resolver | |
|---|---|---|
| Consumers get a value when unset | ✅ | ✅ |
| The admin can clear it back to “resolve” | ✗ — clearing stores nothing, so it comes straight back | ✅ |
| Re-mapping the underlying record reaches existing installs | ✗ — pinned at the moment it was written | ✅ |
The resolver runs as the company being asked about, so get_config(key, doc.company.id)
resolves that company’s accounts rather than the reader’s. Reach the company through
env_ctx.get().company_id (or get_user_company(self)) and it is already correct.
Two practical rules:
- A resolver must not raise. It runs on ordinary reads, including simply opening the
settings form. Resolve softly (
required=Falseon an account role) and let the consumer raise if a missing value is fatal for what it is doing — that decision belongs to the posting, not to the lookup. - A resolver must be specific. Resolve a seeded record by its stable
identifier, or a role, or the company’s first record of a constrained type. NeverModel.filter().first()on an unconstrained type — that grabs whatever sorted first (an invoice PDF as a course certificate).
Not every setting has a resolver, and that’s fine. “No default sale tax” and “nobody in
particular chases overdue payments” are real answers a resolver cannot express. Leave those
without a default= and give the picker an authored placeholder saying what blank does
(“Leave blank for no default tax”).
Scalar settings: get_config returns the field’s declared default
Section titled “Scalar settings: get_config returns the field’s declared default”For a scalar setting (a Boolean toggle, an Integer, a Selection), “nothing stored” is the
normal state rather than an edge case — a save persists only what the user actually changed,
so a setting left at its declared default is never written at all.
get_config therefore falls back to the field’s own default= when storage holds nothing:
# hr_attendance_terminal_require_photo = Boolean(default=True, company_scoped=True)require_photo = await Configuration.get_config("hr_attendance_terminal_require_photo", company_id)# → True on a fresh install, without anyone having opened the settings formWithout that fallback a Boolean(default=True) would read as falsy until someone toggled it
twice, and a feature that ships on would behave as off.
A scalar’s default= is stored-shaped: it is the value the setting has when nothing was
written, so get_config returns it directly and the form shows it as a real value. That is
the right model for a scalar, where “off” (False) is itself a storable answer — which is
exactly why a relation needs the resolver treatment above instead: None on a picker is not
a value, so a stored default would leave no way to express “no override”.
Setting a value from inside action_save — use persist_setting
Section titled “Setting a value from inside action_save — use persist_setting”Because a save writes only what the user submitted, a value your action_save override
computes during the save was never submitted and a plain assignment is silently discarded — the
hook looks like it worked, the in-memory record carries the value, and nothing reaches storage.
That is how a hook minting a secret on first enable can mint a fresh one on every save and
store none of them.
Assign through persist_setting instead, which marks the field for the save already in flight:
async def action_save(self): if self.terminal_enabled and not self.terminal_token: # ✗ self.terminal_token = secrets.token_urlsafe(24) — dropped, never submitted self.persist_setting("terminal_token", secrets.token_urlsafe(24)) return await super().action_save()It works for either backend (Params for a global setting, CompanyConfig for a company_scoped
one), so the hook doesn’t need to know where its own field is stored. Don’t confuse it with
CompanyConfig.set_value, which writes company-scoped storage directly and bypasses the save.
A blank picker says what it resolves to
Section titled “A blank picker says what it resolves to”A blank override is correct, but on screen it is indistinguishable from a setting the app failed to seed — an empty Barcode Rule Set on a fresh database got reported as broken while scanning had worked the whole time.
You don’t have to write anything for this. A setting with a resolver keeps its value empty and gets the resolved record’s name as its placeholder, generated when the form is built:
Barcode Rule Set [ GS1 Standard ] ← greyed placeholder, not a valueBecause it is computed, it names the record you will actually get and cannot go stale when the resolution changes. Nothing is pre-filled, so the picker still reads as empty to the save: clearing an override stays possible and simply means “resolve this again”.
Write a placeholder by hand only for a setting with no resolver, where blank is a real
answer rather than a deferred one (“Leave blank for no default tax”). An authored placeholder
is left alone when a resolver comes back empty, so it also works as a fallback.
Only what changed is saved
Section titled “Only what changed is saved”action_save compares each submitted setting against a pristine form — the same settings
built with nothing submitted — and persists only the differences. The form posts every field
it rendered across every tab, so without that comparison one Save would write ~40 settings
from whatever was on screen, materializing each unset field’s Python default= into storage
and collapsing “never configured” into “deliberately set to the default”.
You get this for free; there is nothing to opt into and no override to write.
Settings-driven visibility
Section titled “Settings-driven visibility”Other views can show or hide elements based on a Configuration value with the
visible_setting property (a Q-expression evaluated against the live settings) — e.g.
hide a menu or field until a feature toggle is on:
visible_setting: Q(crm_use_leads=True)The key must be a Configuration field, and it is resolved from whichever store that field
uses: a company-scoped setting is read for the acting company (its per-company value),
a global setting from Params. Both kinds work — you don’t declare which. Because a saved
setting invalidates the shared caches, the gate reflects a change on the next time the view is
composed (i.e. on navigation), with no server restart; a company-scoped value is even isolated
per company, so the same view can gate differently for two companies. (Gating a visible_setting
on anything that isn’t a Configuration field won’t resolve — put routing keys in a code Char,
not a setting.)
A gated field is hidden, not dropped; gated chrome is removed. When the gate fails,
a field node is kept in the composed view but forced visible: false — so its value still
loads and binds on the form even though the input isn’t shown. This matters for dependencies:
a Monetary whose currency_field points at a currency picker gated behind
Q(enable_multi_currency=True) still formats with its currency symbol when multi-currency is
off, because the (hidden) currency value is still present. required is left intact on a hidden
field — if a gated-off field is required, give it a resolving default so it’s never a
blank-but-required landmine (see Settings must resolve); don’t rely on the gate to suppress the
requirement. Non-field chrome (menus, tabs, settingsPanels, settingsLinks, actionButtons,
linkButtons) is instead removed outright when its gate fails — there’s no value to preserve and
hiding empty structure would only leave layout artifacts.
An add-on’s own menus and views don’t need a visible_setting gate to hide when the add-on
is off — they only compose when the module is installed, so the installs_app switch already
governs their presence.
Who may change a setting (groups=)
Section titled “Who may change a setting (groups=)”Configuration is a transient model, so it has no ModelAccess of its own, and the
settings save runs elevated (it writes the Params / per-company CompanyConfig stores that
are otherwise admin-only). That makes the settings form itself the access boundary — so who
may change a setting is the field’s own groups=, the same attribute that gates any field.
No settings-specific concept to learn:
class ConfigurationSales(Model): __inherit__ = "Configuration"
enable_quote_templates = Boolean( description="Quote Templates", groups=["sales_manager_group"], # who may change this setting (and see it) )- Unlabeled ⇒ admin only. A normal field with no
groups=falls back to the model’s ACL; a transient config field has none, soaction_savetreats an unlabeled field ascore_adminonly — the safe default for system-level settings. - Label an app’s settings with that app’s manager group (
sales_manager_group,invoicing_admin, …) so the people who run the app can configure it — bothcompany_scopedvalues and global feature toggles.installs_apptoggles are the exception: installing or removing a module is an administrator action, so leave them unlabeled (core_admin). groups=is checked against the caller’s implied-inclusive group set, with the usual admin bypass, socore_admin(which implies every app manager) can always change everything.- Because it’s
groups=, the field is also hidden from non-members in the form — you get “don’t show a setting you can’t change” for free, from the same declaration. - On save, changes to settings the caller isn’t authorized for are silently ignored (not an
error) — the form submits the whole object, so a manager saving their own tab also re-submits
fields they can’t write; those keep their stored value. Enforcement is server-side in
action_save; it never trusts anything the client sends about which groups are required.
settingsLinks are gated by their target model
Section titled “settingsLinks are gated by their target model”A settingsLink opens a real reference-data model (Stages, Teams, …), so its visibility is
derived from that model’s own ACL — the link is automatically hidden from a user who lacks
read access to the linked model, and enforced by that model on click. You don’t (and shouldn’t)
put groups: on the link to gate access; the model’s ACL is the single source of truth, so the
link can never drift out of sync with who can actually use it.
Next Steps
Section titled “Next Steps”- View Inheritance - The
operations/target/positionmechanism used above - Form Views - The standard (non-settings) form grid
- Widgets - Input widgets for settings fields