Contributing
Contents
Contributing#
For: developers
How to contribute code to OpenSPP: the coding standards, the pre-commit hook chain, the PR template, and the CI checks that have to pass. OpenSPP uses OCA tooling (pylint_odoo, maintainer-tools) plus an OpenSPP-specific linter suite that enforces the rules in docs/principles/.
Prerequisites#
A GitHub account and a fork of OpenSPP/OpenSPP2
A working development environment — see Development setup
Python 3.12+ (ruff
target-version = "py312")Node.js 22 (pre-commit runs prettier and eslint through Node)
How to use this section#
Set up your fork and branch (the
19.0branch is the active target)Install pre-commit hooks before your first commit — they catch 95% of what CI would fail on
Follow the code style rules (ruff, pylint_odoo mandatory checks) and the OpenSPP conventions (naming, XML IDs, no PII in logs)
Open the PR against
19.0using the template — the CI checks and a reviewer have to approve
When do you need this?#
You are doing |
Required reading |
|---|---|
Any code change (fix, feature, refactor) |
|
Adding a new model or field |
OpenSPP conventions (naming, XML IDs, ACLs) |
Adding a new module |
Plus Custom modules for scaffold + manifest requirements |
Touching API V2 endpoints |
API-auth pre-commit hook runs — auth required by default, public endpoints need allowlist entry |
Writing tests |
Don't use |
Documentation-only change |
Pre-commit skips many hooks on |
Security fix |
See |
Fork, branch, and commit#
OpenSPP targets Odoo 19 on the 19.0 branch. Do not open PRs against main — the repo uses Odoo-style version branches.
# Fork on GitHub, then clone your fork
git clone git@github.com:YOUR-USERNAME/OpenSPP2.git
cd OpenSPP2
git remote add upstream https://github.com/OpenSPP/OpenSPP2.git
# Branch from 19.0
git fetch upstream
git checkout -b 19.0-fix-registrant-search upstream/19.0
Branch names are free-form (use what you need) but keep them descriptive. Commits should follow conventional commit style (feat:, fix:, docs:, refactor:, test:, chore:) — the existing history is a reliable guide.
Module versions in __manifest__.py follow Odoo convention (19.0.X.Y.Z). Bump the version when you change public model behavior, add migrations, or ship a breaking change.
Code style#
Formatting and basic lint run via ruff, pylint_odoo, prettier, and eslint — all wired into pre-commit. You shouldn't hand-format anything.
Ruff (Python)#
Config in .ruff.toml:
Setting |
Value |
|---|---|
Target version |
|
Line length |
|
Enabled rules |
|
McCabe complexity |
|
Import sections |
|
Manifest files (__manifest__.py) get B018, E501 ignored — useless expressions and long lines are fine there because the manifest is a dict literal.
Pylint (pylint_odoo)#
Two configs run:
.pylintrc-mandatory— a small set of checks that block CI (e.g.anomalous-backslash-in-string,assignment-from-none, manifest-required keys). You must pass this..pylintrc— a larger set of checks that run with--exit-zero(warnings only). Fix what you can; CI won't fail you on these, but reviewers may ask.
The mandatory config also enforces manifest requirements on every module:
Manifest key |
Required value |
|---|---|
|
|
|
Must include |
|
Must start with |
|
Deprecated — do not use |
Editorconfig#
.editorconfig sets LF line endings, final newlines, UTF-8 charset, and indent:
.py,.xml,.css,.js,.less,.sass,.scss→ 4-space indent.yml,.yaml,.json,.md,.rst→ 2-space indent
Almost every editor honors this automatically.
Prettier + ESLint#
Run over .css/.htm/.html/.js/.jsx/.json/.less/.md/.scss/.toml/.ts/.xml/.yaml/.yml files. Includes @prettier/plugin-xml so Odoo views get normalized too. Generated files (readme/, static/description/index.html, static/lib/) are excluded.
OpenSPP-specific conventions#
These rules are enforced by a custom linter suite in scripts/lint/ and documented in docs/principles/. They go beyond standard Odoo conventions — a module that passes standard pylint_odoo can still fail OpenSPP lint.
Naming conventions (error — blocks CI)#
Documented in docs/principles/naming-conventions.md and ADR-001. Enforced by scripts/lint/check_naming.py:
Model names use
spp.*namespace.g2p.*is deprecated (separate check:openspp-no-g2p-namespace).Many2one fields end in
_id(e.g.partner_id,program_id). The linter has a built-in exception list of ~30 fields (geography:country,state,district,region,province,city,village; framework:parent,company,currency,partner,user,categ,create_uid,write_uid; audit:created_by,approved_by,rejected_by,resolved_by, etc.). Checkscripts/lint/check_naming.pyfor the full list. Add project-specific exceptions in.openspp-lint.yamlunderrules.naming.many2one_exceptions.Boolean fields use
is_*orhas_*prefix. A short allowlist (bidirectional,recurring,active) is built in; extend via.openspp-lint.yamlrules.naming.boolean_exceptions.XML IDs follow a fixed pattern per element type (view / action / menu / group / record) — enforced by
scripts/lint/check_xml_ids.py.
No PII in logs (warning)#
Documented in docs/principles/error-handling.md. The openspp-no-pii-in-logs pygrep hook and check_logger.py both scan for _logger.*(record.name | .national_id | .phone | .mobile | .email | .address | .birth_date | .tax_id | .bank_account) — any match is a warning.
Don't log field values directly. Log IDs or hashed/masked representations:
# Wrong — puts PII in the log stream
_logger.info("Created registrant %s (%s, %s)", partner.name, partner.email, partner.phone)
# Right — log the ID; operators can look it up if they have the access group
_logger.info("Created registrant id=%s", partner.id)
See the spp_security guide (Security and Encryption) for the encrypted-field pattern.
ACL files required (warning)#
Every module needs security/ir.model.access.csv — enforced by scripts/lint/check_acl.py. If you add a new model and forget the ACL entry, the lint will flag it.
Odoo 19 compatibility (warning)#
Documented in docs/principles/odoo19-compatibility.md. Enforced by scripts/lint/check_odoo19.py:
Use
invisible="..."attribute expressions, not the oldattrs={'invisible': [...]}syntaxUse the
CommandAPI for Many2many / One2many writes —Command.link(id)/Command.set([ids])instead of raw tuple literals(4, id)/(6, 0, ids)group_expand/_read_group_*methods drop theorderparameter in Odoo 19 — the signature is(self, records, domain), not the pre-17(self, records, domain, order)
Test style (warning)#
openspp-no-assertraises-tuple catches self.assertRaises((A, B), ...). Odoo's helper (unlike stdlib unittest) doesn't support a tuple of exceptions — you'll get a confusing failure at runtime. Use one assertRaises per exception.
Performance anti-patterns (warning)#
scripts/lint/check_performance.py flags:
Offset pagination (
search([...], offset=N, limit=M)) — prefer cursor-based pagination in API codeself.env.cr.commit()inside a loopN+1 query patterns (iterating records and dereferencing related fields one-by-one)
UI patterns (warning)#
scripts/lint/check_ui_patterns.py flags missing limit on list views, missing sample="1" where appropriate, malformed XPath expressions, and statusbar placement issues.
Pre-commit hooks#
The single most useful thing you can do before your first commit: install pre-commit locally. Every PR has to pass pre-commit run --all-files in CI, and running it locally catches issues before they bounce back.
pip install pre-commit
pre-commit install
# One-off: run every hook on the whole tree (slow, but complete)
pre-commit run --all-files
# Normal workflow: runs automatically on `git commit`, only on staged files
git commit -m "fix: resolve search race condition"
What the chain runs (in order)#
The full chain is in .pre-commit-config.yaml. Highlights:
Stage |
Hooks |
Purpose |
|---|---|---|
Local guards |
|
Reject copier |
Packaging |
|
Generate READMEs, normalize manifests, update addons table |
OCA checks |
|
Standard OCA module validation |
Formatters |
|
Auto-formatting — commit the result |
Python lint |
|
Odoo-aware pylint |
OpenSPP custom |
|
Naming, XML IDs, ACLs, Odoo 19 compat, performance, UI, logger, API auth |
Secrets & security |
|
Secret scanning + AST-based security analysis |
Some hooks auto-fix (ruff --fix, ruff-format, prettier --write, eslint --fix, end-of-file-fixer, mixed-line-ending). When they do, the commit is rejected so you can review and re-stage the changes:
# Typical cycle
git commit -m "feat: add registrant deduplication"
# ...pre-commit modifies files, commit aborts
git add -u # stage the auto-fixes
git commit -m "feat: add registrant deduplication"
Running the OpenSPP linter directly#
The custom checks are also available as a unified runner, which is useful when you want JSON output for an editor plugin or scoped checking:
# All checks, summary format
python scripts/lint/openspp_lint.py --summary
# Only a specific module
python scripts/lint/openspp_lint.py --module spp_programs
# Only specific checks
python scripts/lint/openspp_lint.py --check naming xml_ids
# Errors only (hide warnings)
python scripts/lint/openspp_lint.py --severity error
# JSON output for CI or editor integration
python scripts/lint/openspp_lint.py --format json
Extend the defaults via .openspp-lint.yaml at the repo root (see the existing file for the schema — rules.naming.boolean_exceptions, modules.*.allow_model_patterns, etc.).
Writing tests#
Every non-trivial change needs tests. See Testing for the full pattern (test base class, role-based access testing, @tagged("post_install", "-at_install")).
The pre-commit chain does not run tests — they run in CI. To run them locally before pushing:
# Replace the module name with yours
spp test spp_my_module
# Or, if you don't have the `spp` helper, directly:
odoo -d openspp_test --test-enable --stop-after-init -i spp_my_module
Opening a pull request#
Open your PR against 19.0 (not main). The PR template is pre-filled with these sections — fill them all in:
## Why is this change needed?
## How was the change implemented?
## New unit tests
## Unit tests executed by the author
## How to test manually
## Related links
Tips that reviewers will thank you for:
Why — 1-2 sentences of motivation. Don't assume the reviewer has read the linked issue.
How — the moving parts, not line-by-line narration. Call out anything non-obvious (an inherited model, a migration, a new dependency).
New unit tests — list the test methods you added and which behavior each one locks in.
Unit tests executed by the author — the
spp test ...command you ran and the result. "All pass" without the command is not helpful.How to test manually — a step-by-step script. Include the demo module to install, the URL to hit, the expected outcome.
Related links — issue, Linear ticket, spec in
openspp-modules-v2/docs/specs/, or upstream OCA PR.
CI checks that gate merge#
On every PR, GitHub Actions runs these workflows from .github/workflows/:
Workflow |
What it does |
How to fix a failure |
|---|---|---|
|
Runs |
Run |
|
Detects which modules changed, runs their test suites on a Postgres+Odoo matrix |
Reproduce with |
|
Full test suite (manual dispatch) |
Same as above — rarely needed unless your change crosses many modules |
|
Static analysis pass |
Read the annotations on the PR; rerun |
|
Bandit, Gitleaks, Semgrep |
Inspect the flagged line; if it's a false positive, add a targeted |
|
Closes abandoned PRs — informational, not a gate |
Keep the PR active |
The detect-changes step in ci.yml only runs tests for modules you touched (or declared dependents of). If you added a new module, make sure its __manifest__.py has the right depends list — otherwise CI may skip it.
License and author requirements#
OpenSPP is LGPL-3.0. New modules and files must carry an LGPL-3 (or AGPL-3 / GPL-2+ / GPL-3+) license declaration in the manifest. pylint_odoo enforces this — see the license-allowed list in .pylintrc-mandatory.
Every manifest must declare author containing OpenSPP.org (manifest-required-authors in .pylintrc-mandatory). Example:
{
"name": "My OpenSPP Module",
"version": "19.0.1.0.0",
"license": "LGPL-3",
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
"depends": ["spp_security", "spp_registry"],
# ...
}
Your name can be added to CONTRIBUTORS.md after your first merged PR.
Common mistakes#
Opening a PR against main. OpenSPP uses Odoo-style version branches. The active target is 19.0. Your PR will be closed and asked to retarget if you pick the wrong base.
Skipping pre-commit locally, pushing, and watching CI fail. The pre-commit.yml workflow runs exactly the same hooks as your laptop. You save a round trip by running them before pushing.
Using --no-verify to bypass a failing hook. If pre-commit fails, fix the underlying issue. Bypassing the hooks pushes the failure to CI, and the reviewer will ask you to address the same warnings there.
Bumping the module version "just in case". Version bumps matter for migrations and packaging. Bump when the change warrants it (public behavior, migration, new feature); don't bump for cosmetic refactors.
Assuming the optional pylint checks don't matter. They're --exit-zero in CI, so they don't block merge — but reviewers see them, and a PR that fixes all the warnings is easier to approve than one that leaves them.
Logging partner.name or partner.phone in a warning message. The PII-in-logs hook will catch you. This is not cosmetic: OpenSPP deployments route logs to external systems (syslog, CloudWatch, Loki). Once PII leaves the Odoo DB, consent and encryption controls no longer apply.
Inheriting spp.versioned.mixin without overriding _get_version_snapshot_fields(). The mixin captures nothing by default — your versions will be empty. See Audit and Versioning.
Submitting a PR without How to test manually filled in. Reviewers lean on this section heavily. Fill it in even for "trivial" changes — what's obvious to you is rarely obvious to a reviewer who didn't live with the problem.
See also#
Development setup — setting up the dev environment before you start
Custom modules — module scaffold, manifest requirements, and the three-tier security pattern
Testing — how to write tests that pass CI
openspp-modules-v2/docs/principles/— the full set of OpenSPP development principles (naming, access rights, UI, performance, error handling)openspp-modules-v2/docs/architecture/decisions/— ADRs that drive the conventions
openspp.org