Building an HCPI Module — Part 3: Rights, Polish & Controllers
You have a working module with data and good UX. Part 3 closes it out: who can do what, a handful of polish features every real HCPI module has, and a short note on the one piece this module deliberately doesn't have — controllers.
Two security layers
Two distinct layers, each ultimately stored as rows in built-in tables (just like views, actions, and menus — recall the picture from Odoo Basics):
| Layer | Stored in | What it controls |
|---|---|---|
| Groups | res.groups |
Who is in which "role." Users belong to groups. |
| Model access (ACL) | ir.model.access |
Per-group, per-model: can the group Create / Read / Update / Delete this model at all? |
| Record rules | ir.rule |
Per-group, per-model: which rows can the group see/edit? Implemented as a domain auto-appended to every query. |
The mental model: ACL is the door, record rules are the floor plan. ACL says "you may enter the room." Record rules say "and within that room you can only see your own desk."
How real HCPI modules set this up
Before we wire up our own groups, look at how the production modules do it. The pattern is consistent across HCPI and worth recognising:
| Module | Groups defined | Pattern |
|---|---|---|
hcpi_coicop |
coicop_user (read-only), coicop_manager (full CRUD) |
The simplest pattern — one read group, one admin group. Used by all the reference-data modules (hcpi_item, hcpi_brand, locations). |
hcpi_outlet |
group_outlet_user, group_outlet_manager |
Same _user / _manager shape. Outlets are master data; reading is broad, editing is restricted. |
hcpi_data_collection |
group_data_collection_collector, group_data_collection_supervisor, group_data_collection_statician |
Three roles for an actual workflow. Each role implies the one below: a statistician implies supervisor implies collector. |
hcpi_index |
group_index_viewer, group_index_manager |
Reading the published indices is broad; computing/republishing is restricted. |
Two takeaways:
- The default pattern is a
_user/_managerpair. If a module's only job is "show this reference data; let admins edit it," that's the whole security model. - Workflow modules use a role triangle with
implied_idsso the higher roles inherit everything below them.hcpi_data_collectionis the canonical example — a collector can submit collections, a supervisor can also approve them, a statistician can also re-run validation algorithms.
Our module fits between the two. We'll add one custom group — Manager — and contrast it with regular internal users (base.group_user). That's enough to demonstrate every primitive without the triangle complexity. Real HCPI modules that need roles add the triangle.
Step 1: define the Manager group
Create security/hcpi_outlet_onboarding_security.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Category — a header in the user permissions UI -->
<record id="module_category_hcpi_outlet_onboarding" model="ir.module.category">
<field name="name">HCPI Outlet Onboarding</field>
<field name="sequence">25</field>
</record>
<!-- One custom group: Manager. Every other internal user is a regular user. -->
<record id="group_outlet_onboarding_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_hcpi_outlet_onboarding"/>
<field name="comment">Approve proposals and see everyone's work.</field>
</record>
</odoo>
ir.module.category— a UI grouping in Settings → Users → User → Access Rights. Without it, your group shows up as a flat checkbox rather than under "HCPI Outlet Onboarding."res.groups— the actual group record.
Add the file to __manifest__.py data (security files load before views — security records must exist before views reference them):
'data': [
'security/hcpi_outlet_onboarding_security.xml', # ← new, FIRST
'security/ir.model.access.csv',
'views/views.xml',
],
Step 2: refine the ACL matrix
Open security/ir.model.access.csv and replace its content:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hcpi_outlet_visit_user,hcpi.outlet.visit user,model_hcpi_outlet_visit,base.group_user,1,1,1,1
access_hcpi_outlet_proposal_user,hcpi.outlet.proposal user,model_hcpi_outlet_proposal,base.group_user,1,1,1,0
access_hcpi_outlet_proposal_manager,hcpi.outlet.proposal manager,model_hcpi_outlet_proposal,hcpi_outlet_onboarding.group_outlet_onboarding_manager,1,1,1,1
Reading row by row:
| Model | Group | C | R | U | D | Why |
|---|---|---|---|---|---|---|
hcpi.outlet.visit |
Every user | 1 | 1 | 1 | 1 | Anyone can log a visit. |
hcpi.outlet.proposal |
Every user | 1 | 1 | 1 | 0 | Create and edit, but no delete — keep an audit trail. |
hcpi.outlet.proposal |
Manager | 1 | 1 | 1 | 1 | Manager can also delete (garbage cleanup). |
Key principle: ACL is binary at the model level. Use it for the rough "can this role even touch this model?" question. For nuance (this row but not that row) use record rules in the next step.
How ACLs combine across groups: they're OR'd. A user in both base.group_user AND Manager has the union of their permissions — so a Manager can delete proposals because that group has unlink, even though the regular-user row doesn't.
Step 3: a record rule — "you only see your own proposals"
A regular user should see only proposals they filed; a Manager sees everyone's. We codify that with ir.rule.
Add to security/hcpi_outlet_onboarding_security.xml, after the group:
<!-- Regular users: only their OWN proposals -->
<record id="rule_proposal_own" model="ir.rule">
<field name="name">Outlet Proposal: own only</field>
<field name="model_id" ref="model_hcpi_outlet_proposal"/>
<field name="domain_force">[('proposed_by', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Managers: see ALL proposals -->
<record id="rule_proposal_manager_all" model="ir.rule">
<field name="name">Outlet Proposal: manager sees all</field>
<field name="model_id" ref="model_hcpi_outlet_proposal"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_outlet_onboarding_manager'))]"/>
</record>
Reading this:
domain_force— a domain auto-appended to every search. The user never sees rows that don't match.user.id— magic variable in record rules: the currently logged-in user's id.(1, '=', 1)— the "match everything" domain.(4, ref('base.group_user'))— the link-an-existing-record command tuple from Part 1. Same shape asassigned_user_idsupdates.
How rules combine
Across groups: rules are OR'd. A Manager is also in base.group_user, so they have two applicable rules. True OR (proposed_by = user.id) simplifies to True — they see everything.
Within one group, rules are AND'd. If a single group has two rules, both must match.
Verify
Restart Odoo with the upgrade flag:
In Settings → Users, the HCPI Outlet Onboarding category appears with a Manager checkbox. Admin has it by default. Create a second user with no Manager tick, log in as them in a private window, and confirm they only see their own proposals. Try deleting a proposal as them — no Delete option in the Actions menu (ACL forbids).
Step 4: GPS validation with @api.constrains
Right now nothing prevents an officer from saving a proposal with garbage coordinates like latitude = 999. Add a Python constraint.
Open models/hcpi_outlet_proposal.py. Add the import at the top of the file:
Inside the class:
@api.constrains('latitude', 'longitude')
def _check_gps_range(self):
for proposal in self:
if proposal.latitude and not (-90 <= proposal.latitude <= 90):
raise ValidationError("Latitude must be between -90 and 90.")
if proposal.longitude and not (-180 <= proposal.longitude <= 180):
raise ValidationError("Longitude must be between -180 and 180.")
@api.constrains('contact_phone')
def _check_phone(self):
for proposal in self:
if proposal.contact_phone and len(proposal.contact_phone.strip()) < 7:
raise ValidationError("Contact phone looks too short to be valid.")
@api.constrains runs on every save. The differences from the alternatives:
| Approach | Where it runs | When it fires | Use for |
|---|---|---|---|
_sql_constraints |
PostgreSQL | INSERT/UPDATE | Uniqueness, simple CHECK clauses |
@api.constrains |
Python, on save | After write | Anything more complex; cross-record checks |
@api.onchange |
Python, in the UI | As the user types | Soft hints — doesn't block save |
Try saving a proposal with latitude = 999 → red error toast appears, save blocked.
Step 5: a sequence for auto-numbering
Replace the placeholder name="New" from Part 1 with proper references like OP/2026/0001.
Create data/hcpi_outlet_proposal_data.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="seq_hcpi_outlet_proposal" model="ir.sequence">
<field name="name">HCPI Outlet Proposal</field>
<field name="code">hcpi.outlet.proposal</field>
<field name="prefix">OP/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</odoo>
code— the handle used in Python (env['ir.sequence'].next_by_code('hcpi.outlet.proposal')).prefix—%(year)sis auto-replaced with the current year. Other tokens:%(month)s,%(day)s.padding=4— pad the counter to 4 digits:0001,0002, …
Add to __manifest__.py:
'data': [
'security/hcpi_outlet_onboarding_security.xml',
'security/ir.model.access.csv',
'data/hcpi_outlet_proposal_data.xml', # ← new
'views/views.xml',
],
Now override create() on the proposal model so newly-created proposals pull from the sequence. In models/hcpi_outlet_proposal.py, add at the bottom of the class:
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', "New") == "New":
vals['name'] = self.env['ir.sequence'].next_by_code('hcpi.outlet.proposal') or "New"
return super().create(vals_list)
Reading the override:
@api.model_create_multi— the modern batch-create decorator.vals_listis a list of dicts (one per new record). This signature replaces the older single-dictcreate().- The
"New"guard — only generate a sequence number if the caller didn't already pass a name (so duplication / import flows keep their names). super().create(vals_list)— chain to the parent so the records actually get created.
After upgrade (-u hcpi_outlet_onboarding) and creating a new proposal, the reference reads OP/2026/0001.
Step 6: mail.thread chatter and audit trail
The chatter is that comments/log section at the bottom of forms in Odoo. It's not just chat — it's where field changes are logged when fields are marked tracking=True. You already added tracking=True to state in Part 1; now we wire up the mixin to make it visible.
In models/hcpi_outlet_proposal.py, change the class header:
class HcpiOutletProposal(models.Model):
_name = 'hcpi.outlet.proposal'
_description = "Outlet Proposal"
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
_inherit on a model with its own _name is called prototype inheritance — you're mixing in features from abstract models. mail.thread adds:
- A
message_idsOne2many to all chatter messages. - A
message_follower_idsfor follower management. - Auto-tracking of fields with
tracking=True.
mail.activity.mixin adds activity_ids for scheduled activities ("call this contact next Tuesday").
Then add the chatter to the form view. At the bottom of the proposal form, after closing </sheet> but inside </form>:
The <chatter/> tag is Odoo 17/18 shorthand. Upgrade, open a proposal — a chatter panel now appears at the bottom with Send message, Log note, Followers, and the log feed where every state change is recorded as State: Draft → Active.
Tracking more fields
Pick any field where you want history. Add tracking=True:
outlet_name = fields.Char(string="Outlet Name", required=True, tracking=True)
outlet_type = fields.Selection([...], required=True, default='open_market', tracking=True)
contact_phone = fields.Char(tracking=True)
Every change shows up in the chatter as Field: old → new.
Step 7: harden the workflow buttons
Right now action_approve from Part 2 lets anyone advance any draft. Add a state check, a sanity check, and a manager gate. Replace the methods in models/hcpi_outlet_proposal.py:
def action_approve(self):
self._require_manager()
for proposal in self:
if proposal.state != 'draft':
raise UserError("Only draft proposals can be approved.")
if not proposal.visit_ids:
raise UserError("Record at least one visit before approving.")
proposal.state = 'active'
proposal.message_post(body="Proposal approved.")
def action_retire(self):
for proposal in self:
if proposal.state != 'active':
raise UserError("Only active outlets can be retired.")
proposal.state = 'retired'
proposal.message_post(body="Outlet retired.")
def action_reset_to_draft(self):
for proposal in self:
proposal.state = 'draft'
proposal.message_post(body="Reset to draft.")
def _require_manager(self):
if not self.env.user.has_group('hcpi_outlet_onboarding.group_outlet_onboarding_manager'):
raise UserError("Only managers can approve proposals.")
Reading the changes:
raise UserError(...)— friendly dialog to the user. Different fromValidationErrormainly by convention; both stop the operation and surface their message.has_group('<xml-id>')— checks group membership. Returns True if the user is in that group.message_post(body=...)— adds a chatter entry. Audit-trail bonus on top of the automatictracking=Truelogs.
Try as a regular user: click Approve → "Only managers..." blocks it. Try Approve with no visits → "Record at least one visit..." blocks it. As a Manager with at least one visit → approval works, and the chatter logs "Proposal approved."
Step 8: a final tour
Restart with -u hcpi_outlet_onboarding one last time, then walk through everything:
- Settings → Users: confirm the HCPI Outlet Onboarding category appears with a Manager checkbox. Admin has it by default; the regular user you created does not.
- As the regular user:
- Create a proposal. Note the auto-name (
OP/2026/0001from the sequence). - Set garbage GPS (
latitude = 999) and try to save → blocked by@api.constrains. - Try Approve → blocked by
_require_manager. - The list shows only proposals you created — record rule working.
- Try to delete a proposal → no Delete option in the Actions menu — ACL working.
- Create a proposal. Note the auto-name (
- As admin (a Manager by default):
- View the regular user's proposal. Click Approve with a visit recorded — state moves to Active, chatter logs "Proposal approved."
- Change
contact_phone. The chatter records the change because the field istracking=True. - Delete a proposal you no longer want — manager has unlink.
That's a production-feeling module. Onto the one thing it deliberately doesn't have.
Controllers — and the RPC endpoint that replaces them
Scroll back to Part 1, Step 1. When we scaffolded the module, the generator created a controllers/ folder. We deleted it before doing anything else. Here's what it would have held, why we don't need it, and what HCPI uses instead.
What a controller is
An Odoo controller is a Python class that registers HTTP routes — URLs that Odoo answers when a browser, a mobile app, or any HTTP client hits them. Conceptually identical to a Flask, Django, or Express route handler:
from odoo import http
from odoo.http import request
class OutletPortal(http.Controller):
@http.route('/outlets/public', type='http', auth='public', website=True)
def public_outlet_list(self, **kwargs):
outlets = request.env['hcpi.outlet.proposal'].sudo().search([('state', '=', 'active')])
return request.render('hcpi_outlet_onboarding.public_outlet_template', {'outlets': outlets})
@http.route('/api/v1/outlet_count', type='json', auth='user')
def outlet_count(self):
return request.env['hcpi.outlet.proposal'].search_count([])
Common reasons to add a controller:
- Public-facing website pages — built with the Odoo Website module.
- Custom JSON / REST endpoints — when XML-RPC isn't a convenient shape for an external integration.
- Portal pages for non-internal users — customer self-service.
- Custom file downloads — generated CSV/Excel with bespoke auth logic.
- Webhooks — external systems POSTing in to trigger something.
Why this module doesn't have one — and what replaces it
Two reasons HCPI back-office modules almost never define their own controllers, and two replacements that cover the same ground.
1. The web UI is generated by Odoo, not by us. Every page you've clicked through in Parts 1–2 — proposal list, form, kanban, statusbar — is rendered by Odoo's built-in /web controller from the XML view definitions we wrote. We don't write our own HTTP routes for the web client; we declare what fields, views, and actions the model has, and Odoo takes care of the URLs, the rendering, the navigation. That's the whole point of being on Odoo: you get the CRUD UI for free.
The replacement: Odoo's own /web controller plus the XML view system. What you'd build with @http.route for a hand-rolled admin UI is replaced by <list>, <form>, <kanban> records plus ir.actions.act_window and menus. Same outcome, less code, consistent UX.
2. The Flutter mobile app talks XML-RPC, not custom endpoints. Enumerators in the field don't hit our URLs. The Flutter app calls Odoo over XML-RPC at /xmlrpc/2/object (and JSON-RPC at /jsonrpc). Those endpoints are part of Odoo core and they automatically expose every public method on every model. The mobile app authenticates with /xmlrpc/2/common (login), gets a user id, and then calls things like:
POST /xmlrpc/2/object
model: 'hcpi.outlet'
method: 'search_read'
args: [[['active', '=', true]], ['name', 'code', 'latitude']]
That call hits the exact same search_read you used in the Part 1 shell exercise — Odoo's ORM is the API. No custom HTTP route on our side; Odoo's RPC layer takes care of routing, authentication, serialisation, and access-control checks (your ACL and record rules from Steps 1–3 apply to RPC calls too).
The replacement: the built-in XML-RPC / JSON-RPC endpoint. Any field, any model method, any computed field — the mobile app can read or call it via RPC, restricted by the same security rules that govern the web UI.
When you would add a controller to HCPI
If the requirement showed up, you'd add a controllers/ folder back. Plausible cases:
- A public CPI dashboard at a custom URL (
/cpi/dashboard) that shows the latest index without logging in. - A webhook receiver for external price feeds —
/hooks/prices/import. - A download endpoint for a custom binary format the QWeb PDF doesn't cover (e.g., a file for upstream statistical software).
- An OAuth callback for integrating a third-party identity provider.
For everything else — internal users doing work in the web UI, the mobile app talking RPC — controllers add complexity without value. That's why the folder was deleted in Step 1 and never came back.
Exercises
-
Restrict editing on active proposals. Add a write-only
ir.ruleforbase.group_userwithperm_write=Trueand a domain restricting tostate == 'draft'. Once a proposal is active, regular users can still read it but can't edit. Managers (via their own "see all" rule) keep full access. -
Smart button on
res.users. Add aproposal_countcomputed field onres.users(using classical inheritance —_inherit = 'res.users'with no new_name) and display it as a smart button on the user form via an<xpath expr="//div[@name='button_box']" position="inside">view inheritingbase.view_users_form. This is the pattern country-specific modules use to extend HCPI base models — see Country Variants. -
Cross-record constraint. Use
@api.constrainson(outlet_name, region)to enforce "no duplicate outlet name per region" with a friendly message. Bonus: also add a_sql_constraintsequivalent. When does each fire? -
Scheduled cron. Create an
ir.cronrecord that runs every Monday at 09:00 and posts a chatter message on every proposal that's been indraftfor more than 7 days. Hint:ir.cronrecords live in data XML; theirmodel_idreferences the model whose method runs. -
One controller, for real. Add a
controllers/public_count.pywith a single@http.route('/onboarding/active_count', type='http', auth='public')that returns the count of active outlets as plain text. Remember to addcontrollers/__init__.pyandfrom . import controllersin the module's outer__init__.py. Visit the URL in a browser. This is the one time you'll write a controller in this tutorial.
What you learned
After Part 3 you understand the entire surface area of an HCPI module:
- Groups, ACLs, record rules, and how they combine.
- HCPI's security conventions — the
_user/_managerdefault pair and the collector/supervisor/statistician triangle that real workflow modules use. ir.module.categoryfor clean user-permissions UI.ir.rule.domain_forcewithuser.idfor "own records only" rules.@api.constrainsvs_sql_constraintsvs@api.onchange— when each fires.- Sequences with
next_by_codeand acreate()override using@api.model_create_multi. mail.threadmixin (prototype inheritance) for chatter + tracking;mail.activity.mixinfor activities.message_postto write to the chatter from Python.UserErrorvsValidationErrorandhas_groupfor in-method permission checks.- What an Odoo controller is, why most HCPI back-office modules don't need one, and the two things that replace it: Odoo's own
/webcontroller (for the UI) and the built-in XML-RPC / JSON-RPC endpoints (for the mobile app).
What now?
The module you built is a real module — clone it, rename it, swap the domain, and you have a working starter for almost any "an officer files, a manager approves" workflow.
From here:
- Module Reference — open the real HCPI modules. You'll recognise every piece: the model files, the security files, the inheritance, the chatter, the kanbans.
- Country Variants — see how Uganda, Kenya, Tanzania extend the same base modules with
_inheritandxpathview inheritance. - The remaining training topics (Coicop, Location, Outlets, Items, Data Collection, Validation, Mobile API) walk through the actual HCPI modules — everything that runs HCPI is the patterns from these three pages, applied to the price-index domain.
Cleaning up
To remove the practice module entirely when you're done:
- Apps → HCPI Outlet Onboarding → Uninstall (drops all tables and data).
- Stop Odoo, then
rm -rf /opt/hcpi/custom/HCPI/hcpi_outlet_onboarding/. - Restart Odoo.
Uninstall before deleting the folder — otherwise Odoo will warn you on every restart about the missing module that still has database records.