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-computed 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

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)

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), and OneToMany fields are rejected — there is no persistent parent id to point a child FK at, so use ManyToMany if a transient model needs a collection.

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 state is “Posted”, “Paid”, or “Cancelled”, most fields become readonly
  • Only note and internal_note can still be edited
  • When state is “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:

OperatorExample
eq (default)Q(state__eq='Posted') or Q(state='Posted')
neqQ(state__neq='Draft')
inQ(state__in=['Posted', 'Cancelled'])
ninQ(state__nin=['Draft'])
isnullQ(user__isnull=True)
gt, gte, lt, lteQ(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 model
class 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 entirely
class 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 exclusions
class InvoiceStrict(Model):
__inherit__ = "Invoice"
_controlled_edits_exclusions_remove = ["internal_note"]
# Result: exclusions = ["note"]
# Condition inherited from parent
# Extension - change the condition
class InvoiceLocked(Model):
__inherit__ = "Invoice"
_controlled_edits_condition = "Q(state__eq='Locked')"
# Exclusions inherited from parent, condition replaced
# Extension - combine operations
class 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 rule
class InvoiceNoRestrictions(Model):
__inherit__ = "Invoice"
_controlled_edits_exclusions_replace = []
_controlled_edits_condition = None
AttributeBehavior
_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)

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

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
}

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 getattr(record, '_ctx', {}).get("from_external_sync"):
continue
await record.sync_to_calendar()
return result
return self

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 = {**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._ctx automatically
  • with_ctx() merges with existing context (doesn’t replace)

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

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")
# 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_price