Skip to content

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 = fields.String(label="Name", required=True)

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:

source_type = fields.Selection(options=["file", "text"], default="text")

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:

parent_id = fields.Reference(target="contact", label="Parent company")

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:

tag_ids = fields.Many2many(target="tag", label="Tags")

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.

api_key = fields.String(label="API Key / Password", secret=True, required=True)

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.