Skip to content

Field Types

Fullfinity provides a rich set of field types for modeling your data.

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 str

Scalar fields map to their obvious types (Char/Text/Selectionstr, Integerint, Float/Monetaryfloat, Booleanbool, Datedate, Datetimedatetime, Binarybytes); 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.

All fields support these options:

OptionTypeDefaultDescription
requiredboolFalseNOT NULL constraint
defaultanyNoneDefault value (can be callable)
descriptionstrNoneHuman-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
hintstrNoneHelp 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)
readonlyboolderived → True, else FalseField 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)
indexboolFalseCreate database index
uniqueboolFalseEnforce uniqueness at database level
cloneboolTrue (False for OneToMany/OneToOne)Include field value when duplicating a record — see Duplicating records
storeboolTrueStore in database
calculatestrNoneCalculate method name
setterstrNoneMethod to call when setting a calculated field value
calculate_elevatedboolFalseBypass access rights during calculation
company_scopedboolFalseStore value per-company in CompanyConfig
groupslist[str][]Gate read/write of this field by group at the API boundary (see below)
validatorslist[]List of validation functions (see Validators)
prefix_fieldstrNoneDynamic field path for prefix display (e.g., "currency__symbol")
suffix_fieldstrNoneDynamic field path for suffix display (e.g., "uom__name")

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 AccessError if such a user tries to set the field.
  • Admin bypass — members of core_admin always 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.

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 validation
phone = Char(max_length=20, min_length=10)
code = Char(max_length=10, regex=r"^[A-Z]{2}-\d{4}$")
PropertyTypeDescription
max_lengthintMaximum character length (required)
min_lengthintMinimum character length
regexstrRegular expression pattern for validation

Unlimited multi-line text:

description = Text()
notes = Text(description="Internal Notes")

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.

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 via max_length, default 2048), so converting an existing Char secret to Encrypted is 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); isnotnull and 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. :::

Whole numbers:

quantity = Integer(default=1)
sequence = Integer(default=10)
age = Integer(required=True, min_value=0, max_value=150)
PropertyTypeDescription
min_valueintMinimum allowed value
max_valueintMaximum 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.

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 model
rate = 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.0
measured_value = Float(default=None)
PropertyTypeDescription
precisionint/strDecimal places: static int (default: 2) or field path string
min_valuefloatMinimum allowed value
max_valuefloatMaximum 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 zero
reading = Float(default=None) # None = not measured, 0.0 = measured zero
if check.reading is None:
... # nothing recorded yet — do not score it

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

Currency values with precision from currency field:

# Precision from currency.rounding (recommended)
price = Monetary(currency_field="currency")
# Without currency field - defaults to 2 decimal places
discount = Monetary(min_value=0, max_value=10000)
PropertyTypeDescription
currency_fieldstrField name containing the currency (precision from currency.rounding)
min_valuefloatMinimum allowed value
max_valuefloatMaximum 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 places
amount = round_float(100.125, digits=2) # 100.13
# Round using currency rounding factor
amount = round_float(100.125, rounding=0.01) # 100.13
# Check if effectively zero
if is_zero(0.001, rounding=0.01): # True
pass
# Compare floats safely
if 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")

:::

Float fields support dynamic precision using field path strings:

SyntaxDescriptionExample
precision=4Static integerFixed 4 decimal places
precision="rounding"Same model fieldUses self.rounding
precision="uom__rounding"Related model fieldUses self.uom.rounding

:::warning No Deep Nesting Deep paths like company__currency__rounding are not allowed. Only one level of relation traversal is supported. :::

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"
)
PropertyTypeDescription
prefix_fieldstrField path for dynamic prefix (e.g., "uom__symbol", "currency__symbol")
suffix_fieldstrField 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_field for dynamic values from related fields
  • View: Use prefix/suffix for 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: UoM

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

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 without time:

birth_date = Date()
due_date = Date(description="Due Date")

Date with time (stored in UTC):

created_at = Datetime(default=lambda: datetime.now())
scheduled_at = Datetime()

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
)
PropertyTypeDescription
choiceslistList of allowed string values (required)
max_lengthintMaximum 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"]) # correct
mode = Selection(choices=["balance_sheet", "profit_loss"]) # renders as-is, to the user

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

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 saved
record.settings["theme"] = "dark" # in-place key set — detected and saved
await record.save()
record.metadata = {**record.metadata, "k": "v"} # reassignment — also fine
await record.save()

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 accessible

Usage:

# Upload: base64 string with format: "filename;content_type;base64,data"
record.logo = "logo.png;image/png;base64,iVBORw0KGgo..."
await record.save()
OptionTypeDefaultDescription
publicboolFalseWhen 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.
trackboolFalseRecord changes to this file in the change log.

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

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.

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

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

  • OneToOne is 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=True marks 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).

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.

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.

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

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())
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")

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
)
  • Storage: Values are stored in CompanyConfig with 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=False implied)
  • 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)

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.

  • Cannot filter on company_scoped fields: filter(income_account=x) won’t work
  • Cannot sort on company_scoped fields
  • No database-level uniqueness constraints
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
)

Fields support custom validators for advanced validation logic. Pass a list of validators to the validators parameter.

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")
]
)
ValidatorDescription
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

Create custom validators by subclassing Validator or using a callable:

from fullfinity.engine.fields import Validator, ValidationError
# Class-based validator
class 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 validator
def 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])