Module Development¶
This page is the practical guide for building a new module that extends HEMMS — whether it's a community contribution to roots_hemms_* or a private customer extension that lives in your own repo. It assumes you've read Architecture and understand the three-layer model.
The 3-layer rule (most important thing on this page)¶
Your code goes in Layer 3. Not Layer 1 (Odoo base), not Layer 2 (OCA building blocks).
| Layer | What lives there | When you touch it |
|---|---|---|
| Layer 1 — Odoo base | maintenance.request, res.partner, hr.employee, … | Never directly. _inherit from Layer 3. |
Layer 2 — OCA-vendored (addons/oca/) | maintenance_plan, maintenance_stock, sign_oca, … | Never directly. Pin a version when vendoring, fix bugs upstream at OCA. |
Layer 3 — roots_hemms_* (addons/roots/) | Hospital-specific governance, compliance, Thai workflows | This is where your code goes. |
Why? Because Layer 1 and Layer 2 are upstreams you don't own. Every change you make there is a change you have to maintain forever every time those upstreams release. Layer 3 is yours — Trinity Roots' or your customer's — and survives upgrades cleanly.
Where new modules go¶
addons/
├── oca/ # vendored OCA — don't put your code here
└── roots/
├── roots_hemms_workflow/
├── roots_hemms_criticality/
├── ...
└── roots_<your_module>/ # ← your code goes here
The folder name is the technical name (__manifest__.py is read by its directory name). For HEMMS contributions, prefix with roots_hemms_. For private customer extensions, use roots_<customer>_ so it's clear at a glance the module isn't part of the public suite.
Manifest template¶
Use this as a starting point — copy from an existing roots_hemms_* module rather than inventing keys:
{
"name": "Roots HEMMS — Your Module",
"version": "18.0.1.0.0",
"category": "Maintenance",
"summary": "One-sentence description — what it adds, not how.",
"author": "Trinity Roots Co., Ltd.",
"website": "https://roots.tech",
"license": "LGPL-3",
"development_status": "Alpha",
"maintainers": ["yourname"],
"depends": [
# Depend on the most specific HEMMS module that gives you what
# you need, NOT on `maintenance` directly. See "Dependency hygiene"
# below.
"roots_hemms_pm",
],
"data": [
"security/ir.model.access.csv",
"views/yourmodel_views.xml",
],
"installable": True,
"application": False,
}
Keys we always set:
version: always18.0.<major>.<minor>.<patch>(5 components — Odoo convention).license:LGPL-3for public modules. AGPL-3 only when depending on AGPL-licensed OCA modules and you're OK with the implication.development_status:Alphauntil stable;Betaafter a real customer is running it;Stableafter a year in production;Matureafter two. Seeoca-coding-standardskill for the criteria.maintainers: your GitHub handle.author:"Trinity Roots Co., Ltd."for HEMMS-core contributions.
Dependency hygiene¶
Depend on the most specific HEMMS module that gives you what you need.
| You need... | Depend on |
|---|---|
| Equipment with brand/model + condition + criticality | roots_hemms_asset_master and roots_hemms_criticality |
| The 5-stage workflow + SLA + audit | roots_hemms_workflow |
| PM kinds + PM Scheduled stage | roots_hemms_pm |
| Spare-parts lines | roots_hemms_spare_parts |
| Contract coverage lookup | roots_hemms_service_contracts |
Sign-off via sign_oca | roots_hemms_signature |
| MoPH device-class + EM-score waterfall | roots_hemms_ha_report |
Don't depend on Odoo maintenance directly
If there's a HEMMS module that already extends maintenance for your use case, depend on it. Depending on maintenance directly re-derives a parallel hospital lens, which is exactly the kind of drift the 3-layer rule exists to prevent.
Field-naming convention¶
Hospital-specific fields on inherited Odoo / OCA models use a roots_ prefix. Brand-new models defined by HEMMS use a hemms. _name prefix; their own fields don't need redundant prefixing.
class MaintenanceEquipment(models.Model):
_inherit = "maintenance.equipment"
roots_floor_number = fields.Integer() # ✅ prefixed
roots_room_code = fields.Char() # ✅ prefixed
class HemmsCalibrationCert(models.Model):
_name = "hemms.calibration.cert" # ✅ hemms. prefix
_description = "Equipment Calibration Certificate"
name = fields.Char() # ✅ no redundant prefix
expiry_date = fields.Date() # ✅ no redundant prefix
equipment_id = fields.Many2one("maintenance.equipment") # ✅ standard Odoo suffix
Field-name suffix rules (per Odoo coding standard):
_idfor Many2one_idsfor One2many and Many2many_countfor stored computes returning Integer counts_datefor Date / Datetime
Read the odoo-coding-standard skill output for the full set.
Model attribute order¶
Inside a model class, attributes go in this order (mirrors existing HEMMS code):
class HemmsExample(models.Model):
# 1. Model attributes (_name, _description, _inherit, _order, _rec_name)
_name = "hemms.example"
_description = "Example Model"
_inherit = ["mail.thread"]
_order = "name"
# 2. SQL constraints
_sql_constraints = [
("name_uniq", "unique(name)", "Name must be unique."),
]
# 3. Fields, grouped by type with a comment header
# ── Char / Text ──────────────────────────────────────────────────
name = fields.Char(required=True)
# ── Many2one ─────────────────────────────────────────────────────
partner_id = fields.Many2one("res.partner")
# 4. Compute methods (in field-declaration order)
@api.depends("name")
def _compute_display_name(self):
...
# 5. CRUD lifecycle (create / write / unlink) overrides
@api.model_create_multi
def create(self, vals_list):
...
# 6. Constraints
@api.constrains("name")
def _check_name(self):
...
# 7. Public action methods (button handlers, named `action_*`)
def action_do_the_thing(self):
...
# 8. Private helpers (named `_helper_*` or just `_*`)
def _helper_internal(self):
...
Writing tests¶
Tests live under tests/ in each module and follow Odoo's TransactionCase pattern. Existing tests are the best reference — read addons/roots/roots_hemms_service_contracts/tests/test_contract_unit.py for a unit-test example and test_contract_integration.py for an integration-test example.
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
class TestYourFeature(TransactionCase):
"""Unit tests for roots_hemms_yourmodule.YourFeature."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.equipment = cls.env["maintenance.equipment"].create({
"name": "Test Equipment",
"roots_value_tier": "high",
"roots_risk_tier": "high",
})
def test_u01_red_criticality_propagates(self):
"""U1: high value + high risk → criticality_color == 'red'."""
self.assertEqual(self.equipment.roots_criticality_color, "red")
Tagging convention:
post_install— most tests, ensures other modules are loaded.at_installonly for module-load-time invariants.-at_installnegates the default so the test doesn't run twice.
Naming convention:
- Unit tests:
test_uNN_<short_description>(matches the plan §7 unit list). - Integration tests:
test_iNN_<short_description>.
Don't import the model class — go through self.env["..."]. Always.
Running tests locally¶
# Run all tests for one module:
make test-module MODULE=roots_hemms_yourmodule
# Or, manually:
odoo --test-enable \
--test-tags=/roots_hemms_yourmodule \
--stop-after-init \
-i roots_hemms_yourmodule \
-d test_yourmodule
--test-tags=/<module> scopes to your module so other modules' tests don't run.
CI integration¶
CI runs every test on every PR via the four leaf modules in .github/workflows/tests.yml. If your module is in the dependency graph of one of those leaves (most are), you get CI coverage for free — no action needed. If you add a top-of-tree HEMMS module (nothing depends on it), add it to MODULES_TO_TEST in tests.yml and HEMMS_MODULES in the Makefile. Keep them synchronized.
See CI/CD for the full workflow detail.
Pre-commit setup¶
Every commit runs the OCA pre-commit toolchain. Install once after cloning:
make docs-install # gets the venv set up
pip install pre-commit
pre-commit install # installs the .git/hooks/pre-commit shim
From then on, every git commit runs:
- ruff — Python lint + format
- pylint_odoo — Odoo-specific lint (mostly UX/style)
- oca-checks-odoo-module — manifest + structure checks
- XML / YAML hygiene — trailing whitespace, EOF newlines, etc.
If a hook fails, the commit is blocked. Fix and re-stage; don't --no-verify past a failing hook.
To run the same hooks on the whole tree:
Read .pre-commit-config.yaml for the three intentional deviations from OCA defaults (oca-gen-addon-readme skipped, oca-fix-manifest-website skipped, prettier-xml + eslint commented out).
Coding standards (skill references)¶
Two skill files in this repo encode the canonical standards. When in doubt, invoke them:
| Skill | Covers |
|---|---|
odoo-coding-standard | Official Odoo conventions — model attribute order, field type ordering, method ordering, field suffix rules, the 5 Odoo programming idioms (propagate context, think extendable, never commit transaction, avoid catching exceptions, use _() correctly), commit message format. |
oca-coding-standard | The stricter OCA enforcement layer on top — pylint_odoo, oca-checks-odoo-module, manifest extras (development_status, maintainers), module maturity lifecycle, README generation from readme/ fragments. |
Commit-message format¶
Follow the OCA convention used across the repo:
[TAG](module): one-line summary [QN.M]
Optional longer body explaining the why.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
[TAG] is one of feat, fix, test, docs, chore, style, refactor, build. (module) is the affected module (short form is fine: feat(spare) instead of feat(roots_hemms_spare_parts)). The [QN.M] suffix references the plan task — see existing commits in git log for examples.
Worked example: adding a calibration-cert module¶
A small worked example showing every piece together.
1. Scaffold the module:
mkdir -p addons/roots/roots_hemms_calibration/{models,views,security,data,tests}
touch addons/roots/roots_hemms_calibration/{__init__.py,__manifest__.py}
touch addons/roots/roots_hemms_calibration/models/__init__.py
touch addons/roots/roots_hemms_calibration/tests/__init__.py
2. Manifest:
{
"name": "Roots HEMMS — Calibration Certificates",
"version": "18.0.1.0.0",
"category": "Maintenance",
"summary": "Track calibration certificates per equipment with vendor sign-off.",
"author": "Trinity Roots Co., Ltd.",
"website": "https://roots.tech",
"license": "LGPL-3",
"development_status": "Alpha",
"maintainers": ["yourname"],
"depends": [
"roots_hemms_asset_master", # depend on the specific HEMMS module
"roots_hemms_signature", # for sign_oca bridge later
],
"data": [
"security/ir.model.access.csv",
"views/hemms_calibration_cert_views.xml",
],
"installable": True,
"application": False,
}
3. Model:
from odoo import fields, models
class HemmsCalibrationCert(models.Model):
_name = "hemms.calibration.cert"
_description = "Equipment Calibration Certificate"
_order = "expiry_date asc, id desc"
# ── Char / Text ──────────────────────────────────────────────────
name = fields.Char(required=True)
# ── Date ─────────────────────────────────────────────────────────
issued_date = fields.Date(required=True)
expiry_date = fields.Date(required=True)
# ── Many2one ─────────────────────────────────────────────────────
equipment_id = fields.Many2one(
"maintenance.equipment",
required=True,
ondelete="cascade",
index=True,
)
calibration_body_id = fields.Many2one("res.partner")
# ── Binary ───────────────────────────────────────────────────────
pdf_file = fields.Binary(attachment=True)
pdf_filename = fields.Char()
4. Tests:
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
class TestCalibration(TransactionCase):
def test_u01_create_minimal_cert(self):
equipment = self.env["maintenance.equipment"].create({"name": "Eq"})
cert = self.env["hemms.calibration.cert"].create({
"name": "CAL-001",
"issued_date": "2026-01-01",
"expiry_date": "2027-01-01",
"equipment_id": equipment.id,
})
self.assertEqual(cert.equipment_id, equipment)
5. Run CI locally:
6. Commit:
git add addons/roots/roots_hemms_calibration/
git commit -m "feat(calibration): add calibration-cert module skeleton"
That's the loop. Iterate on features behind the same lint + test gate.
Related pages¶
- Architecture — the 3-layer model these modules sit inside.
- API Reference — public surface of every existing HEMMS module.
- CI/CD — what the lint and test workflows enforce.
- Contributing — docs / commit / PR workflow.
- Q-plans under
docs/developer/q*-*-plan.md— worked examples of how to scope and ship a quarter.