55This module extends the CEL (Common Expression Language) system with vocabulary-aware
66functions that enable robust eligibility rules across different deployment vocabularies.
77
8+ > ** Note:** Both ` r ` and ` me ` are valid prefixes for the registrant symbol. This guide
9+ > uses ` r ` (matching ADR-008). The ` me ` alias is available via YAML profile
10+ > configuration.
11+
812## Features
913
1014### Core Functions
@@ -14,26 +18,26 @@ functions that enable robust eligibility rules across different deployment vocab
1418Resolve a vocabulary code by URI or alias.
1519
1620``` cel
17- me.gender == code("urn:iso:std:iso:5218#2") # By URI
18- me.gender == code("female") # By alias
19- me.gender == code("babae") # Local alias (Philippines)
21+ r.gender_id == code("urn:iso:std:iso:5218#2") # By URI
22+ r.gender_id == code("female") # By alias
23+ r.gender_id == code("babae") # Local alias (Philippines)
2024```
2125
2226#### ` in_group(code_field, group_name) `
2327
2428Check if a vocabulary code belongs to a concept group.
2529
2630``` cel
27- in_group(me.gender , "feminine_gender")
28- members.exists(m, in_group(m.gender , "feminine_gender"))
31+ in_group(r.gender_id , "feminine_gender")
32+ members.exists(m, in_group(m.gender_id , "feminine_gender"))
2933```
3034
3135#### ` code_eq(code_field, identifier) `
3236
3337Safe code comparison handling local code mappings.
3438
3539``` cel
36- code_eq(me.gender , "female")
40+ code_eq(r.gender_id , "female")
3741```
3842
3943### Semantic Helpers
@@ -44,28 +48,32 @@ User-friendly functions for common checks:
4448- ` is_male(code_field) ` - Check if code is in masculine_gender group
4549- ` is_head(code_field) ` - Check if code is in head_of_household group
4650- ` is_pregnant(code_field) ` - Check if code is in pregnant_eligible group
51+ - ` head(member) ` - Check if a member is the head of household (takes a member record,
52+ not a code field)
4753
4854### Example Usage
4955
5056#### Simple Gender Check
5157
5258``` cel
53- is_female(me.gender )
59+ is_female(r.gender_id )
5460```
5561
5662#### Complex Eligibility Rule
5763
5864``` cel
5965# Pregnant women or mothers with children under 5
60- is_pregnant(me.pregnancy_status) or
66+ # Note: pregnancy_status_id is provided by country-specific modules
67+ is_pregnant(r.pregnancy_status_id) or
6168 members.exists(m, age_years(m.birthdate) < 5)
6269```
6370
6471#### Local Code Support
6572
6673``` cel
6774# Works in any deployment, even with local terminology
68- in_group(me.hazard_type, "climate_hazards")
75+ # Note: hazard_type_id is provided by country-specific modules
76+ in_group(r.hazard_type_id, "climate_hazards")
6977```
7078
7179## How It Works
@@ -76,7 +84,7 @@ The `code()` function resolves identifiers in this order:
7684
77851 . Full URI (e.g., ` "urn:iso:std:iso:5218#2" ` )
78862 . Code value in active vocabulary
79- 3 . Display name
87+ 3 . Label (display value)
80884 . Reference URI mapping (for local codes)
8189
8290### Concept Groups
@@ -100,7 +108,7 @@ Example:
100108CEL expressions are translated to Odoo domains:
101109
102110``` cel
103- in_group(me.gender , "feminine_gender")
111+ in_group(r.gender_id , "feminine_gender")
104112```
105113
106114Translates to:
@@ -120,24 +128,28 @@ Translates to:
120128
121129## Configuration
122130
123- ### Defining Concept Groups
131+ ### Concept Groups
132+
133+ Standard concept groups are created automatically via ` post_init_hook ` on module
134+ installation (search-or-create pattern, safe for upgrades). They have no XML IDs — look
135+ them up by name.
124136
125- Create concept groups via UI or data files:
137+ To add codes to a group via data files:
126138
127139``` xml
128- <record id = " group_feminine_gender " model = " spp.vocabulary.concept.group " >
129- < field name = " name" >feminine_gender</ field >
130- <field name = " display_name " >Feminine Gender</ field >
131- < field name = " cel_function " >is_female</ field >
132- < field name = " description " >Codes representing feminine gender identity</ field >
133- < field
134- name = " code_ids "
135- eval = " [
136- (4, ref('spp_vocabulary.code_female')),
137- (4, ref('spp_vocabulary_ph.code_babae'))
138- ] "
139- />
140- </ record >
140+ <!-- Look up by name since groups are created by hook, not XML -- >
141+ < function model = " spp.vocabulary.concept.group " name = " search " >
142+ <!-- Use write() to add codes after finding the group -- >
143+ </ function >
144+ ```
145+
146+ Or via Python:
147+
148+ ``` python
149+ group = env[ ' spp.vocabulary.concept.group ' ].search(
150+ [( ' name ' , ' = ' , ' feminine_gender ' )], limit = 1
151+ )
152+ group.write({ ' code_ids ' : [Command.link(female_code.id)]})
141153```
142154
143155### Local Code Mapping
@@ -161,36 +173,45 @@ Map local codes to standard codes:
161173
162174```
163175spp_cel_vocabulary/
164- ├── __init__.py
176+ ├── __init__.py # post_init_hook, concept group creation
165177├── __manifest__.py
166178├── models/
167179│ ├── __init__.py
168- │ ├── cel_vocabulary_functions.py # Function registration
169- │ └── cel_vocabulary_translator.py # Domain translation
180+ │ ├── cel_vocabulary_functions.py # Function registration
181+ │ └── cel_vocabulary_translator.py # Domain translation
170182├── services/
171183│ ├── __init__.py
172- │ └── cel_vocabulary_functions.py # Pure Python functions
184+ │ ├── cel_vocabulary_functions.py # Pure Python functions
185+ │ └── vocabulary_cache.py # Session-scoped cache
173186├── tests/
174187│ ├── __init__.py
175- │ └── test_cel_vocabulary.py # Comprehensive tests
176- └── security/
177- └── ir.model.access.csv
188+ │ ├── test_cel_vocabulary.py # Core function and translation tests
189+ │ ├── test_vocabulary_cache.py # Cache behavior tests
190+ │ ├── test_vocabulary_in_exists.py # Vocabulary in exists() predicates
191+ │ └── test_init_and_coverage.py # Init, edge cases, coverage
192+ ├── security/
193+ │ └── ir.model.access.csv
194+ └── data/
195+ ├── concept_groups.xml # Documentation (groups created by hook)
196+ └── README.md # Data configuration guide
178197```
179198
180199### Design Patterns
181200
1822011 . ** Pure Functions** - Services contain stateless Python functions
183- 2 . ** Environment Injection** - Models wrap functions with Odoo env
202+ 2 . ** Environment Injection** - Functions marked with ` _cel_needs_env=True ` ; CEL service
203+ injects fresh env at evaluation time
1842043 . ** Function Registry** - Dynamic registration with CEL system
1852054 . ** Domain Translation** - AST transformation to Odoo domains
206+ 5 . ** Two-Layer Caching** - ` @ormcache ` (registry-scoped) + ` VocabularyCache `
207+ (session-scoped)
186208
187209## Testing
188210
189211Run tests:
190212
191213``` bash
192- # From openspp-odoo-19-migration/ directory
193- invoke test-spp-deps --modules=spp_cel_vocabulary --skip=queue_job --mode=update --db-filter=' ^devel$'
214+ ./scripts/test_single_module.sh spp_cel_vocabulary
194215```
195216
196217## Related Documentation
@@ -207,20 +228,20 @@ invoke test-spp-deps --modules=spp_cel_vocabulary --skip=queue_job --mode=update
207228** Before (fragile):**
208229
209230``` cel
210- me.gender == "female"
231+ r.gender_id == "female"
211232```
212233
213234** After (robust):**
214235
215236``` cel
216237# Option 1: Semantic helper
217- is_female(me.gender )
238+ is_female(r.gender_id )
218239
219240# Option 2: Concept group
220- in_group(me.gender , "feminine_gender")
241+ in_group(r.gender_id , "feminine_gender")
221242
222243# Option 3: Safe comparison
223- code_eq(me.gender , "female")
244+ code_eq(r.gender_id , "female")
224245```
225246
226247### From Hardcoded Values
@@ -235,15 +256,18 @@ if member.pregnancy_status_id.code == "pregnant":
235256** After:**
236257
237258``` python
238- pregnant_group = env.ref(' spp_vocabulary.group_pregnant_eligible' )
239- if pregnant_group.contains(member.pregnancy_status_id):
259+ group = env[' spp.vocabulary.concept.group' ].search(
260+ [(' name' , ' =' , ' pregnant_eligible' )], limit = 1
261+ )
262+ if group.contains(member.pregnancy_status_id):
240263 grant_maternal_benefit()
241264```
242265
243266Or use CEL:
244267
245268``` cel
246- in_group(me.pregnancy_status, "pregnant_eligible")
269+ # Note: pregnancy_status_id is provided by country-specific modules
270+ in_group(r.pregnancy_status_id, "pregnant_eligible")
247271```
248272
249273## Benefits
@@ -258,6 +282,7 @@ in_group(me.pregnancy_status, "pregnant_eligible")
258282
259283- Code resolution uses ` @ormcache ` for fast lookups
260284- Concept group URIs are pre-computed and stored as JSON
285+ - Session-scoped ` VocabularyCache ` eliminates N+1 queries during evaluation
261286- Domain translation happens once at compile time
262287- No per-record overhead in query execution
263288
0 commit comments