Field Types
Fullfinity provides a rich set of field types for modeling your data.
Static Typing
Section titled “Static Typing”Fields are generic typing descriptors, so editors and pyright resolve record.<field> to its real Python type instead of Any — both inside the model’s own methods (self.name) and on references elsewhere:
class Product(Model): name = Char(max_length=255) # record.name → str price = Float() # record.price → float active = Boolean() # record.active → bool
async def label(self) -> str: return self.name.upper() # type-checked: self.name is strScalar fields map to their obvious types (Char/Text/Selection → str, Integer → int, Float/Monetary → float, Boolean → bool, Date → date, Datetime → datetime, Binary → bytes); JSON/File and relational fields (ManyToOne, OneToMany, …) resolve to Any (relations are awaited/lazy). This is typing-only — at runtime Model.__getattribute__ serves values from the record’s data, so the descriptors add no behavior. Class-level access (Product.name) still returns the Field instance for introspection.
Run the checker with the project’s pyrightconfig.json.
Common Field Options
Section titled “Common Field Options”All fields support these options:
| Option | Type | Default | Description |
|---|---|---|---|
required | bool | False | NOT NULL constraint |
default | any | None | Default value (can be callable) |
description | str | None | Human-readable label — the field’s caption on forms/lists AND the name used in any error message about the field (see Exceptions). Keep it a short caption; put explanation in hint. Omitted, the framework falls back to a readable form of the field name (due_date → “Due Date”) — never the raw identifier |
hint | str | None | Help text, shown as an ⓘ tooltip beside the field’s label on forms — no view change needed (a hint: in the view’s properties overrides it, exactly as a view label: overrides description) |
readonly | bool | derived → True, else False | Field cannot be edited. Defaults to True for a derived value — a related_field, or a calculate with no setter — since the record doesn’t own the value. Pass it explicitly to override in either direction (see Calculated fields are readonly by default) |
index | bool | False | Create database index |
unique | bool | False | Enforce uniqueness at database level |
clone | bool | True (False for OneToMany/OneToOne) | Include field value when duplicating a record — see Duplicating records |
store | bool | True | Store in database |
calculate | str | None | Calculate method name |
setter | str | None | Method to call when setting a calculated field value |
calculate_elevated | bool | False | Bypass access rights during calculation |
company_scoped | bool | False | Store value per-company in CompanyConfig |
groups | list[str] | [] | Gate read/write of this field by group at the API boundary (see below) |
validators | list | [] | List of validation functions (see Validators) |
prefix_field | str | None | Dynamic field path for prefix display (e.g., "currency__symbol") |
suffix_field | str | None | Dynamic field path for suffix display (e.g., "uom__name") |
Field-Level Group Gating (groups)
Section titled “Field-Level Group Gating (groups)”groups restricts who can read or write a single field to members of the listed
groups:
cost_price = Float(groups=["inventory_manager"]) # only managers see/edit it- Read — the serializer omits the field from API output for users not in any listed group. They never see the value (the column/data is untouched).
- Write — the create/update endpoints raise
AccessErrorif such a user tries to set the field. - Admin bypass — members of
core_adminalways have access. - Boundary-only — this is enforced at the API serializer and create/update endpoints, not in-process. Direct ORM access inside server code is intentionally ungated, so your own model logic can always read/write the field.
Text Fields
Section titled “Text Fields”Single-line text with max length:
name = Char(max_length=255, required=True)email = Char(max_length=200, index=True)sku = Char(max_length=50, description="Stock Keeping Unit")
# With validationphone = Char(max_length=20, min_length=10)code = Char(max_length=10, regex=r"^[A-Z]{2}-\d{4}$")| Property | Type | Description |
|---|---|---|
max_length | int | Maximum character length (required) |
min_length | int | Minimum character length |
regex | str | Regular expression pattern for validation |
Unlimited multi-line text:
description = Text()notes = Text(description="Internal Notes")Translatable content
Section titled “Translatable content”A Char or Text field holding content a customer reads — a product name, a category, page
copy — can carry a value per language:
name = Char(max_length=255, description="Product Name", translate=True)The source value stays in its own column and a per-language overlay is stored beside it, so
reads show the reader’s language and lookups, uniqueness and integrations keep resolving
against the source. Opt-in per field, because name and barcode are the same type. See
Translatable Values.
Encrypted
Section titled “Encrypted”A credential field that is encrypted at rest. The value is plaintext in
Python but stored as Fernet ciphertext in the column (keyed off the configured
SECRET_KEY). Use it for API keys, OAuth tokens, and other secrets.
api_secret = Encrypted(description="API Secret")access_token = Encrypted(description="Access Token")- Reads/writes are transparent — assign and read the field as a normal string;
encryption/decryption happen in
to_db_value/from_db_value. - Backed by
VARCHAR(sized for ciphertext viamax_length, default 2048), so converting an existingCharsecret toEncryptedis a safe in-place column widening — no data migration needed. - Legacy un-encrypted values (stored before conversion) are detected by the
absence of the
enc:v1:prefix and returned unchanged on read, then encrypted on the next write. - Filtering by exact value won’t work (the column holds ciphertext);
isnotnulland similar presence checks do.
:::caution Key rotation makes existing values unreadable
The Fernet key is derived deterministically from SECRET_KEY. Rotating
SECRET_KEY makes all previously-stored ciphertext undecryptable. On a decrypt
failure the field does not raise — it returns the stored token unchanged (the
prefix-stripped ciphertext), so reads never crash and no data is lost, but the
plaintext is gone until you restore the original key. If SECRET_KEY is unset
entirely, reading/writing an Encrypted field raises ConfigurationError. Plan a
re-encryption step if you ever rotate the key.
:::
Numeric Fields
Section titled “Numeric Fields”Integer
Section titled “Integer”Whole numbers:
quantity = Integer(default=1)sequence = Integer(default=10)age = Integer(required=True, min_value=0, max_value=150)| Property | Type | Description |
|---|---|---|
min_value | int | Minimum allowed value |
max_value | int | Maximum allowed value |
Integer stores as a 32-bit column, so it holds values up to about 2.1 billion — the right choice for quantities, counts, sequences, ages, years, and the like.
BigInt
Section titled “BigInt”A 64-bit whole number, for the rare scalar that can exceed Integer’s ~2.1 billion range — a byte count, an externally-assigned identifier, an epoch-nanosecond timestamp, a bitmask:
file_size_bytes = BigInt(default=0)external_ref = BigInt(min_value=0)BigInt behaves exactly like Integer in Python (same validation, same min_value/max_value, arbitrary-precision int values); only the stored column width differs. You do not need BigInt for record ids or relations — a model’s own id and every ManyToOne/OneToOne foreign key are already 64-bit, so they never run out of ids. Reach for BigInt only when a declared scalar field genuinely needs the extra range.
Floating-point numbers with configurable precision:
# Static precision (2 decimal places)weight = Float(precision=2, default=0.0)percentage = Float(precision=4, min_value=0, max_value=100)
# Dynamic precision from field on same modelrate = Float(precision="decimal_places")
# Dynamic precision from related model (one level only)quantity = Float(precision="uom__rounding")
# Nullable: unset stays None instead of reading as 0.0measured_value = Float(default=None)| Property | Type | Description |
|---|---|---|
precision | int/str | Decimal places: static int (default: 2) or field path string |
min_value | float | Minimum allowed value |
max_value | float | Maximum allowed value |
A Float defaults to 0.0, and an unset one reads as 0.0 — so arithmetic on numbers
nobody filled in just works, without a None guard at every call site.
When zero is a real value for your field, that folding is wrong: a measurement not yet
taken, an optional tolerance bound, a rate that may legitimately be unset. 0.0 then means
both “unset” and “measured exactly zero”, and code cannot tell them apart. Declare
Float(default=None) to opt that field into a meaningful NULL — it reads back as None
until something writes a number:
tolerance_max = Float(default=None) # None = no upper bound, 0.0 = bound at zeroreading = Float(default=None) # None = not measured, 0.0 = measured zero
if check.reading is None: ... # nothing recorded yet — do not score itOnly fields that declare it are affected; every other Float keeps reading 0.0.
:::info Default Value
Float fields default to 0.0 if no default is specified.
:::
Monetary
Section titled “Monetary”Currency values with precision from currency field:
# Precision from currency.rounding (recommended)price = Monetary(currency_field="currency")
# Without currency field - defaults to 2 decimal placesdiscount = Monetary(min_value=0, max_value=10000)| Property | Type | Description |
|---|---|---|
currency_field | str | Field name containing the currency (precision from currency.rounding) |
min_value | float | Minimum allowed value |
max_value | float | Maximum allowed value |
:::info Default Value
Monetary fields default to 0.0 if no default is specified.
:::
:::tip Rounding Utilities For precise float/monetary rounding in calculations, use the engine utilities:
from fullfinity.engine.utils import round_float, is_zero, compare
# Round to 2 decimal placesamount = round_float(100.125, digits=2) # 100.13
# Round using currency rounding factoramount = round_float(100.125, rounding=0.01) # 100.13
# Check if effectively zeroif is_zero(0.001, rounding=0.01): # True pass
# Compare floats safelyif compare(a, b, rounding=0.01) == 0: # Equal after rounding pass:::
:::tip Currency on Line Items For line items where currency is on the parent model, use a related field:
class OrderLine(Model): order = ManyToOne("Order") currency = ManyToOne("Currency", related_field="order__currency", store=False) unit_price = Monetary(currency_field="currency"):::
Precision Field Paths (Float only)
Section titled “Precision Field Paths (Float only)”Float fields support dynamic precision using field path strings:
| Syntax | Description | Example |
|---|---|---|
precision=4 | Static integer | Fixed 4 decimal places |
precision="rounding" | Same model field | Uses self.rounding |
precision="uom__rounding" | Related model field | Uses self.uom.rounding |
:::warning No Deep Nesting
Deep paths like company__currency__rounding are not allowed. Only one level of relation traversal is supported.
:::
Prefix and Suffix Fields
Section titled “Prefix and Suffix Fields”Any field can display a dynamic prefix or suffix by defining field paths on the model:
# Display UOM name as suffix (e.g., "10 kg")qty_on_hand = Float( precision=3, suffix_field="uom__name")
# Display currency symbol as prefix (e.g., "$ 100")amount = Float( prefix_field="currency__symbol")| Property | Type | Description |
|---|---|---|
prefix_field | str | Field path for dynamic prefix (e.g., "uom__symbol", "currency__symbol") |
suffix_field | str | Field path for dynamic suffix (e.g., "uom__name") |
The referenced field is automatically extracted and serialized with the record. The frontend displays the value from the resolved field path.
:::tip Model vs View
- Model: Use
prefix_field/suffix_fieldfor dynamic values from related fields - View: Use
prefix/suffixfor static strings only (e.g.,"%","kg")
Model’s dynamic fields take priority over view’s static values. :::
:::caution Where the view already shows the unit, turn the suffix off
suffix_field is declared once on the model so the number is never a bare “5” —
wherever it appears, the unit rides along. A view that gives that unit a place of its
own — a UoM column beside the quantity column, a UoM picker beside the input —
would then print it twice: 5.00 Units | Units. Opt that field out in the view:
- type: field name: quantity properties: widget: NumberInput label: Qty suffix_field: false # the UoM column beside it states the unit- type: field name: uom properties: widget: DataCombo label: UoMOnly false is meaningful — a view cannot point the suffix at a different path. Leave
it off wherever the unit has no other home on screen (a report, a popover, a form that
shows the quantity but not its unit).
:::
:::info Monetary Fields
For currency values, use Monetary with currency_field instead. It automatically handles symbol positioning (before/after) based on currency settings.
:::
Boolean
Section titled “Boolean”True/False values:
active = Boolean(default=True)is_published = Boolean(default=False)verified = Boolean() # same as Boolean(default=False)A boolean is never null. Declared without a default, it defaults to False, and its
column is created NOT NULL — a boolean has exactly two values, so there is no third state
for a column to hold. You do not need required=True to get that; required is the
user-facing “you must answer this”, which a boolean always satisfies, and setting it only
puts a required marker on the field in the form.
This matters when you add a boolean to a model that already has rows. The column is
added nullable, backfilled with the default, and then constrained — so existing records get
False rather than an absent value, and no migration hook is needed. If you want existing
rows to become True instead, write a data migration for it (see
Migrations); the automatic backfill only applies the declared
default.
Reading a boolean therefore always gives you True or False, and a filter can rely on it:
Q(is_published=False) matches every unpublished row, with no need to also test for a
missing value.
Date and Time
Section titled “Date and Time”Date without time:
birth_date = Date()due_date = Date(description="Due Date")Datetime
Section titled “Datetime”Date with time (stored in UTC):
created_at = Datetime(default=lambda: datetime.now())scheduled_at = Datetime()Selection
Section titled “Selection”Predefined choices:
status = Selection( choices=["Draft", "Confirmed", "Done", "Cancelled"], default="Draft", max_length=200)
priority = Selection( choices=["Low", "Medium", "High", "Urgent"], default="Medium")
type = Selection( choices=["Individual", "Company"], required=True)| Property | Type | Description |
|---|---|---|
choices | list | List of allowed string values (required) |
max_length | int | Maximum length for values (default: 200) |
:::caution Choices are labels — write them as readable text
A Selection has no separate value/label pair. The stored string is what the dropdown
shows, what your Python dispatches on, what seed YAML carries, and what the frontend
compares — one spelling, everywhere. So write the choice as the caption a user should read:
mode = Selection(choices=["Balance Sheet", "Profit & Loss"]) # correctmode = Selection(choices=["balance_sheet", "profit_loss"]) # renders as-is, to the userDispatch on that readable value directly (if report.mode == "Balance Sheet"). Never map a
stored token to a display label at some boundary — that is two spellings of one concept to
keep in step forever.
If you need a stable internal handle (a routing key, an external system’s identifier),
that isn’t a Selection: put it in a Char code/identifier field, which is never
user-displayed and where snake_case is correct.
This is enforced at build time — fullfinity-server check --only selection fails on a
choice shaped like an identifier (all-lowercase, with or without underscores). Genuine
external standards keep their own spelling (HTTP 301, HTML dir ltr/rtl, unit
symbols like km) and are exempt.
:::
:::tip Extending Choices
Extensions can add Selection choices via _selection_add, declaring what becomes of the rows if the choice later goes away with _selection_ondelete. Choices can only be added, never removed from another module’s field. See Selection Field Inheritance.
:::
Structured Data
Section titled “Structured Data”Store JSON/dict data:
metadata = JSON(default=dict)settings = JSON(description="User Settings")extra_data = JSON()Usage:
record.metadata = {"key": "value", "nested": {"a": 1}}await record.save()Both in-place mutation and reassignment persist — the framework snapshots a JSON column’s loaded value so a later change is detected either way:
record.tags.append("new") # in-place mutation — detected and savedrecord.settings["theme"] = "dark" # in-place key set — detected and savedawait record.save()
record.metadata = {**record.metadata, "k": "v"} # reassignment — also fineawait record.save()Binary Data
Section titled “Binary Data”Binary
Section titled “Binary”Raw binary data:
data = Binary(description="Binary Data")File attachments (stored as attachment ID):
logo = File(description="Company Logo")document = File(description="Attached Document")public_brochure = File(public=True, description="Brochure") # publicly accessibleUsage:
# Upload: base64 string with format: "filename;content_type;base64,data"record.logo = "logo.png;image/png;base64,iVBORw0KGgo..."await record.save()| Option | Type | Default | Description |
|---|---|---|---|
public | bool | False | When True, the underlying attachment is created with is_public=True, so it is reachable without an authenticated session (e.g. logos, website assets). Leave False for anything access-controlled. |
track | bool | False | Record changes to this file in the change log. |
Relational Fields
Section titled “Relational Fields”ManyToOne
Section titled “ManyToOne”Foreign key (many records point to one):
customer = ManyToOne( "Contact", # Related model name related_name="orders", # Reverse relation name on_delete="CASCADE", # CASCADE, SET NULL, RESTRICT required=True)
category = ManyToOne( "Category", related_name="products", on_delete="SET NULL")OneToMany
Section titled “OneToMany”Reverse of ManyToOne (automatically created):
# Defined on the "one" side, points to "many"orders = OneToMany("Order", related_name="customer")Usually you don’t define this explicitly - it’s created from related_name.
OneToOne
Section titled “OneToOne”One-to-one relationship (foreign key with UNIQUE constraint):
profile = OneToOne( "UserProfile", related_name="user", # Reverse returns single object, not list on_delete="CASCADE")
settings = OneToOne( "UserSettings", related_name="user", on_delete="SET NULL")Similar to ManyToOne but:
- Has a UNIQUE constraint on the foreign key column
- The reverse accessor returns a single object instead of a list
ManyToMany
Section titled “ManyToMany”Many-to-many relationship:
tags = ManyToMany( "Tag", related_name="products", through="fkproducttag" # Through table name)
groups = ManyToMany( "Group", related_name="users", through="fkusergroup"):::note Relational defaults
ManyToMany and OneToMany also support default=, but the value must be
written in command notation ([["link", id]], [["create", {vals}]]), not a bare
list of instances. See Relational field defaults.
:::
Duplicating Records (clone)
Section titled “Duplicating Records (clone)”Duplicating a record (the Duplicate action, or POST /api/clone/{model}) copies its
scalar fields and its ManyToOne/ManyToMany links. For a OneToMany, “copying” means
creating a new child record for every child of the original — so whether a relation is
cloneable decides whether duplicating a document also manufactures new documents.
Because of that, OneToMany and OneToOne are not cloneable by default. Copying child
records is opt-in:
class SaleOrder(Model): # Composition — lines are owned parts of the order, so they come along. lines = OneToMany("SaleOrderLine", related_name="order", clone=True)
# Everything else that points AT a sale order (invoices, subscriptions, # deliveries) is an independent document. It takes the default and is not copied.Use clone=True only for composition: children with no existence or workflow of their
own — document lines, config sub-rows, template content. Records that merely reference
the parent — anything with its own state machine, sequence number, or accounting/inventory
effect — must not be cloneable, or a duplicate silently creates real business documents.
This applies to the reverse relations the framework creates automatically. Declaring
ManyToOne("SaleOrder", related_name="subscriptions") on your model gives SaleOrder a
subscriptions accessor that the order’s author never opted into — those are never cloned.
If you need a reverse relation to be cloneable, declare the OneToMany explicitly on the
parent and set clone=True.
Three further points:
OneToOneis not cloneable because the underlying UNIQUE constraint means copying the foreign key either fails or takes the related record away from the original.- Cloning follows composition all the way down. Because
clone=Truemarks ownership and ownership is transitive, a clone recurses through cloneable relations: duplicating a survey copies its questions and each question’s answer choices. Relations left at the default are never descended into, so the copy stops at the edge of what the record owns. - References inside the copy are re-pointed at the copy. If a copied child references another record that the same clone also copied — a duplicated event’s track pointing at one of its rooms — the copy is updated to reference the copied room, not the original’s. References that point outside the clone (product, currency, company) are left alone, which is correct: those are shared, not owned.
Scalar fields take clone=False too — use it for values that must not survive a copy, such
as a sequence number, a posting state, or a captured signature.
active is an ordinary scalar, so it is copied like any other. Duplicating an archived
record therefore produces an archived copy — created, but absent from every list until
someone unarchives it. If a duplicate of a retired record should be usable straight away
(usually the case: the user is duplicating it precisely to make a live one), declare
active = Boolean(default=True, clone=False) on that model.
unique=True scalars need no annotation: copying a unique value can never succeed, so a
clone never copies one verbatim. An optional unique field is left empty on the copy; a
required one (a slug, a code) takes a derived free value — summer-fest becomes
summer-fest-copy, and Summer Fest becomes Summer Fest (copy).
Calculated Fields
Section titled “Calculated Fields”Fields calculated from other fields:
total = Monetary( calculate="_compute_total", store=True # Store result in database)
display_name = Char( max_length=255, calculate="get_display_name", store=False # Calculate on-the-fly)See Calculated Fields for details.
Related Fields
Section titled “Related Fields”Auto-compute from related model:
company_name = Char( max_length=255, related_field="company__name", store=False)
country_code = Char( max_length=10, related_field="address__country__code", store=False)The read-through works for every field type — scalar (Char, Monetary,
Selection, Boolean, Date, …) and relational (ManyToOne, OneToOne,
OneToMany, ManyToMany). A relational leaf resolves to the related record (or
record list); a path through a collection resolves against its first row.
Related fields on transient models (wizards)
Section titled “Related fields on transient models (wizards)”A related field resolves the same way on a transient (wizard)
record as on a persisted one — as long as the relation it reads through is set. A wizard
takes its initial values from _default_get, so declaring the relation (typically via a
default_<relation> in the opening action’s ctx) is all that’s needed:
class GlobalDiscountWizard(Model): _transient = True
order = ManyToOne("SaleOrder", related_name="discount_wizards", on_delete="CASCADE") # Read straight through the order relation — resolved when the wizard opens. currency = ManyToOne("Currency", related_name="_w", related_field="order__currency", store=False) order_subtotal = Monetary(related_field="order__subtotal", store=False, readonly=True)Opened with {"type": "wizard", "model": "GlobalDiscountWizard", "ctx": {"default_order": order.id}}, the wizard form shows the live subtotal and
currency without any _default_get override — the relation is set, so the read-through
resolves. (There is no need to hand-copy the values in _default_get; do that only when
the value isn’t a plain read-through of an already-set relation.)
Field Examples by Use Case
Section titled “Field Examples by Use Case”Contact Model
Section titled “Contact Model”class Contact(Model): # Identity name = Char(max_length=255, required=True) email = Char(max_length=255, index=True) phone = Char(max_length=50)
# Type type = Selection(choices=["Individual", "Company"], default="Individual") is_customer = Boolean(default=False) is_supplier = Boolean(default=False)
# Relations company = ManyToOne("Contact", related_name="contacts") tags = ManyToMany("ContactTag", related_name="contacts", through="fkcontacttag")
# Media image = File(description="Photo")
# Timestamps created_at = Datetime(default=lambda: datetime.now())Invoice Model
Section titled “Invoice Model”class Invoice(Model): # Reference name = Char(max_length=100, required=True) reference = Char(max_length=100)
# Dates invoice_date = Date(default=lambda: datetime.now().date()) due_date = Date()
# Relations customer = ManyToOne("Contact", required=True) currency = ManyToOne("Currency")
# Status status = Selection( choices=["Draft", "Sent", "Paid", "Cancelled"], default="Draft" )
# Financials subtotal = Monetary(calculate="_compute_totals", store=True) tax_total = Monetary(calculate="_compute_totals", store=True) total = Monetary(calculate="_compute_totals", store=True)
# Notes notes = Text() internal_notes = Text(description="Internal Notes")Company-Scoped Fields
Section titled “Company-Scoped Fields”Fields with company_scoped=True store their values in the CompanyConfig table instead of the model’s own table. This enables per-company configuration for the same record.
class ProductCategory(Model): name = Char(max_length=255)
# Different income account per company income_account = ManyToOne( "Account", description="Income Account", company_scoped=True # Stored in CompanyConfig )
# Different expense account per company expense_account = ManyToOne( "Account", description="Expense Account", company_scoped=True )Key Behaviors
Section titled “Key Behaviors”- Storage: Values are stored in
CompanyConfigwith key format:Model.record_id.field_name - Transient Models: For transient models (wizards/configuration), key is just
field_name - Hydration: ManyToOne/OneToOne/ManyToMany fields return fully hydrated records, not just IDs
- Orphan Cleanup: Orphaned references are automatically cleaned up on load
- Migrations: Fields are excluded from database migrations (
store=Falseimplied)
Supported Field Types
Section titled “Supported Field Types”- Scalar types (Char, Text, Boolean, Integer, Float, Date, etc.)
- ManyToOne / OneToOne (stores single ID)
- ManyToMany (stores list of IDs)
- OneToMany (stores its child rows — see below)
OneToMany: value-object rows
Section titled “OneToMany: value-object rows”A company_scoped OneToMany persists a list of value-object rows — its children are
transient records with no table of their own (the same in-memory O2M mechanism the
wizards use). Each child is stored as a JSON dict of its own fields
(scalars JSON-safe, ManyToOne as an id, ManyToMany as an id-list, nested OneToMany
recursively); framework-managed fields (id, identifier, audit columns) and the child’s
back-reference to the parent are not stored. On load the rows are rebuilt into transient
children — the exact shape a form grid (widget: List) renders and edits inline.
Use this for owned config sub-rows that have no independent life. If the “children” are
real records that live in their own table (stages, rules, templates), do not model them
as a company_scoped OneToMany — give that model a company ManyToOne and surface it with a
settingsLink or a company-scoped List. See Configuration Forms.
Limitations
Section titled “Limitations”- Cannot filter on company_scoped fields:
filter(income_account=x)won’t work - Cannot sort on company_scoped fields
- No database-level uniqueness constraints
Example: Configuration Model
Section titled “Example: Configuration Model”class ConfigurationCRM(Model): __inherit__ = "Configuration"
crm_default_stage = ManyToOne( "CrmStage", description="Default Lead Stage", company_scoped=True )
crm_auto_assign = Boolean( default=False, description="Auto-assign Leads", company_scoped=True )Validators
Section titled “Validators”Fields support custom validators for advanced validation logic. Pass a list of validators to the validators parameter.
Built-in Validators
Section titled “Built-in Validators”from fullfinity.engine.fields import ( EmailValidator, URLValidator, RegexValidator, MinValueValidator, MaxValueValidator, MinLengthValidator, MaxLengthValidator,)
class Contact(Model): email = Char( max_length=255, validators=[EmailValidator("Please enter a valid email")] )
website = Char( max_length=255, validators=[URLValidator("Please enter a valid URL")] )
code = Char( max_length=20, validators=[ RegexValidator(r"^[A-Z]{3}-\d{4}$", "Code must be XXX-0000 format") ] )
age = Integer( validators=[ MinValueValidator(0, "Age cannot be negative"), MaxValueValidator(150, "Age cannot exceed 150") ] )
username = Char( max_length=50, validators=[ MinLengthValidator(3, "Username must be at least 3 characters"), MaxLengthValidator(50, "Username cannot exceed 50 characters") ] )Available Validators
Section titled “Available Validators”| Validator | Description |
|---|---|
EmailValidator(message) | Validates email format |
URLValidator(message) | Validates URL format (http/https) |
RegexValidator(pattern, message) | Validates against regex pattern |
MinValueValidator(min, message) | Validates minimum numeric value |
MaxValueValidator(max, message) | Validates maximum numeric value |
MinLengthValidator(min, message) | Validates minimum string length |
MaxLengthValidator(max, message) | Validates maximum string length |
Custom Validators
Section titled “Custom Validators”Create custom validators by subclassing Validator or using a callable:
from fullfinity.engine.fields import Validator, ValidationError
# Class-based validatorclass PhoneValidator(Validator): def __init__(self, message="Invalid phone number"): self.message = message
def __call__(self, value): if value and not value.replace("-", "").replace(" ", "").isdigit(): raise ValidationError(self.message)
# Function-based validatordef validate_positive(value): if value is not None and value < 0: raise ValidationError("Value must be positive")
class Order(Model): phone = Char(max_length=20, validators=[PhoneValidator()]) quantity = Integer(validators=[validate_positive])Next Steps
Section titled “Next Steps”- Relationships - Working with related data
- Calculated Fields - Automatic calculations
- Querying Data - Fetch and filter records