Skip to content

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 3roots_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:

addons/roots/roots_hemms_yourmodule/__manifest__.py
{
    "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: always 18.0.<major>.<minor>.<patch> (5 components — Odoo convention).
  • license: LGPL-3 for public modules. AGPL-3 only when depending on AGPL-licensed OCA modules and you're OK with the implication.
  • development_status: Alpha until stable; Beta after a real customer is running it; Stable after a year in production; Mature after two. See oca-coding-standard skill 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.

Correct — extending an inherited model
class MaintenanceEquipment(models.Model):
    _inherit = "maintenance.equipment"

    roots_floor_number = fields.Integer()        # ✅ prefixed
    roots_room_code = fields.Char()              # ✅ prefixed
Correct — new model owned by HEMMS
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):

  • _id for Many2one
  • _ids for One2many and Many2many
  • _count for stored computes returning Integer counts
  • _date for 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.

addons/roots/roots_hemms_yourmodule/tests/test_yourfeature.py
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_install only for module-load-time invariants.
  • -at_install negates 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:

make lint       # what CI runs, in 1 command

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:

__manifest__.py
{
    "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:

models/hemms_calibration_cert.py
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:

tests/test_calibration.py
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:

make lint
make test-module MODULE=roots_hemms_calibration

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.

  • 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.