Example: custom registry fields
Contents
Example: custom registry fields#
For: developers
This tutorial walks you through building a complete OpenSPP module from scratch. You will create a module that adds two custom fields to the individual registry: education level and head of household.
By the end, you will have a working module that demonstrates all the patterns covered in this section — manifest, models, security, views, and tests.
Tip
Want to skip ahead? Download the complete module: spp_custom_registry_fields.zip
What you will build#
Field |
Type |
Purpose |
|---|---|---|
|
Many2one (vocabulary code) |
Highest education level completed |
|
Boolean |
Whether the individual is head of their household |
Prerequisites#
A running development environment (see Development setup)
The
spp_registrymodule installed (included in all demo profiles)
Step 1: Create the module scaffold#
Create the directory structure:
mkdir -p spp_custom_registry_fields/{models,views,security,tests,readme}
__manifest__.py#
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
{
"name": "OpenSPP Custom Registry Fields",
"summary": "Adds education level and head of household fields to the individual registry.",
"category": "OpenSPP/Configuration",
"version": "19.0.2.0.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
"license": "LGPL-3",
"development_status": "Alpha",
"maintainers": [],
"depends": [
"spp_registry",
"spp_security",
"spp_vocabulary",
],
"data": [
# Security (privileges before groups, both before ACLs)
"security/privileges.xml",
"security/groups.xml",
"security/ir.model.access.csv",
# Views
"views/individual_views.xml",
],
"application": False,
"installable": True,
"auto_install": False,
}
Note the dependencies:
spp_registry— provides the individual registry we are extendingspp_security— provides thegroup_spp_admingroup we link tospp_vocabulary— providesspp.vocabulary.codefor the education level field
pyproject.toml#
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
__init__.py#
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from . import models
models/__init__.py#
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from . import individual
Step 2: Define the model#
We are not creating a new model — we are extending the existing res.partner model used by the registry. This adds our fields to the existing database table.
models/individual.py#
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
import logging
from odoo import fields, models
_logger = logging.getLogger(__name__)
class ResPartnerCustomFields(models.Model):
"""Add custom fields to the individual registry.
Extends res.partner to add education level and head of household
tracking for individual registrants.
"""
_inherit = "res.partner"
education_level_id = fields.Many2one(
"spp.vocabulary.code",
string="Education Level",
domain="[('namespace_uri', '=', 'urn:openspp:vocab:education-level')]",
help="Highest level of education completed.",
)
is_head_of_household = fields.Boolean(
string="Head of Household",
default=False,
help="Whether this individual is the head of their household.",
)
Key points:
We use
_inherit = "res.partner"without_name— this extends the existing modeleducation_level_idends with_id(Many2one naming rule)is_head_of_householdstarts withis_(Boolean naming rule)The
domainfilter oneducation_level_idrestricts the dropdown to codes from the "Education Level" vocabularyWe use
spp.vocabulary.codeinstead of a hardcodedSelectionfield — this allows the education levels to be configured without code changes
Step 3: Set up security#
This follows the three-tier pattern from Security. Since our module only adds fields to an existing model (not a new model), we use a simplified two-role variant (viewer + manager). Convention: privileges in security/privileges.xml, groups in security/groups.xml, with privileges.xml loaded first so groups.xml can reference them.
security/privileges.xml#
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Viewer: read-only access to custom fields -->
<record id="privilege_custom_fields_viewer" model="res.groups.privilege">
<field name="name">Viewer</field>
<field name="category_id" ref="spp_security.category_spp_registry" />
<field name="sequence">200</field>
</record>
<!-- Manager: can edit custom fields on registrants -->
<record id="privilege_custom_fields_manager" model="res.groups.privilege">
<field name="name">Manager</field>
<field name="category_id" ref="spp_security.category_spp_registry" />
<field name="sequence">210</field>
</record>
</odoo>
We use category_spp_registry because this module extends the registry. Picking a domain-specific category puts the groups in the right place in the user-permissions UI (see spp_security/README.rst for the full category list).
security/groups.xml#
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Technical Groups (Tier 3) -->
<record id="group_custom_fields_read" model="res.groups">
<field name="name">Custom Registry Fields: Read</field>
<field name="comment">Technical group for read access to custom registry fields.</field>
</record>
<record id="group_custom_fields_write" model="res.groups">
<field name="name">Custom Registry Fields: Write</field>
<field name="comment">Technical group for write access to custom registry fields.</field>
<field name="implied_ids" eval="[Command.link(ref('group_custom_fields_read'))]" />
</record>
<!-- User-Facing Groups (Tier 2) -->
<record id="group_custom_fields_viewer" model="res.groups">
<field name="name">Custom Registry Fields Viewer</field>
<field name="privilege_id" ref="privilege_custom_fields_viewer" />
<field name="comment">Can view custom registry fields but cannot modify them.</field>
<field name="implied_ids" eval="[Command.link(ref('group_custom_fields_read'))]" />
</record>
<record id="group_custom_fields_manager" model="res.groups">
<field name="name">Custom Registry Fields Manager</field>
<field name="privilege_id" ref="privilege_custom_fields_manager" />
<field name="comment">Can edit custom fields on registrant records.</field>
<field name="implied_ids" eval="[
Command.link(ref('group_custom_fields_write')),
Command.link(ref('group_custom_fields_viewer')),
]" />
</record>
<!-- Link Manager to SPP Admin -->
<record id="spp_security.group_spp_admin" model="res.groups">
<field name="implied_ids" eval="[Command.link(ref('group_custom_fields_manager'))]" />
</record>
</odoo>
security/ir.model.access.csv#
Since we are extending res.partner (not creating a new model), the ACL grants field-level access through the existing model:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_res_partner_custom_fields_viewer,Partner Custom Fields Viewer,base.model_res_partner,group_custom_fields_viewer,1,0,0,0
access_res_partner_custom_fields_manager,Partner Custom Fields Manager,base.model_res_partner,group_custom_fields_manager,1,1,0,0
res.partner is defined by the base module, so the model_id reference is always base.model_res_partner — even when spp_registry has extended it with additional fields. Using spp_registry.model_res_partner would fail at install time because that external ID does not exist.
Step 4: Create the view#
We extend the existing individual form view using XPath to inject our fields into the Demographics section.
views/individual_views.xml#
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Extend Individual Form View: add custom fields to Demographics section -->
<record id="view_individual_custom_fields_form" model="ir.ui.view">
<field name="name">spp.custom.registry.fields.individual.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="spp_registry.view_individuals_form" />
<field name="arch" type="xml">
<!-- Add education level after the occupation field in Demographics -->
<xpath expr="//group[@name='demographics_section']/group[field[@name='occupation_id']]" position="after">
<group>
<field
name="education_level_id"
readonly="disabled"
options="{'no_create': True, 'no_create_edit': True}"
/>
</group>
<group>
<field name="is_head_of_household" readonly="disabled" />
</group>
</xpath>
</field>
</record>
</odoo>
Key points:
inherit_idreferences the original form view we are extendingThe XPath targets the occupation field's
<group>wrapper insidedemographics_sectionand adds our fields after itreadonly="disabled"follows the registry pattern — the field respects the form's overall edit modeoptions="{'no_create': True, 'no_create_edit': True}"prevents users from creating new vocabulary codes from the dropdown (codes should be managed through the vocabulary system)
Step 5: Write tests#
tests/__init__.py#
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_custom_fields
tests/common.py#
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.tests.common import TransactionCase
class CustomFieldsTestCommon(TransactionCase):
"""Common test setup for spp_custom_registry_fields tests."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create an "Education Level" vocabulary and codes
cls.vocab_education = cls.env["spp.vocabulary"].create({
"name": "Education Level",
})
cls.education_primary = cls.env["spp.vocabulary.code"].create({
"vocabulary_id": cls.vocab_education.id,
"name": "Primary",
})
cls.education_secondary = cls.env["spp.vocabulary.code"].create({
"vocabulary_id": cls.vocab_education.id,
"name": "Secondary",
})
cls.education_tertiary = cls.env["spp.vocabulary.code"].create({
"vocabulary_id": cls.vocab_education.id,
"name": "Tertiary",
})
# Create test users with different access levels
base_user_group = cls.env.ref("base.group_user")
cls.user_viewer = cls.env["res.users"].create({
"name": "Registry Viewer",
"login": "custom_fields_viewer",
"email": "viewer@test.com",
"group_ids": [
Command.link(base_user_group.id),
Command.link(
cls.env.ref(
"spp_custom_registry_fields.group_custom_fields_viewer"
).id
),
],
})
cls.user_manager = cls.env["res.users"].create({
"name": "Registry Manager",
"login": "custom_fields_manager",
"email": "manager@test.com",
"group_ids": [
Command.link(base_user_group.id),
Command.link(
cls.env.ref(
"spp_custom_registry_fields.group_custom_fields_manager"
).id
),
],
})
def _create_test_individual(self, **kwargs):
"""Create a test individual registrant.
Args:
**kwargs: Override default field values.
Returns:
res.partner: Created individual record.
"""
default_vals = {
"name": "Test Individual",
"is_registrant": True,
"is_group": False,
}
default_vals.update(kwargs)
return self.env["res.partner"].create(default_vals)
tests/test_custom_fields.py#
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from .common import CustomFieldsTestCommon
@tagged("post_install", "-at_install")
class TestCustomRegistryFields(CustomFieldsTestCommon):
"""Tests for custom registry fields on individual registrants."""
def test_create_individual_with_education_level(self):
"""Test creating an individual with an education level."""
individual = self._create_test_individual(
education_level_id=self.education_secondary.id,
)
self.assertEqual(individual.education_level_id, self.education_secondary)
def test_create_individual_with_head_of_household(self):
"""Test creating an individual marked as head of household."""
individual = self._create_test_individual(
is_head_of_household=True,
)
self.assertTrue(individual.is_head_of_household)
def test_default_head_of_household_is_false(self):
"""Test that head of household defaults to False."""
individual = self._create_test_individual()
self.assertFalse(individual.is_head_of_household)
def test_update_education_level(self):
"""Test updating an individual's education level."""
individual = self._create_test_individual(
education_level_id=self.education_primary.id,
)
individual.write({
"education_level_id": self.education_tertiary.id,
})
self.assertEqual(individual.education_level_id, self.education_tertiary)
Step 6: Install and verify#
Copy the module to your OpenSPP addons directory and install it:
spp stop
ODOO_INIT_MODULES=spp_custom_registry_fields spp start
Once Odoo is running:
Navigate to Registry in the main menu
Open any individual record
Scroll to the Demographics section
You should see the new Education Level and Head of Household fields
To run the tests:
spp test spp_custom_registry_fields
Complete file listing#
spp_custom_registry_fields/
├── __init__.py
├── __manifest__.py
├── pyproject.toml
├── models/
│ ├── __init__.py
│ └── individual.py
├── views/
│ └── individual_views.xml
├── security/
│ ├── privileges.xml
│ ├── groups.xml
│ └── ir.model.access.csv
├── tests/
│ ├── __init__.py
│ ├── common.py
│ └── test_custom_fields.py
└── readme/
└── DESCRIPTION.md
openspp.org