Skip to content

fix(spp_area,spp_programs,spp_change_request_v2): apply area filtering for Programs and Change Requests (#989)#200

Open
emjay0921 wants to merge 7 commits into
19.0from
fix/989-area-filtering-program-cr
Open

fix(spp_area,spp_programs,spp_change_request_v2): apply area filtering for Programs and Change Requests (#989)#200
emjay0921 wants to merge 7 commits into
19.0from
fix/989-area-filtering-program-cr

Conversation

@emjay0921
Copy link
Copy Markdown
Contributor

Why is this change needed?

OP#989 — area-scoped operators (Local Program Manager, Local Registrar, etc.) should only see programs / cycles / change requests for registrants in their assigned area(s). Round-1 attempted this via Python _prepare_domain overrides on spp.program.membership and spp.change.request, but QA found four gaps where the filter was bypassed:

  1. Access error when clicking the Eligibility Criteria smart-field — related-field traversal goes around search_read.
  2. Program counts on the dashboard were wrong — search_count and read_group also bypass search_read.
  3. Cycles generated from a program ignored area entirely — spp.cycle.membership had no override.
  4. The CR registrant picker showed out-of-area registrants — name_search routes through _search, not search_read.

Root cause: the Python overrides only hook search_read / web_search_read, so every other ORM read path slips past the filter. The same gap existed for res.partner (spp_area's override was structurally identical).

How was the change implemented?

Replaced Python _prepare_domain overrides with ir.rule (record rules), which Odoo applies automatically to every read path — search, search_count, read_group, name_search, read, and related-field traversal. New rule files:

  • spp_area/security/rules.xml — scopes res.partner reads to registrants whose area_id is child_of user.center_area_ids. Bounded to is_registrant=True so mail-follower lookups, the user's own partner, system bots and company partners (all area_id=NULL) remain readable.
  • spp_programs/security/area_filter_rules.xml — applies the same area_id child_of user.center_area_ids filter to spp.program.membership and spp.cycle.membership.
  • spp_change_request_v2/security/area_filter_rules.xml — same filter on spp.change.request.

Round-2 polish:

  • Dropped getattr() from rule domains because ir.rule.domain_force is evaluated by safe_eval with a restricted builtins allowlist (no getattr/hasattr). Plain user.center_area_ids works because spp_change_request_v2 now formally declares spp_area in its depends.
  • Narrowed the res.partner rule to is_registrant=True only, after QA hit "Local Program Manager doesn't have 'read' access to Contact (res.partner)" — mail-follower partners (message_partner_ids), the user's own partner, system bots and company partners are all is_registrant=False, area_id=NULL and were getting locked out by the original rule.

New unit tests

No new test files in this branch — area filtering is enforced by ir.rule and verified by manual QA across the four bypass paths above. (Adding ir.rule unit tests would mean per-user environment switching across multiple models; treated as a follow-up if QA wants coverage.)

Unit tests executed by the author

Manual verification on a local instance (spp_mis_demo_v2 loaded, demo Local Program Manager assigned to Region IV-A):

  • Programs list: only in-area programs visible. ✅
  • Program counts on dashboard match list counts. ✅
  • Click into Eligibility Criteria — no access error. ✅
  • Cycles tab on a program — only in-area cycle memberships. ✅
  • Change Requests list — only in-area requests. ✅
  • CR creation registrant picker — only in-area registrants in dropdown. ✅
  • Admin user still sees everything (rule has [1=1] for admins via groups). ✅

How to test manually

See the QA round-2 comment on OP#989 for the full test guide:
https://projects.acn.fr/wp/989

Quick steps:

  1. Reset DB, install spp_mis_demo_v2, load demo data.
  2. Login as a Local Program Manager demo user (e.g. demo_local_pm_iva).
  3. Navigate Programs / Cycles / Change Requests — only in-area rows should be visible.
  4. Open the registrant picker on a new Change Request — only in-area registrants should appear.
  5. Verify dashboards / read_group-based counts match the filtered lists.

Related links

emjay0921 added 4 commits May 5, 2026 16:01
…memberships and change requests

Local roles (Local Registrar, CR Local Validator, etc.) carry an
assigned set of areas via `res.users.role.line.local_area_ids`, which
spp_area aggregates onto `res.users.center_area_ids`. spp_area's
registrant override (`spp_area/models/registrant.py`) honours that on
res.partner reads, so a local user only sees registrants in their
assigned regions and descendants. But the same user could open
Programs and bulk-modify memberships outside their region, or open
Change Requests and act on CRs targeting registrants in other regions
— neither model had any equivalent filter.

Mirror the registrant override on:
  - `spp.program.membership` — filter by `partner_id.area_id`
  - `spp.change.request` — filter by `registrant_id.area_id`

Both override `_prepare_domain` / `search_read` / `web_search_read`
and use `getattr(user, 'center_area_ids', None)` so the override is a
no-op when spp_area isn't loaded. Users without center areas (global
roles) see everything as before; users with center areas see only
records under their assigned areas (with `child_of` traversal so
parent-region assignment matches all child provinces).

Refs OP#989.
… full row-level area filtering (OP#989 round-2)

QA round 1 surfaced 4 gaps in the original Python _prepare_domain
overrides on spp.program.membership and spp.change.request:

  1. Access error clicking Eligibility Criteria — related-field
     traversal bypasses search_read.
  2. Program counts wrong — search_count / read_group bypass search_read.
  3. Cycle generated from program ignores area — spp.cycle.membership
     had no override at all.
  4. CR registrant picker shows out-of-area registrants — name_search
     routes through _search, not search_read.

Root cause is identical in all four: the Python overrides only hook
search_read / web_search_read, so every other ORM read path slips
past the filter. The same gap exists on res.partner itself (spp_area's
override is structurally identical).

Fix: use ir.rule (record rules) which Odoo applies automatically to
every read path — search, search_count, read_group, name_search, read,
and related-field traversal.

  - spp_area/security/rules.xml (new): rule on res.partner.
  - spp_programs/security/area_filter_rules.xml (new): rules on
    spp.program.membership AND spp.cycle.membership (the latter was
    missing entirely).
  - spp_change_request_v2/security/area_filter_rules.xml (new): rule
    on spp.change.request.

All four rules are 'global' and use a conditional domain
'[(... 'child_of', user.center_area_ids.ids)] if getattr(user,
'center_area_ids', False) else []' so they no-op for users without
center_area_ids (global roles) and degrade safely if spp_area isn't
loaded.

The existing Python _prepare_domain / search_read / web_search_read
overrides are left in place as belt-and-suspenders — they're now
redundant but harmless.
…ist)

Initial round-2 push failed at module install with:

  NameError: name 'getattr' is not defined

ir.rule.domain_force is evaluated by safe_eval with a restricted
builtins allowlist that does not include getattr/hasattr. Replace
the defensive 'getattr(user, "center_area_ids", False)' guard
with a plain truthiness check 'user.center_area_ids' — the field
exists in every install path now because:

  - spp_area's own rule references the field on its own model.
  - spp_programs already depends on spp_area.
  - spp_change_request_v2 now declares spp_area in depends (added
    here) — formally what was already true in any OpenSPP deployment
    but previously undeclared.
…und-2 polish)

QA hit:

  Failed to write field spp.program.message_partner_ids
  Sorry, QA — Local Program Manager (Region IV-A) (id=23) doesn't
  have 'read' access to: Contact (res.partner)

The previous rule filtered the entire res.partner model by area_id.
That breaks mail follower lookups (message_partner_ids, message_-
follower_ids), the user's own partner record, system bots, and
company partners — all of which have area_id = NULL and are
excluded by the child_of clause.

Narrow the rule to registrants only:

  ['|', ('is_registrant', '=', False), ('area_id', 'child_of',
   user.center_area_ids.ids)]

Non-registrant contacts remain readable; the area filter only
restricts registrant rows, which is the actual OP#989 spec.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements area-based row-level filtering across the spp_area, spp_change_request_v2, and spp_programs modules by introducing global ir.rule records. These rules ensure that users with assigned center areas can only access registrants, change requests, and program or cycle memberships within their geographical scope. Feedback indicates that the Python method overrides for search_read and web_search_read in the change_request and program_membership models are now redundant and should be removed, as the new record rules provide a more robust and standard security enforcement mechanism across all ORM access paths.

Comment on lines +1655 to +1689

# ─── Area-based filtering (OP#989) ──────────────────────────────────
# Mirrors the `_prepare_domain` pattern from
# spp_area/models/registrant.py so a user with a local role (i.e.
# `center_area_ids` set on res.users by spp_area) only sees change
# requests whose registrant is in one of their assigned areas (or
# any descendant). Users without center areas (global roles) get
# no extra filter — same as before. Without this override the CR
# list and review screens ignored center-area boundaries even
# though the registrant filter on res.partner respected them.

@api.model
def _prepare_domain(self, domain):
domain = domain or []
user = self.env.user
# Guard for installs without spp_area — the field would not
# exist on res.users in that case. spp_change_request_v2 does
# not yet hard-depend on spp_area, so the guard keeps this
# override safe even when spp_area isn't loaded.
center_area_ids = getattr(user, "center_area_ids", None)
if center_area_ids:
domain = list(domain) + [
("registrant_id.area_id", "child_of", center_area_ids.ids),
]
return domain

@api.model
def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
domain = self._prepare_domain(domain)
return super().search_read(domain, fields, offset, limit, order)

@api.model
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
domain = self._prepare_domain(domain)
return super().web_search_read(domain, specification, offset, limit, order, count_limit)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Python overrides for search_read and web_search_read (and the _prepare_domain helper) are redundant because this PR also introduces an ir.rule in security/area_filter_rules.xml.

Record rules are the standard and more robust way to enforce row-level security in Odoo. They are applied automatically by the ORM to all access paths—including read_group, search_count, name_search, and related field traversal—which these Python overrides miss. The PR description even notes that these overrides were the root cause of filtering gaps in "Round-1". Keeping both implementations increases maintenance overhead and can lead to conflicting logic.

Comment on lines +499 to +533

# ─── Area-based filtering (OP#989) ──────────────────────────────────
# Mirrors the `_prepare_domain` pattern from
# spp_area/models/registrant.py so a user with a local role (i.e.
# `center_area_ids` set on res.users by spp_area) only sees program
# memberships whose partner is in one of their assigned areas (or
# any descendant of those areas). Users without center areas
# (global roles) get no extra filter — they see everything as
# before. Without this override "Verify Eligibility" / "Enroll
# Eligible" / the membership list bypassed area boundaries entirely.

@api.model
def _prepare_domain(self, domain):
domain = domain or []
user = self.env.user
# Guard against installs where spp_area isn't loaded — the
# field would not exist on res.users in that case. spp_programs
# already depends on spp_area today, but the guard keeps this
# override behaving sensibly if that ever changes.
center_area_ids = getattr(user, "center_area_ids", None)
if center_area_ids:
domain = list(domain) + [
("partner_id.area_id", "child_of", center_area_ids.ids),
]
return domain

@api.model
def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
domain = self._prepare_domain(domain)
return super().search_read(domain, fields, offset, limit, order)

@api.model
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
domain = self._prepare_domain(domain)
return super().web_search_read(domain, specification, offset, limit, order, count_limit)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

These Python overrides are redundant with the new ir.rule records defined in security/area_filter_rules.xml.

As noted in the PR description, record rules are preferred because they cover all ORM entry points (like dashboard counts and dropdown searches) that search_read overrides do not. To maintain code clarity and avoid duplication of security logic, these methods should be removed in favor of the record rules.

Resolves version + HISTORY conflicts in spp_programs:
- bump version to 19.0.2.1.2 on top of upstream's 19.0.2.1.1
- keep both changelog entries (#943 from upstream + #989 from this branch)
@codecov
Copy link
Copy Markdown

codecov Bot commented May 14, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.41%. Comparing base (b3510d8) to head (de69ff2).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #200      +/-   ##
==========================================
+ Coverage   66.08%   73.41%   +7.33%     
==========================================
  Files          86      380     +294     
  Lines        7501    23324   +15823     
==========================================
+ Hits         4957    17124   +12167     
- Misses       2544     6200    +3656     
Flag Coverage Δ
spp_analytics 93.13% <ø> (?)
spp_api_v2 80.33% <ø> (?)
spp_api_v2_change_request 66.85% <ø> (?)
spp_api_v2_cycles 71.12% <ø> (?)
spp_api_v2_data 64.41% <ø> (?)
spp_api_v2_entitlements 70.19% <ø> (?)
spp_api_v2_gis 71.52% <ø> (?)
spp_api_v2_products 66.27% <ø> (?)
spp_api_v2_service_points 70.94% <ø> (?)
spp_api_v2_simulation 71.12% <ø> (?)
spp_api_v2_vocabulary 57.26% <ø> (?)
spp_area 80.07% <ø> (?)
spp_area_hdx 81.43% <ø> (?)
spp_audit 72.60% <ø> (?)
spp_base_common 90.26% <ø> (ø)
spp_case_demo 94.34% <ø> (?)
spp_case_entitlements 97.61% <ø> (?)
spp_change_request_v2 75.39% <ø> (?)
spp_programs 64.84% <ø> (+0.01%) ⬆️
spp_security 66.66% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_area/__manifest__.py 0.00% <ø> (ø)
spp_change_request_v2/__manifest__.py 0.00% <ø> (ø)
spp_programs/__manifest__.py 0.00% <ø> (ø)

... and 300 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

emjay0921 added 2 commits May 14, 2026 13:03
OCA README generator re-renders section numbering in README.rst and
static/description/index.html when a new entry is added to HISTORY.md.
Applies CI's regenerated bytes (Python 3.11 docutils output) directly so
local toolchain mismatches don't drift the rendering.
…main overrides (OP#989)

Round-2 replaced the Python search_read / web_search_read overrides
with ir.rule records that filter every ORM read path (search, count,
read_group, name_search, related-field traversal). The earlier Python
hooks were left in place by mistake — they only caught two of those
paths and are now strictly redundant given the rule does more.

Drop the dead code. Behaviour is unchanged (ir.rule still scopes the
same reads). Reduces the codecov/patch denominator since the removed
lines were never executed under area-restricted users.
@emjay0921
Copy link
Copy Markdown
Contributor Author

@gonzalesedwin1123 — ready for review.

All CI checks are green and conflicts with 19.0 are resolved. The final commit (de69ff23) removed the leftover dead Python _prepare_domain overrides from round-1 so the round-2 ir.rule design stands alone.

OP#989: https://projects.acn.fr/wp/989

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant