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-computed 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 searchArchivable (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)
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), andOneToManyfields are rejected — there is no persistent parent id to point a child FK at, so useManyToManyif a transient model needs a collection.
Controlled Edits
Section titled “Controlled Edits”Restrict field editing based on record state using _controlled_edits. When the condition matches, all fields become readonly except those listed in exclusions.
class Invoice(Model): _verbose_name = "Invoice" _controlled_edits = { "exclusions": ["note", "internal_note"], "condition": "Q(state__in=['Posted', 'Paid', 'Cancelled'])" }
state = Selection(choices=["Draft", "Posted", "Paid", "Cancelled"], default="Draft") amount = Monetary() note = Text() internal_note = Text()In this example:
- When
stateis “Posted”, “Paid”, or “Cancelled”, most fields become readonly - Only
noteandinternal_notecan still be edited - When
stateis “Draft”, all fields are editable
Enforcement:
- Backend: Attempting to update restricted fields via API raises a
UserError - Frontend: Restricted fields appear as readonly (grayed out, non-editable)
Condition syntax:
Uses Q-expression syntax with these operators:
| Operator | Example |
|---|---|
eq (default) | Q(state__eq='Posted') or Q(state='Posted') |
neq | Q(state__neq='Draft') |
in | Q(state__in=['Posted', 'Cancelled']) |
nin | Q(state__nin=['Draft']) |
isnull | Q(user__isnull=True) |
gt, gte, lt, lte | Q(amount__gt=0) |
Combine conditions with & (AND) and | (OR):
"Q(state='Posted') & Q(amount__gt=0)"1-level deep field access:
"Q(stage__is_done=True)" # Accesses stage.is_done:::warning Nesting Limit
Only 1 level of relation traversal is supported (e.g., stage__is_done). Deeper nesting like contact__company__state is not allowed.
:::
Inheritance behavior:
When extending models with __inherit__, controlled edits support add/replace/remove operations using separate attributes:
# Base modelclass Invoice(Model): _controlled_edits = { "exclusions": ["note", "internal_note"], "condition": "Q(state__in=['Posted', 'Cancelled'])" }
# Extension - add to parent's exclusions (default)class InvoiceCRM(Model): __inherit__ = "Invoice" _controlled_edits_exclusions = ["sales_rep"] # Added to parent's list # Result: exclusions = ["note", "internal_note", "sales_rep"] # Condition inherited from parent
# Extension - replace parent's exclusions entirelyclass InvoiceCustom(Model): __inherit__ = "Invoice" _controlled_edits_exclusions_replace = ["only_this"] # Result: exclusions = ["only_this"] # Condition inherited from parent
# Extension - remove specific fields from parent's exclusionsclass InvoiceStrict(Model): __inherit__ = "Invoice" _controlled_edits_exclusions_remove = ["internal_note"] # Result: exclusions = ["note"] # Condition inherited from parent
# Extension - change the conditionclass InvoiceLocked(Model): __inherit__ = "Invoice" _controlled_edits_condition = "Q(state__eq='Locked')" # Exclusions inherited from parent, condition replaced
# Extension - combine operationsclass InvoiceExtended(Model): __inherit__ = "Invoice" _controlled_edits_exclusions = ["new_field"] _controlled_edits_exclusions_remove = ["internal_note"] _controlled_edits_condition = "Q(state__eq='Locked')" # Result: exclusions = ["note", "new_field"], condition = "Q(state__eq='Locked')"
# Extension - disable the ruleclass InvoiceNoRestrictions(Model): __inherit__ = "Invoice" _controlled_edits_exclusions_replace = [] _controlled_edits_condition = None| Attribute | Behavior |
|---|---|
_controlled_edits_exclusions = [...] | Add fields to parent’s exclusions (default) |
_controlled_edits_exclusions_replace = [...] | Replace parent’s exclusions entirely |
_controlled_edits_exclusions_remove = [...] | Remove specific fields from parent’s exclusions |
_controlled_edits_condition = "Q(...)" | Replace parent’s condition |
:::note Legacy syntax
The dict-based _controlled_edits = {"exclusions": [...], "condition": "..."} syntax is still supported for backwards compatibility and will merge exclusions with the parent by default.
:::
Bypassing in code:
For programmatic updates that need to bypass restrictions:
await record.with_ctx(skip_controlled_edits=True).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”Extensions can add or remove choices from Selection fields using _selection_add and _selection_remove class attributes.
Adding choices:
class OrderExtension(Model): __inherit__ = "Order"
_selection_add = { "status": ["Cancelled", "On Hold"], # Append these choices }Removing choices:
class OrderExtension(Model): __inherit__ = "Order"
_selection_remove = { "status": ["Draft"], # Remove this choice }Combined add and remove:
class OrderExtension(Model): __inherit__ = "Order"
# Remove is applied first, then add _selection_remove = { "status": ["pending"], # Remove old status } _selection_add = { "status": ["awaiting_approval"], # Add replacement }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 getattr(record, '_ctx', {}).get("from_external_sync"): continue await record.sync_to_calendar()
return result return selfPassing 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 = {**getattr(record, '_ctx', {}), "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 via
getattr(self, '_ctx', {})- no parameters needed - Always call
super()to maintain inheritance chain - Context flows through
self._ctxautomatically with_ctx()merges with existing context (doesn’t replace)
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
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 self.display_name = f"{self.first_name} {self.last_name}"If not overridden, records display using the _name_field (defaults to name) by default.
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")
# Computed 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
- Computed Fields - Automatic calculations