Skip to content

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.

These element types are only meaningful inside a Configuration tab. Each carries its display config under properties, and most wrap child elements via content.

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
...
PropertyDescription
titlePanel heading (a properties key)
anchorStable 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

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
PropertyDescription
labelSetting label (left column) — required (S4)
descriptionHelp text under the label
visibleQ(...) 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.

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
PropertyDescription
labelLink label
descriptionHelp text under the label
actionIdentifier 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.

A sub-heading inside a panel — splits a long panel into labelled sections without starting a new card.

- type: settingsDivider
properties:
title: Reports
PropertyDescription
titleSection sub-heading

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_action

The 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. :::

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 settingsPanel holds only settingsRow/field or only settingsLinks, never a mix. Put links in the tab’s Setup panel, not beside a field.
  • S2 — One Setup panel, last. A tab has at most one link panel; it is titled Setup and is the tab’s last panel (navigation lives at the bottom, after all settings). Sub-group many links with settingsDivider inside Setup — never a second link panel.
  • S3 — The Setup panel carries an anchor. So add-ons route their links into it (target: {anchor: <tab>_setup}, position: inside) instead of bolting on their own panel.
  • F1 — One Features panel for standalone toggles. A panel whose fields are all Switch toggles (a group of on/off capability enables) must be titled Features, carry an anchor (<tab>_features), and be the tab’s only such panel — sub-group many toggles with settingsDivider inside it, never a second toggle panel. Add-ons route toggles in via target: {anchor: <tab>_features}, position: inside.
  • F2 — Features is reserved. A panel titled Features holds only Switch toggles. 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 settingsRow carries properties.label. The row draws the caption column; without one the control floats against an empty column.
  • S5 — the field inside a row carries nolabel: true. The row already drew the label, so a control that draws its own names the setting twice on the same line. (A field with visible: false is 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 on Configuration would 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 as settingsRows — 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} plus info for a hint line, where id is 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: switch for a setting you turn on and off, checkbox for 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.

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_view

Menu (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_action

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_save override 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 defaultResolver
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=False on 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. Never Model.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 form

Without 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 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 value

Because 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.

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.

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.

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, so action_save treats an unlabeled field as core_admin only — 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 — both company_scoped values and global feature toggles. installs_app toggles 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, so core_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.
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.

  • View Inheritance - The operations/target/position mechanism used above
  • Form Views - The standard (non-settings) form grid
  • Widgets - Input widgets for settings fields