Models & fields¶
A model is a kind of record your module stores and works with — a contact, a ticket, an invoice line. You declare a model as a Python class, list its fields, and Victor takes care of the rest: the database table, the REST API, and the list/form screens that views render on top of it. You never write SQL or build a table component by hand.
This page covers how to declare a model, the title-field convention, every field type and its options, and two everyday patterns — deriving a field on create and extending a model that another module already defined.
Declaring a model¶
Import the SDK and subclass models.Model. A model always sets three class
attributes:
| Attribute | What it is |
|---|---|
name |
The model's identifier — snake_case, singular (e.g. contact, ticket). Relations and views reference the model by this name. |
label |
The human-readable name shown in menus and view titles (e.g. Contact). |
_description |
A one-line summary of what the model is for. Required on every model your module owns; it is also surfaced to agents so they understand each model. |
Why the underscore on _description?
description is a very common data field name (a ticket has a description, a
product has a description). The model's own summary is prefixed with an
underscore so it never collides with a field you want to store.
Here is the smallest useful model — a tag used to categorise contacts:
from victor import fields, models
class Tag(models.Model):
name = "tag"
_description = "A label used to categorise contacts."
label = "Tag"
display_name = fields.String(label="Name", required=True)
Every model gets id, created_at, and updated_at
Victor adds these three fields to every model automatically — you never declare
them. id is the record's unique identifier (relations and views reference
records by it), and created_at / updated_at are timestamps kept current for
you. Because they always exist you can refer to them in views and relations, but
you cannot reuse those three names for fields of your own.
The display_name title field¶
By convention, a model declares a display_name field to serve as the record's
title:
display_name is not special syntax — it is simply the agreed field name that
the framework looks for. Whatever you put there becomes:
- the record's name in list and form views, and
- the label shown when another record references this one.
Make it a String, and usually required=True, so every record has a title.
Tip
If a good title can be derived from other fields (say, a slug computed from
the name), keep the human-facing display_name as the editable title and use
prepare_create to compute the derived field.
Field types¶
Every field is a descriptor from fields. The sections below group them by what
they hold.
Common options¶
Except where noted, all field types accept the same four options:
| Option | Default | Meaning |
|---|---|---|
label |
field name | The human-readable label shown in views. |
required |
False |
Whether a value must be provided. |
default |
None |
The value used when none is supplied. |
secret |
False |
Marks the value as a masked credential (see Secret fields). |
Many2many, One2many, and Vector are the exceptions. Many2many and
One2many are relation fields: they accept default but not required (a
record never has to have related records — an empty list is always valid).
Vector is an embedding field and takes neither required nor default. Each
is called out below.
Scalar fields¶
These hold a single plain value.
| Field | Holds |
|---|---|
fields.String |
Short, single-line text |
fields.Text |
Long / multi-line text |
fields.Integer |
A whole number |
fields.Float |
A decimal number |
fields.Boolean |
A true/false toggle |
fields.Date |
A calendar date (no time) |
fields.Datetime |
A date and time |
from victor import fields, models
class Ticket(models.Model):
name = "ticket"
_description = "A support request raised by a customer."
label = "Ticket"
display_name = fields.String(label="Subject", required=True)
description = fields.Text(label="Description")
reopened_count = fields.Integer(label="Times reopened", default=0)
done = fields.Boolean(label="Resolved", default=False)
due_on = fields.Date(label="Due date")
Choice fields¶
Selection stores a single choice from a fixed set as a string. Pass
options as a list of (value, label) pairs — or bare strings, which are used
as both the stored value and the label.
from victor import fields, models
class Contact(models.Model):
name = "contact"
_description = "A person or company this instance does business with."
label = "Contact"
display_name = fields.String(label="Name", required=True)
email = fields.String(label="Email")
is_company = fields.Boolean(label="Is company", default=False)
stage = fields.Selection(
options=[("lead", "Lead"), ("customer", "Customer"), ("vendor", "Vendor")],
label="Stage",
default="lead",
)
Bare-string options are handy when the value and label are the same:
CheckboxSet is for multiple toggles at once — a set of capabilities the
user can each turn on or off. options is a list of (key, label) pairs (a bare
string is both). The stored value is the list of enabled keys, so a default
is normally a list:
capabilities = fields.CheckboxSet(
label="Capabilities",
options=[
("read", "Read records"),
("create", "Create records"),
("update", "Update records"),
("delete", "Delete records"),
],
default=["read", "update"],
)
Relation fields¶
Three field types link records to each other. Choosing the right one is about ownership and cardinality:
| Field | Cardinality | Value | Use it for |
|---|---|---|---|
Reference |
one link out | a single target record | "this ticket belongs to one contact" |
Many2many |
a list of links | a list of target record ids | "this contact has many tags, tags have many contacts" |
One2many |
owned children | a list of child records | "this workflow owns its steps" |
Reference(target=...) is a many-to-one link — the record points at exactly
one record of the target model:
Many2many(target=...) relates the record to many target records, and each
target can be shared by many records. The value is a list of target record ids.
An optional relation argument overrides the auto-derived relation name:
One2many(target=..., inverse=...) are the owned children of this record.
It is the mirror of a Reference: the child model carries a Reference back to
the parent, and inverse names that field. Writing a One2many is a
composition — Victor creates, updates, and deletes children to match the list you
supply, because the children are owned by the parent.
from victor import fields, models
class Workflow(models.Model):
name = "workflow"
_description = "A multi-step sequence of nodes that agents and automations execute."
label = "Workflow"
workflow_name = fields.String(label="Name", required=True)
input_schema = fields.Text(label="Input schema")
nodes = fields.One2many(target="workflow_node", inverse="workflow", label="Steps")
class WorkflowNode(models.Model):
name = "workflow_node"
_description = "A single step within a workflow."
label = "Workflow Node"
# `inverse="workflow"` above points at this Reference field:
workflow = fields.Reference(target="workflow", label="Workflow", required=True)
sequence = fields.Integer(label="Sequence")
pos_x = fields.Float(label="Canvas X")
A One2many needs a matching Reference
inverse must name a Reference field on the child model that points back at
the parent. Without it, the framework has no column to hang the children on.
Vector fields¶
Vector(dim=...) stores a fixed-dimension embedding vector that is queried by
similarity rather than typed in a form. It takes only dim, label, and
secret — no required, default, or target — and it is not edited through
the UI. You typically pair a Vector with the text it was computed from:
from victor import fields, models
EMBED_DIM = 768
class KnowledgeDocument(models.Model):
name = "knowledge_document"
_description = "A source document belonging to a knowledge base."
label = "Document"
base = fields.Reference(target="knowledge_base", required=True)
title = fields.String(required=True)
source_type = fields.Selection(options=["file", "text"], default="text")
body_text = fields.Text(label="Text")
chunk_count = fields.Integer(default=0)
class KnowledgeChunk(models.Model):
name = "knowledge_chunk"
_description = "A chunk of a document with its embedding vector, used for similarity search."
label = "Chunk"
text = fields.Text(required=True)
embedding = fields.Vector(dim=EMBED_DIM)
Secret fields¶
Set secret=True on a field whose value is a credential — an API key, a password,
a token. The value is stored securely and masked in the UI so it is not shown
back after it is entered. secret is available on all field types.
Deriving a field on create¶
Sometimes a field should be computed from another rather than typed. Override the
optional prepare_create classmethod: it receives the values dict about to be
written and returns a (possibly modified) dict. It runs only on create, not on
update — which makes it perfect for a stable identifier that should survive later
renames.
from victor import fields, models
class OdooConnection(models.Model):
name = "odoo_connection"
_description = "A connection to a customer's Odoo instance: URL, database, credentials and capabilities."
label = "Odoo Connection"
display_name = fields.String(label="Name", required=True)
code = fields.String(label="Code", required=True) # derived, then frozen
api_key = fields.String(label="API Key / Password", secret=True, required=True)
capabilities = fields.CheckboxSet(
label="Capabilities",
options=[
("read", "Read records"),
("create", "Create records"),
("update", "Update records"),
("delete", "Delete records"),
],
default=["read", "update"],
)
@classmethod
def prepare_create(cls, values):
if not values.get("code"):
values["code"] = _slug(values.get("display_name"))
return values
Here code is derived from display_name the first time the record is created.
Because prepare_create does not run on update, renaming the connection later
leaves code untouched — anything that references it keeps working.
Extending an existing model¶
You do not have to own a model to add fields to it. Declare a class with the
same name as an existing model in your own module, and your new fields merge
into it. This is how one module builds on another (Odoo developers know this as
_inherit).
from victor import fields, models
class ContactExt(models.Model):
name = "contact" # extend the core contact model
lead_count = fields.Integer(label="Leads")
After this module installs, every contact record gains a lead_count field —
no change to the module that originally defined contact. The base model's own
_description and title field stay in place; you are only contributing fields.
Tip
Extension is the clean way to enrich built-in models like contact from your
app's module, instead of forking or copying them.
Next steps¶
- Views — turn these models into list and form screens with the view XML DSL.
- Modules &
module.yaml— how models, views, and menus come together as an installable app.