Why the Single Name Field Is Not Enough
Odoo stores person names as a single name field on res.partner. For companies, this is the correct model — a company has one name, full stop. For people, it conflates display with data.
Any downstream operation that needs to work with a person's name structurally — addressing them by first name in a salutation, sorting a list alphabetically by last name, matching records against an import file, passing name data to an external API or HR system — ends up parsing the string. That works until someone enters "van der Berg, Jan" instead of "Jan van der Berg", or types only a first name, or pastes a full name with a middle initial. Parsing is fragile at exactly the edge cases that matter most in real data.
The fix is to add firstname and lastname as proper stored fields at the model layer, keep name as a derived display value, and handle the one place in the standard UI where a widget bypasses structured input entirely.
Architecture: Two Modules, Two Models
Two separate models need the fix. They have different ownership chains and different sync challenges, so they are handled by independent modules.
res.partner <- strictbase_partner_names (depends: contacts, crm) resource.resource <- strictbase_employee_names (depends: hr) hr.employee ^ related fields pointing at resource_id.*
| Module | Models patched | Fields added |
|---|---|---|
strictbase_partner_names |
res.partner, crm.lead |
firstname, lastname on partner; contact_firstname, contact_lastname on lead; CRM quick-create OWL widget |
strictbase_employee_names |
resource.resource, hr.employee |
firstname, lastname on resource; pass-through related fields on employee |
The modules are independent and can be installed separately. strictbase_employee_names has no dependency on strictbase_partner_names — HR and contact names are distinct concerns in Odoo's data model.
Partners — res.partner
Why Not a Computed name?
The obvious implementation is to redefine name as a computed field driven by firstname and lastname. This breaks. res.partner.name is written directly in hundreds of places — core Odoo, integrations, imports, external APIs. Making it computed means any call that writes name explicitly (e.g., partner.write({"name": "Acme Corp"})) will have its value overwritten at the next recompute cycle. For companies, which have no split fields, name must remain fully manual. You cannot serve both cases with one computed field definition.
The Name Sync Strategy
name stays writable. firstname and lastname are regular stored Char fields. Three hooks keep them in sync:
_onchange_name_from_split_fields— fires in the UI when first or last name changes. Updatesnameimmediately for live feedback without saving.createoverride — if nonameis present in the create dict, derives it from first + last. Ifnameis explicitly provided, leaves it alone.writeoverride — iffirstnameorlastnamechanges andnameis not being set in the same call, re-derivesnameper partner.
The _build_person_name utility strips whitespace from both parts and joins them with a single space, omitting empty parts:
def _build_person_name(self, firstname=False, lastname=False):
firstname = (firstname or "").strip()
lastname = (lastname or "").strip()
return " ".join(part for part in (firstname, lastname) if part)
The Write Override in Detail
The write is split per partner rather than applied as a single batch, because each partner may have different current values of firstname and lastname that need to be read to derive the new name:
def write(self, vals):
# If name is explicitly set, or no name-related fields are changing, skip.
if "name" in vals or not any(
field in vals for field in ("firstname", "lastname", "is_company")
):
return super().write(vals)
partners_to_sync = self.filtered(
lambda p: not vals.get("is_company", p.is_company)
)
for partner in partners_to_sync:
partner_vals = dict(vals)
derived = self._build_person_name(
partner_vals.get("firstname", partner.firstname),
partner_vals.get("lastname", partner.lastname),
)
if derived:
partner_vals["name"] = derived
super(ResPartner, partner).write(partner_vals)
...
The early return when name is in the write dict is the key invariant: explicitly written names are never overridden. Split fields are subordinate — they derive name only when name is not being set directly in the same call.
Companies Are Untouched
Every sync path checks is_company before doing anything. A company partner's name is never derived from firstname or lastname. Those fields remain empty and unused for companies.
Extended Name Search
Standard Odoo name_search searches only the name field. With split storage, a user searching "Smith" expects to find "John Smith" whether they search by first name, last name, or full name. The override adds an OR domain across all three fields:
domain = [
"|", "|",
("name", operator, name),
("firstname", operator, name),
("lastname", operator, name),
]
This means partial searches work regardless of how the display name was assembled — including cases where an existing partner was created before the split fields were populated.
Employees — resource.resource
Why Not hr.employee?
The instinct is to add firstname and lastname to hr.employee. This produces fields that look correct in the form but are structurally disconnected from the name. The reason is the ownership chain:
resource.resource.name <- actual DB column, NOT NULL constraint
^
hr.employee.name = fields.Char(related='resource_id.name', store=True, readonly=False)
hr.employee.name is already a related field in stock Odoo. It does not store its own value — it reads from and writes through to the linked resource.resource. If you add firstname and lastname directly to hr.employee, those fields have no path to resource.resource.name. Changing an employee's first name would update a column on hr.employee with no effect on the name the rest of Odoo sees.
The Correct Placement
firstname and lastname belong on resource.resource, where name actually lives. The same create/write/onchange sync pattern is applied there. hr.employee then gets the split fields as write-through related fields:
# hr.employee firstname = fields.Char(related='resource_id.firstname', store=True, readonly=False) lastname = fields.Char(related='resource_id.lastname', store=True, readonly=False) name = fields.Char(related='resource_id.name', store=True, readonly=False)
Editing hr.employee.firstname writes to resource.resource.firstname, which triggers the resource-level sync and updates resource.resource.name, which propagates back to hr.employee.name via the related field. The chain is complete without any extra sync code on the employee model itself.
The NOT NULL Edge Case
resource.resource.name has a NOT NULL constraint in the database. Creating an employee with only first and last name — no explicit name — would normally fail at the DB level. The create override handles this: if neither name nor any split field is provided, it falls back to "New Resource" as a valid placeholder. If first or last name is given, it derives name from them before the insert reaches the database.
Non-Human Resources
resource.resource is used for both people (resource_type = "user") and material resources like rooms or equipment (resource_type = "material"). The name sync logic only runs for human resources. Equipment names are left untouched at every hook.
CRM — The Quick-Create Problem
What the Standard Widget Does
The CRM kanban "New Lead" popover includes a contact person field — a standard many2one widget pointing at res.partner. In stock Odoo, this widget supports inline creation: type a name and press Enter, and Odoo creates a res.partner record with name = "John Doe". The split fields are bypassed entirely. The contact lands in the database with no firstname, no lastname, and a monolithic name string — exactly the problem the module was installed to prevent.
The Replacement
strictbase_partner_names replaces this mechanism with a structured flow using a custom OWL widget:
- The contact selector (
partner_id) is made selection-only. The "Create …", "Create and edit…", and quick-create-by-typing options are removed. The selector is for choosing an existing contact, not creating one. - Explicit
contact_firstnameandcontact_lastnamefields are shown on the lead. - A Create Contact button triggers a server-side action that creates the partner correctly.
Three UI States
The quick-create form adapts based on whether the selected company already has contacts, driven by the computed field company_has_contacts:
| State | What is shown | What the user does |
|---|---|---|
| No company selected, or company has no contacts | First Name + Last Name + Create button. Contact selector hidden. | Enter name, click Create. A new contact is created and linked. |
| Company has one existing contact | Contact selector (pre-populated) + First Name + Last Name + Create button. | Keep the pre-selected contact, or create an additional one. |
| Company has multiple existing contacts | Contact selector (first available pre-selected) + First Name + Last Name + Create button. | Pick a different contact from the selector, or create a new one. |
When Create Contact is clicked, action_create_contact_person() runs server-side: it validates that a company and at least one name part are present, creates the partner with parent_id, firstname, and lastname set correctly, links it to the lead, and clears the input fields.
The Two-Table Sync
A CRM lead and its linked partner store name data in separate tables. crm.lead has flat text fields — contact_firstname, contact_lastname, contact_name — for leads that are not yet linked to a partner. res.partner has its own firstname and lastname. These are different columns in different tables, connected only by crm_lead.partner_id.
Onchange handlers keep them consistent when either side changes:
- Partner selected on lead → populate
contact_firstname/contact_lastnameon the lead from the partner's stored split fields. - Split fields edited on lead → update
contact_name(the legacy full-name field) to stay in sync. - Contact created via the button → partner is written with the split fields, lead fields are cleared.
When a lead is eventually converted to a customer, _prepare_customer_values is overridden to carry firstname and lastname through to the newly created partner record.
When a partner is selected that was created before the module was installed and has no split fields set, the onchange falls back to a simple name split: everything before the last word becomes firstname, the last word becomes lastname. This is the only heuristic in the module and is applied only in this fallback path.