Defining Models
Models define data structures and business logic in Fullfinity.
Basic Model
Section titled “Basic Model”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)How Models Are Loaded
Section titled “How Models Are Loaded”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. Useget_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.
Schema Migrations
Section titled “Schema Migrations”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 NULLconstraint is then applied. - Required field without a default →
NOT NULLis 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 aWARNINGinstead of aborting. Backfill the column yourself and tighten it toNOT NULLin a follow-up. (Previously this raised a rawNotNullViolationErrorand 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.
M2M junction column naming
Section titled “M2M junction column naming”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) getsnake(owner)_id/snake(target)_idcolumns automatically. - Explicit through models (a
class FkOwnerTarget(Model)you write to add a field to the link) must name their twoManyToOnefields the snake_case of each target model (pos_register, notposregisterorregister) — the column is then<field>_idand matches. - Exception — self-referential junctions (both FKs point to the same model, e.g.
Group↔Group): 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).
Model Options
Section titled “Model Options”Display Name (Required)
Section titled “Display Name (Required)”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).
:::
Table Name
Section titled “Table Name”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)Default Ordering
Section titled “Default Ordering”class Product(Model): _verbose_name = "Product" _order_by = ["name ASC", "id DESC"] name = Char(max_length=255)Collaboration Features
Section titled “Collaboration Features”Enable activity tracking and messaging:
class Lead(Model): _verbose_name = "Lead" _collaborate = True name = Char(max_length=255)Field-Level Change Tracking
Section titled “Field-Level Change Tracking”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 Type | track default |
|---|---|
Char, Integer, Float, Boolean, Date, Datetime, Selection, Monetary | True |
Text, JSON, File, Binary | False |
ManyToOne, OneToOne | True |
OneToMany, ManyToMany | Never 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 trackGlobal Search
Section titled “Global Search”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 searchAPI Reference Visibility
Section titled “API Reference Visibility”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 referenceThe flag is inherited by subclasses. Through-tables and models with no verbose name are omitted automatically.
Archivable (Soft-Delete)
Section titled “Archivable (Soft-Delete)”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
Automatic Active Filtering
Section titled “Automatic Active Filtering”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 productsarchived = await Product.filter(active=False).include_archived().all()
# Explicit active filter bypasses automatic filteringactive_only = await Product.filter(active=True).all() # Same as defaultThe automatic filter is applied when:
- Model has an
activeBoolean field include_archived()is not called- No explicit
activefilter 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 placedawait order.fetch_related("customer")assert order.customer is not None # archived, but still the referenced recordThis 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.
System-Protected Records
Section titled “System-Protected Records”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 deletedHow it works:
- The metaclass auto-detects the
is_systemfield and sets_system_protected = Trueon the model (mirrors_archivable). - A single guard in the base
Model.delete()raisesUserErrorif any target record hasis_system=True. There is no per-modeldelete()override — this is the one enforcement point. is_systemis explicit data: seed YAML setsis_system: trueon 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
activegoverns 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_deletein context —await rec.with_ctx(allow_system_delete=True).delete(). - Module uninstall drops tables directly, and DB-level
ON DELETE CASCADEoperates below Python — both bypass this guard, so dependent cleanup still works.
Transient (No Table)
Section titled “Transient (No Table)”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
_transientfrom their base unless they set it themselves (it defaults toFalse). - Constraints the metaclass enforces: company-scoped fields are forced to
store=False(kept in KV storage). A transient model may have aOneToMany(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 transientOneToManyat a persistent model is rejected (there is no parent row for a child FK to reference). - No
ModelAccessneeded — 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 aModelAccessrule. 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 aModelAccessfor a transient model is a no-op at best and misleading at worst.
Controlled Edits
Section titled “Controlled Edits”_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()| Key | Meaning |
|---|---|
condition | Q-expression; while it holds for a record, the record is frozen. |
exclusions | Fields this rule still allows through. |
message | Shown when a write is refused, and as the tooltip on a locked cell. Say what to do. |
id | Stable handle another module targets to override or disable this rule. Optional — derived from the declaring class when omitted. |
disabled | With 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.
Layering rules
Section titled “Layering rules”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 applyclass 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 outrightclass TimesheetLocalOverride(Model): __inherit__ = "Timesheet" _controlled_edits = [ {"id": "invoiced", "condition": "Q(invoiced=True) & Q(company__eq=5)", "exclusions": ["invoice_line", "hours"]}, ]
# ...or switches it off entirelyclass 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)Unique Constraints
Section titled “Unique Constraints”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 uniqueComposite 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 constraintRemoving 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=TrueComposite Indexes
Section titled “Composite Indexes”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 indexComposite 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 modelclass Product(Model): _composite_indexes = [("name", "category")]
# Extension adds more indexesclass 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.
:::
Selection Field Inheritance
Section titled “Selection Field Inheritance”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.
Validation Methods
Section titled “Validation Methods”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 :::
Model Inheritance
Section titled “Model Inheritance”Python Inheritance
Section titled “Python Inheritance”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, activeExtension with __inherit__
Section titled “Extension with __inherit__”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)Model Methods
Section titled “Model Methods”Instance Methods
Section titled “Instance Methods”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_invoiceClass Methods
Section titled “Class Methods”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()Overriding CRUD Methods
Section titled “Overriding CRUD Methods”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 resultPassing 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 instancerecord._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, orNone) — e.g.(record._ctx or {}).get(key) - Always call
super()to maintain inheritance chain - Context flows through
self._ctxautomatically 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()).idA 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.
Model-Level Defaults (_default_get)
Section titled “Model-Level Defaults (_default_get)”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 defaultsKey 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
contextparameter 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 :::
Display Name
Section titled “Display Name”There are three display name concepts:
Model Display Name (_verbose_name)
Section titled “Model Display Name (_verbose_name)”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)Name Field (_name_field)
Section titled “Name Field (_name_field)”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 neededclass 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:
- The
_name_fieldexists on the model - It’s one of: Char, Text, Selection, or ManyToOne
- If ManyToOne, the related model’s
_name_fieldis NOT also a ManyToOne
Record Display Name (get_display_name())
Section titled “Record Display Name (get_display_name())”An optional method to customize how individual records are displayed in dropdowns and references.
Default behavior (if not overridden):
- If
_name_fieldis a scalar field → uses that field’s value - If
_name_fieldis a ManyToOne → fetches related record and uses itsdisplay_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.
Name Search
Section titled “Name Search”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)Full Example
Section titled “Full Example”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_priceNext Steps
Section titled “Next Steps”- Field Types - All available field types
- Relationships - Working with related data
- Calculated Fields - Automatic calculations