Skip to content

Defining Models

Models define data structures and business logic in Fullfinity.

from fullfinity.engine.base import *
class Product(Model):
name = Char(max_length=255, required=True)
description = Text()
price = Monetary(default=0.0)
active = Boolean(default=True)

A module’s models are loaded by importing its models/ package — the loader runs models/__init__.py and does not descend into the directory. So a model file is imported only if models/__init__.py has a from . import <file> line for it. Drop a <name>.py in without listing it there and it silently never loads (no registration, no composition, _selection_add/__inherit__ extensions lost). There is no manifest models: key.

To add a model: create <name>.py in the module’s models/ directory and add from . import <name> to that models/__init__.py (keep the list alphabetical to match the existing files).

Under the hood, every listed model file is imported once when the server boots, and the Model metaclass registers each class under its module. The framework then builds a fresh per-database version of each model (composing any __inherit__ extensions and that database’s custom fields) without ever mutating the imported class — so two databases never affect each other.

Consequences for module authors:

  • Relative imports work inside a module: from ._helpers import compute_total. Use get_model("Other") to reference other models (it avoids import cycles and respects per-database composition), but ordinary helpers can be imported relatively.
  • Changing a model’s Python requires restarting the server/worker for the change to take effect (models are loaded once, not re-read per request) — the same as any normal deploy.
  • Prefix a non-model helper file with _ (e.g. _helpers.py) to keep it out of model discovery; it’s still importable.

The database schema is synced from your model definitions automatically when a module is installed or updated — you don’t write migrations by hand. A few rules govern how adding a stored field to a model that already has data is handled:

  • Optional field → the column is added (nullable). Nothing else to do.
  • Required field with a default → the column is added nullable, every existing row is backfilled with the default, and the NOT NULL constraint is then applied.
  • Required field without a defaultNOT NULL is enforced only when the table is empty. On a table that already has rows there is no value to put, so the column is added nullable and the migration logs a WARNING instead of aborting. Backfill the column yourself and tighten it to NOT NULL in a follow-up. (Previously this raised a raw NotNullViolationError and failed the whole update.)

So: a brand-new required-no-default field is fine on a fresh database but needs a default (or a manual backfill) to become NOT NULL on an existing one.

Every foreign-key column — including the two in a many-to-many junction — is named <snake_case_model>_id (e.g. a junction between PosRegister and PosTenderType has columns pos_register_id / pos_tender_type_id). One convention, everywhere: ordinary ManyToOne fields, auto-generated junctions, and explicit through models all agree.

  • Auto junctions (a plain ManyToMany(through="FkOwnerTarget") with no explicit model) get snake(owner)_id / snake(target)_id columns automatically.
  • Explicit through models (a class FkOwnerTarget(Model) you write to add a field to the link) must name their two ManyToOne fields the snake_case of each target model (pos_register, not posregister or register) — the column is then <field>_id and matches.
  • Exception — self-referential junctions (both FKs point to the same model, e.g. GroupGroup): the two columns can’t share a name, so use distinct semantic names (group / implied_group). These are left as-is.

Promoting an auto junction to an explicit model, or renaming a through model’s FK field, is seamless and data-preserving. On the next migration, db.reconcile_m2m_junctions renames the physical column in place (it never ADD/DROPs an FK, which would drop the links) — matching the old column by the table name (auto/promotion) or the previous field name (field rename). It runs on every migration, even when no per-module schema change is detected, so the convention reaches all junctions. If you rename a through FK field, also fix any raw SQL that joins that junction by the old column name (the ORM’s own M2M reads/writes adapt automatically; only hand-written SQL needs updating).

Every model must define _verbose_name - a human-readable name used in UI labels, breadcrumbs, and activity streams:

class Product(Model):
_verbose_name = "Product"
name = Char(max_length=255, required=True)

:::warning Required Models without _verbose_name will raise an error at startup (except inherited models, transient models, and through tables). :::

By default, the table name is the lowercase model name. Override with _table_name:

class Product(Model):
_verbose_name = "Product"
_table_name = "my_products"
name = Char(max_length=255)
class Product(Model):
_verbose_name = "Product"
_order_by = ["name ASC", "id DESC"]
name = Char(max_length=255)

Enable activity tracking and messaging:

class Lead(Model):
_verbose_name = "Lead"
_collaborate = True
name = Char(max_length=255)

For models with _collaborate = True, field changes are recorded in the activity log. Each field type has a default track setting that determines whether changes are tracked:

Field Typetrack default
Char, Integer, Float, Boolean, Date, Datetime, Selection, MonetaryTrue
Text, JSON, File, BinaryFalse
ManyToOne, OneToOneTrue
OneToMany, ManyToManyNever tracked

To exclude a specific field from tracking, set track=False:

class Invoice(Model):
_verbose_name = "Invoice"
_collaborate = True
number = Char(max_length=50)
# Exclude auto-calculated field from change tracking
total_amount = Monetary(calculate="compute_total", store=True, track=False)
exchange_rate = Float(track=False) # Auto-set, don't track

Enable the model to appear in the global search feature:

class Product(Model):
_verbose_name = "Product"
_global_search = True # Appears in global search
name = Char(max_length=255, required=True) # Required for global search

Every model is included in the workspace’s generated, shareable API reference (published from Configuration → Setup → API Documentation and served at /api-docs). The reference is built live from the model registry: each model contributes its CRUD operations (query/fetch/create/ update/delete), its public methods, and any report/print actions, grouped under the model’s _verbose_name, with request/response schemas and examples derived from the field definitions.

Set _api_doc = False to keep an internal model (a junction table, a plumbing record an integrator should never call directly) out of that reference. The model still works exactly as before — it’s only hidden from the docs:

class WidgetAuditTrail(Model):
_verbose_name = "Widget Audit Trail"
_api_doc = False # internal — omit from the public API reference

The flag is inherited by subclasses. Through-tables and models with no verbose name are omitted automatically.

Models that define an active Boolean field automatically support archive behavior (soft-delete):

class Product(Model):
_verbose_name = "Product"
active = Boolean(default=True, description="Active") # Enables archive behavior
name = Char(max_length=255, required=True)

When a model has an active Boolean field:

  • The UI shows “Archive” option instead of permanent delete
  • Archived records have active = False
  • List views can filter by active/archived status
  • Queries automatically filter to active=True (see below)

When a model does not have an active field:

  • The UI shows “Delete” for permanent deletion
  • No automatic filtering is applied

For archivable models, queries automatically filter out archived records (active=False) unless you explicitly include them:

# Only returns active products (automatic filter applied)
products = await Product.filter().all()
product = await Product.filter(id=1).first()
# Include archived records with include_archived()
all_products = await Product.filter().include_archived().all()
# Get only archived products
archived = await Product.filter(active=False).include_archived().all()
# Explicit active filter bypasses automatic filtering
active_only = await Product.filter(active=True).all() # Same as default

The automatic filter is applied when:

  • Model has an active Boolean field
  • include_archived() is not called
  • No explicit active filter is in the query (e.g., active=True, active=False, active__eq=True)

Archiving does not null out references to a record

Section titled “Archiving does not null out references to a record”

The automatic active filter applies to queries and reverse collections, not to resolving a reference by id. A ManyToOne/OneToOne FK, and the explicit links of a ManyToMany, always resolve their target even when it is archived — the reference is to a specific row that still exists, so it must not read as NULL:

# order.customer resolves even if that customer was archived after the order was placed
await order.fetch_related("customer")
assert order.customer is not None # archived, but still the referenced record

This holds for both fetch_related (single record) and prefetch_related (bulk) — they agree. A OneToMany reverse collection is the deliberate exception: like any list it hides archived children (parent.children excludes archived rows).

Practical consequence: do not infer “the target was removed” from a relation reading as None after archiving — archiving never makes a FK None. Test the target’s active flag explicitly when you need to skip archived records (e.g. a scheduler skipping retired equipment), and reserve the None case for a genuine hard delete.

Models that define an is_system Boolean field automatically block deletion of records flagged is_system=True — the same field-presence pattern as archivable. Use it for shipped/standard/auto-managed records (e.g. seeded report definitions, warehouse-generated routes, core web pages) that users must not delete:

class FinancialReport(Model):
_verbose_name = "Financial Report"
name = Char(max_length=255, required=True)
is_system = Boolean(default=False) # records with is_system=True can't be deleted

How it works:

  • The metaclass auto-detects the is_system field and sets _system_protected = True on the model (mirrors _archivable).
  • A single guard in the base Model.delete() raises UserError if any target record has is_system=True. There is no per-model delete() override — this is the one enforcement point.
  • is_system is explicit data: seed YAML sets is_system: true on the records to protect. It is never derived from an install/seeding context.
  • Editing is not blocked — the convention is “duplicate to customize” (or archive). Only deletion is guarded, paralleling how active governs delete behavior.

Customizing and bypassing:

class Route(Model):
is_system = Boolean(default=False)
# Optional: a tailored message instead of the generic default
_system_protected_message = (
"System routes are auto-managed and cannot be deleted. Archive them instead."
)
  • Escape hatch for legitimate programmatic teardown: pass allow_system_delete in context — await rec.with_ctx(allow_system_delete=True).delete().
  • Module uninstall drops tables directly, and DB-level ON DELETE CASCADE operates below Python — both bypass this guard, so dependent cleanup still works.

Set _transient = True for a model that should have no database table — the classic case is a wizard: a short-lived form whose values are collected, acted on, and discarded. Transient models are excluded from schema migrations entirely.

class CreateInvoiceWizard(Model):
_verbose_name = "Create Invoice"
_transient = True
invoice_date = Date(required=True)
journal = ManyToOne("Journal", on_delete="CASCADE", related_name="invoice_wizards")
async def create_invoice(self):
# self is an in-memory instance — never persisted
...

How it behaves:

  • No table, no migration. Transient models are filtered out of both the source and target registries before the schema is built, so nothing is ever created or dropped for them.
  • In-memory instances. When the API runs a method on a transient model it builds the instance with model.new(...) rather than inserting a row — the record exists only for the duration of the call.
  • The flag is inherited. Subclasses inherit _transient from their base unless they set it themselves (it defaults to False).
  • Constraints the metaclass enforces: company-scoped fields are forced to store=False (kept in KV storage). A transient model may have a OneToMany (an editable wizard line grid) as long as the child model is also transient — the lines live only in memory and the action method materializes the real records (see Wizards → Editable line grids). Pointing a transient OneToMany at a persistent model is rejected (there is no parent row for a child FK to reference).
  • No ModelAccess needed — and don’t add one. A transient model has no rows, so access grants don’t apply to it: the framework treats transient forms (and their embedded editable line grids) as editable without a ModelAccess rule. What gates a wizard is the ability to open it plus the real permission checks the backend runs when its action confirms (on the persistent records it creates). Adding a ModelAccess for a transient model is a no-op at best and misleading at worst.

_controlled_edits freezes a record once it reaches a state where editing it would be wrong — a posted invoice, invoiced time. It is a list of rules; when a rule’s condition holds, every field is read-only except the ones that rule lists in exclusions.

A frozen record also cannot be deleted: while any rule’s condition holds, delete() refuses the record (with that rule’s message), because dropping a posted/locked row is strictly more destructive than editing it. exclusions govern which fields stay editable — they never make a frozen record deletable. As with the write freeze, an elevate() context lifts it, which is how a legitimate reversal/cancel or a system teardown still removes the row.

class Invoice(Model):
_verbose_name = "Invoice"
_controlled_edits = [
{
"id": "posted",
"condition": "Q(state__in=['Posted', 'Paid', 'Cancelled'])",
"exclusions": ["note", "internal_note"],
"message": "This invoice is posted. Reverse it to make changes.",
},
]
state = Selection(choices=["Draft", "Posted", "Paid", "Cancelled"], default="Draft")
amount = Monetary()
note = Text()
internal_note = Text()
KeyMeaning
conditionQ-expression; while it holds for a record, the record is frozen.
exclusionsFields this rule still allows through.
messageShown when a write is refused, and as the tooltip on a locked cell. Say what to do.
idStable handle another module targets to override or disable this rule. Optional — derived from the declaring class when omitted.
disabledWith an id, switches an inherited rule off.

It is a list because several modules may freeze the same model for reasons they don’t share. Billing freezes invoiced timesheets; approval freezes submitted ones. Neither knows about the other, so neither can be asked to restate the other’s rule.

Rules merge across __inherit__ by id — the same idea as a view patch targeting an anchor. You name the rule you are changing rather than overwriting whatever composed before you:

# Another module ADDS a reason of its own — new id, so both rules now apply
class TimesheetApproval(Model):
__inherit__ = "Timesheet"
_controlled_edits = [
{"id": "approved",
"condition": "Q(approval_state__eq='Approved')",
"exclusions": ["approval_state"],
"message": "Approved — ask your manager to reopen it."},
]
# A customization OVERRIDES an existing rule — same id replaces it outright
class TimesheetLocalOverride(Model):
__inherit__ = "Timesheet"
_controlled_edits = [
{"id": "invoiced",
"condition": "Q(invoiced=True) & Q(company__eq=5)",
"exclusions": ["invoice_line", "hours"]},
]
# ...or switches it off entirely
class TimesheetNoBillingFreeze(Model):
__inherit__ = "Timesheet"
_controlled_edits = [{"id": "invoiced", "disabled": True}]

When more than one rule matches a record, a field stays writable if any matching rule exempts it — each rule’s exclusions are the exemptions it grants. That is what lets billing write invoice_line onto time that approval has also frozen. Exemptions from rules that don’t currently match are ignored.

A freeze bites on a modification, not on a field merely being present in a write: a field whose submitted value equals the stored one is a no-op and passes untouched. This is why saving an embedded-list edit (which re-submits the parent’s untouched fields) doesn’t trip the parent’s freeze. You never list a field in exclusions just because a client re-sends it unchanged — only list fields whose value genuinely changes on a frozen record (a revert/reset action). The engine’s own metadata (identifier, display_name, audit stamps) and calculated fields are never blocked, so they need no exclusion either.

:::note The UI reads the same rules Form fields, List cells and TimeGrid cells all render read-only from these rules and show the matching message, so a freeze is declared once and every surface agrees. You do not declare locking per view.

UI effects obey them too: a value an effect derives into a frozen field is dropped and the record’s own value restored, so editing a field the rule still allows can never produce a save the rule then rejects. Effects are written for the editable case; the freeze decides the rest. :::

Bypassing in code:

System flows that legitimately need to write a frozen record — posting, reversing, a scheduled job — run elevated, and freeze rules are not applied under elevate():

from fullfinity.engine.base import elevate
with elevate():
await record.update(amount=100)

Single field uniqueness: Use unique=True on the field definition.

class Product(Model):
_verbose_name = "Product"
code = Char(max_length=50, unique=True) # Code must be unique

Composite uniqueness: Use _unique_together for multi-field constraints.

class Translation(Model):
_verbose_name = "Translation"
_unique_together = [("language", "key")]
language = ManyToOne("Language", related_name="translations")
key = Char(max_length=255)

Multiple constraints:

_unique_together = [
("code", "company"),
("name", "category"),
]

Removing constraints via inheritance:

Extensions can remove unique constraints defined by a base model:

class ContactExtension(Model):
__inherit__ = "Contact"
_unique_together_remove = [("code", "company")] # Remove this constraint

Removing field-level uniqueness via inheritance:

class ContactExtension(Model):
__inherit__ = "Contact"
# Redefine the field without unique=True
code = Char(max_length=50, unique=False) # Overrides base's unique=True

Single field indexes: Use index=True on the field definition.

class Product(Model):
_verbose_name = "Product"
sku = Char(max_length=50, index=True) # Single-column index

Composite indexes: Use _composite_indexes for multi-column indexes that speed up queries filtering on multiple fields together.

class Quant(Model):
_verbose_name = "Quant"
_composite_indexes = [
("product_variant", "location", "lot"), # 3-column index
("product_variant", "location"), # 2-column index
]
product_variant = ManyToOne("ProductVariant", related_name="quants")
location = ManyToOne("Location", related_name="quants")
lot = ManyToOne("Lot", related_name="quants")

Use field names (not column names) - the ORM automatically converts ManyToOne fields to their _id column names:

  • ("product_variant", "location") → index on (product_variant_id, location_id)

Index naming: idx_{table_name}_{field1}_{field2}_{...}

Inheritance: Composite indexes from parent and child classes are merged:

# Base model
class Product(Model):
_composite_indexes = [("name", "category")]
# Extension adds more indexes
class ProductExtension(Model):
__inherit__ = "Product"
_composite_indexes = [("sku", "warehouse")]
# Result: both indexes exist

:::tip When to use composite indexes Composite indexes are useful when you frequently query by multiple fields together:

# This query benefits from a composite index on (product_variant, location)
await Quant.filter(product_variant=variant, location=loc).all()

PostgreSQL can use a composite index for prefix queries (e.g., filtering only on product_variant), but a single-column index is more efficient for single-field queries. :::

An extension can add choices to a Selection field another module declared, using _selection_add. Offer a choice from the module that can actually honour it, so the dropdown never shows an option nothing behind it can fulfil.

class OrderExtension(Model):
__inherit__ = "Order"
_selection_add = {
"status": ["Cancelled", "On Hold"], # Append these choices
}

Choices accumulate across the inheritance chain, and duplicates are ignored. A contributed choice must fit the base field’s declared max_length — that width sizes the column, so it cannot grow with whatever modules happen to be installed.

There is no way to remove a choice from another module’s field. Narrowing strands every row already holding the value: the field stops offering it while the data keeps it, and the next write of that field fails validation with no legal value the user can pick instead. Widening can never do that, which is why only widening is offered. To retire a choice, remove it where the field is declared, or contribute a replacement and migrate the existing rows with a data entry in your schema-change ledger.

Declaring what happens when your choice goes away

Section titled “Declaring what happens when your choice goes away”

A choice you contribute disappears the day your module is uninstalled — and the rows holding it are still there. Declare the fallback beside the addition:

class OrderExtension(Model):
__inherit__ = "Order"
_selection_add = {"status": ["On Hold"]}
_selection_ondelete = {"status": {"On Hold": "clear"}}

Two policies:

  • clear (the default, applied when you declare nothing) — the value is set to blank. Correct for an optional field, where blank is a representable state meaning “unset”.
  • A replacement choice — name any value the field still offers once yours is gone ({"On Hold": "Draft"}). Rows holding your choice are moved to it.

A required Selection cannot fall back to clear, because blank fails validation just as loudly as the stale value did. Contributing a choice to a required field without naming a replacement is refused when the registry is composed.

The policy is stored on the field registry when your module is installed, so it still applies after your module is gone. The framework reconciles the stored rows whenever a field’s choice set shrinks — whether from an uninstall or from an edit to the base declaration — so you do not write a migration for it yourself.

Use @Model.validate to add custom validation logic that runs before every write:

class Order(Model):
_verbose_name = "Order"
quantity = Integer()
price = Monetary()
@Model.validate("quantity", "price")
async def check_positive_values(self):
"""Raises error if validation fails"""
if self.quantity < 0:
raise UserError("Quantity must be positive")
if self.price < 0:
raise UserError("Price must be positive")

A constraint runs whenever one of its declared fields is written — on create(), on instance.save(), and on every update() path (instance.update(), Model.update([...]), and QuerySet.update()). The constraint sees the prospective post-write value: on an update the new value is applied in memory before the method runs, exactly as it is on save. A constraint whose fields aren’t touched by a given write does not run, so updating an unrelated field is unaffected.

:::tip When to use @Model.validate vs _unique_together

  • _unique_together: Database-level enforcement, fast, race-condition safe
  • @Model.validate: Application-level, for complex conditional logic :::

Inherit fields from a base model:

class BaseModel(Model):
created_at = Datetime(default=lambda: datetime.now())
updated_at = Datetime()
active = Boolean(default=True)
class Product(BaseModel):
name = Char(max_length=255)
# Inherits created_at, updated_at, active

Extend existing models without modifying them:

class ProductExtension(Model):
__inherit__ = "Product"
# Add new fields to Product
weight = Float(default=0.0)
dimensions = Char(max_length=100)
class Invoice(Model):
status = Selection(choices=["Draft", "Sent", "Paid"], default="Draft")
amount = Monetary()
async def send_invoice(self):
"""Send invoice to customer."""
if self.status != "Draft":
raise UserError("Only draft invoices can be sent")
# Send email logic...
self.status = "Sent"
await self.save()
return {"message": "Invoice sent successfully"}

Instance methods are callable via API:

POST /api/invoice/123/send_invoice
class Invoice(Model):
async def get_overdue(cls):
"""Get all overdue invoices."""
from datetime import datetime
return await cls.filter(
status="Sent",
due_date__lt=datetime.now()
).all()

Override create, update, or delete to add custom logic. No decorators needed — the metaclass auto-wraps these three lifecycle methods. Use record._ctx to pass context between method calls without changing signatures.

class Attachment(Model):
_verbose_name = "Attachment"
filepath = Char(max_length=255)
async def delete(cls, records):
# Custom logic before deletion
for record in records:
if record.filepath and os.path.exists(record.filepath):
os.remove(record.filepath)
# Always call parent
return await super().delete(records)
class Meeting(Model):
_verbose_name = "Meeting"
async def create(cls, records):
# records is ALWAYS a list (normalized by metaclass)
# Context is available on each record via record._ctx
skip_sync = records[0]._ctx and records[0]._ctx.get("from_external_sync")
result = await super().create(records)
if not skip_sync:
for instance in result:
await instance.sync_to_calendar()
return result
async def update(cls, records, **vals):
result = await super().update(records, **vals)
for record in records:
if (record._ctx or {}).get("from_external_sync"):
continue
await record.sync_to_calendar()
return result

Passing context to CRUD methods:

# Use with_ctx() for chainable context (preferred)
await record.with_ctx(from_external_sync=True).save()
await record.with_ctx(skip_cascade=True).delete()
# Or set _ctx directly on instance
record._ctx = {**(record._ctx or {}), "from_external_sync": True}
await record.save()

Key points:

  • Use with_ctx() for clean, chainable context: record.with_ctx(key=value).method()
  • Override methods use simple signatures: delete(self), update(self, **kwargs), create(cls, **kwargs)
  • Access context directly via record._ctx (a dict, or None) — e.g. (record._ctx or {}).get(key)
  • Always call super() to maintain inheritance chain
  • Context flows through self._ctx automatically
  • with_ctx() merges with existing context (doesn’t replace)

Writing values in create: fill a gap, don’t overwrite an answer

Section titled “Writing values in create: fill a gap, don’t overwrite an answer”

The records list your override receives holds what the caller asked for. Anything you assign into it replaces a value they may have set deliberately — the API accepted their argument, your override dropped it, and nothing anywhere reports that. So decide which of three things you mean:

A default — supply the value only when the caller left it out. setdefault says exactly that in one token:

async def create(cls, records):
for record in records:
record.setdefault("channel", "Web")
return await super().create(records)

setdefault evaluates its argument either way, so use the guard form when the default is expensive or has to be awaited:

for record in records:
if not record.get("pricelist"):
record["pricelist"] = (await cls.get_default_pricelist()).id

A derived value — the field is yours and the caller has no say in it: a sequence number, a checksum computed from the payload they sent, a state your workflow owns. Overwrite it, leave a comment saying why their value doesn’t count, and mark the field readonly in the view so nobody is invited to set it in the first place.

A rejection — what they sent isn’t allowed here. raise ValidationError(...). Quietly substituting something else is the option to avoid: it leaves them holding a record that disagrees with what they submitted.

The distinction bites hardest on relational fields, because a link is an identity. A User.create override that assigned every new user a freshly created Contact unconditionally meant that callers passing an existing contact — “give this customer a login” — got a user attached to an empty duplicate. The customer’s orders and invoices stayed on one record while their login pointed at another, so every portal record rule scoping on contact__user matched nothing and their document lists came back empty, with no error at any point.

For complex default logic that spans multiple fields or depends on configuration settings, define a _default_get classmethod. This runs before field-level defaults, allowing field defaults to fill in any remaining gaps.

from fullfinity.engine.context import env_ctx
class CrmLead(Model):
_verbose_name = "Lead"
user = ManyToOne("User", related_name="leads")
team = ManyToOne("SalesTeam", related_name="leads")
async def _default_get(cls, context=None):
"""
Handle auto-assignment when crm_auto_assign is enabled.
Works for both UI form creation and API creation.
"""
defaults = await super()._default_get(context)
# Check if auto-assignment is enabled
env = env_ctx.get()
company_ids = env.active_company_ids
company_id = company_ids[0] if company_ids else None
CompanyConfig = env("CompanyConfig")
auto_assign = await CompanyConfig.get_value("crm_auto_assign", company_id)
if auto_assign and not defaults.get("user"):
SalesTeam = env("SalesTeam")
team = await SalesTeam.filter().first()
if team:
next_user = await team.get_next_member_round_robin()
if next_user:
defaults["user"] = next_user.id
defaults["team"] = team.id
return defaults

Key points:

  • Returns a dict of {field_name: value} pairs
  • Runs before field-level defaults (so field defaults fill gaps)
  • Works for both UI and API/programmatic record creation
  • Call super()._default_get(context) when extending inherited models
  • The context parameter receives any context passed during creation

:::tip When to use _default_get vs field defaults

  • Field defaults (default=...): Simple values or single-field logic
  • _default_get: Complex logic involving multiple fields, configuration lookups, or conditional defaults :::

There are three display name concepts:

A class attribute defining the human-readable model name (e.g., “Lead”, “Sales Order”). This is required for all models and used in UI labels, breadcrumbs, and system messages.

class CrmLead(Model):
_verbose_name = "Lead" # Required class attribute
name = Char(max_length=255)

A class attribute specifying which field to use for record display names and name_search(). Defaults to "name". Override this for models that use a different field as their primary identifier.

Valid field types for _name_field:

  • Char, Text, Selection - Scalar fields (value used directly)
  • ManyToOne - Relation fields (uses related record’s display name)
class ProductVariant(Model):
_verbose_name = "Product"
_name_field = "variant_name" # Use variant_name instead of name
variant_name = Char(max_length=255, description="Variant Name")
# No 'name' field needed
class JournalEntry(Model):
_verbose_name = "Journal Entry"
_name_field = "number" # Search/display by number field
number = Char(max_length=50, description="Number")

ManyToOne as _name_field:

When _name_field is a ManyToOne field, the record’s display name comes from the related record:

class SaleOrderLine(Model):
_verbose_name = "Sale Order Line"
_name_field = "product" # Display name from product
product = ManyToOne("Product", related_name="order_lines", on_delete="RESTRICT")
quantity = Float(default=1)

In this example, a SaleOrderLine’s display name will be the product’s name (e.g., “Widget X”).

:::warning ManyToOne Depth Limit If _name_field is a ManyToOne, the related model’s _name_field must be a scalar field (Char, Text, Selection) - not another ManyToOne. This prevents circular references and performance issues. :::

Validation: At startup, Fullfinity validates that:

  1. The _name_field exists on the model
  2. It’s one of: Char, Text, Selection, or ManyToOne
  3. If ManyToOne, the related model’s _name_field is NOT also a ManyToOne

An optional method to customize how individual records are displayed in dropdowns and references.

Default behavior (if not overridden):

  • If _name_field is a scalar field → uses that field’s value
  • If _name_field is a ManyToOne → fetches related record and uses its display_name
  • Falls back to record ID if the field is empty/null

self is the whole recordset, so iterate and assign every record — the same rule as any calculated field. Assigning on the set itself (self.display_name = ...) is refused, because it would write only the first record. Return nothing: the framework supplies the method’s return value, so an implementation only has to populate display_name.

class Contact(Model):
_verbose_name = "Contact" # Model name
first_name = Char(max_length=100)
last_name = Char(max_length=100)
async def get_display_name(self):
# Customize individual record display — one assignment per record
for record in self:
record.display_name = f"{record.first_name} {record.last_name}"

If not overridden, records display using the _name_field (defaults to name) by default.

Declaring what a computed display name reads (_display_name_fields)

Section titled “Declaring what a computed display name reads (_display_name_fields)”

A record is often fetched with a narrow field list — a list view loads only the columns it shows. _name_field is always included, but anything else your get_display_name() reads is not, and reading a field that wasn’t loaded gives you nothing to format. That failure lands in the record query, so the whole screen fails to load rather than one label rendering oddly.

List those fields in _display_name_fields and every fetch will include them:

class Sequence(Model):
_name_field = "name"
# The display name previews the next number — "INV-00042" — so a fetch that renders a
# Sequence needs the padding width and counter, however few columns it asked for.
_display_name_fields = ("prefix", "suffix", "size", "next_number", "reset_frequency")
async def get_display_name(self):
for record in self:
number = str(record.next_number).zfill(record.size)
record.display_name = f"{record.prefix}{number}{record.suffix}"

Scalars are selected and relations are prefetched, exactly as _name_field is. You only need this when get_display_name() is overridden — the default implementation reads nothing beyond _name_field.

Customize search behavior. Overrides build a Q with their search fields, combine with filters (caller restrictions), and omit term in the super() call so the base skips its default _name_field search:

class Product(Model):
name = Char(max_length=255)
sku = Char(max_length=50)
async def name_search(cls, term=None, limit=None, filters=None, operator='icontains'):
"""Search by name or SKU."""
if term:
search_q = Q(name__icontains=term) | Q(sku__icontains=term)
filters = (filters & search_q) if filters else search_q
return await super().name_search(limit=limit, filters=filters)
return await super().name_search(term=term, limit=limit, filters=filters)
from fullfinity.engine.base import *
from datetime import datetime
class Order(Model):
_verbose_name = "Order"
_order_by = ["created_at DESC"]
_collaborate = True
# Basic fields
name = Char(max_length=100, required=True, description="Order Reference")
customer = ManyToOne("Contact", related_name="orders", required=True)
order_date = Date(default=lambda: datetime.now().date())
currency = ManyToOne("Currency", related_name="orders")
# Status
status = Selection(
choices=["Draft", "Confirmed", "Shipped", "Delivered", "Cancelled"],
default="Draft"
)
# Financial (precision from currency.rounding)
subtotal = Monetary(calculate="_compute_totals", store=True, currency_field="currency")
tax_amount = Monetary(calculate="_compute_totals", store=True, currency_field="currency")
total = Monetary(calculate="_compute_totals", store=True, currency_field="currency")
# Calculated fields
@Model.calculate("lines", "lines__quantity", "lines__unit_price")
async def _compute_totals(self):
await self.fetch_related("lines")
self.subtotal = sum(line.quantity * line.unit_price for line in self.lines)
self.tax_amount = self.subtotal * 0.1 # 10% tax
self.total = self.subtotal + self.tax_amount
# Actions
async def confirm_order(self):
if self.status != "Draft":
raise UserError("Only draft orders can be confirmed")
self.status = "Confirmed"
await self.save()
return {"message": f"Order {self.name} confirmed"}
async def cancel_order(self):
if self.status in ["Shipped", "Delivered"]:
raise UserError("Cannot cancel shipped orders")
self.status = "Cancelled"
await self.save()
return {"message": f"Order {self.name} cancelled"}
class OrderLine(Model):
_verbose_name = "Order Line"
order = ManyToOne("Order", related_name="lines", required=True)
product = ManyToOne("Product", related_name="order_lines", required=True)
quantity = Integer(default=1)
# Currency from parent order (related_field pattern)
currency = ManyToOne("Currency", related_field="order__currency", store=False)
unit_price = Monetary(currency_field="currency")
subtotal = Monetary(calculate="_compute_subtotal", store=True, currency_field="currency")
@Model.calculate("quantity", "unit_price")
async def _compute_subtotal(self):
self.subtotal = self.quantity * self.unit_price