Skip to content

Commit 14b1d28

Browse files
Merge pull request #106 from OpenSPP/feat/spp-cel-vocabulary-stable-release
fix: prepare spp_cel_vocabulary for stable release
2 parents 9afb36b + 00919a9 commit 14b1d28

File tree

16 files changed

+1240
-225
lines changed

16 files changed

+1240
-225
lines changed

spp_cel_domain/tests/common.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import time
99

10+
from odoo.fields import Command
11+
1012

1113
class CELTestDataMixin:
1214
"""Mixin for creating CEL test data.
@@ -179,12 +181,12 @@ def _create_test_vocabulary_code(cls, vocabulary=None, code=None, display=None,
179181
return VocabularyCode.create(values)
180182

181183
@classmethod
182-
def _create_test_concept_group(cls, name=None, display_name=None, cel_function=None, codes=None, **kwargs):
184+
def _create_test_concept_group(cls, name=None, label=None, cel_function=None, codes=None, **kwargs):
183185
"""Create a test concept group.
184186
185187
Args:
186188
name: Group name. If not provided, generates unique name
187-
display_name: Display name. If not provided, uses name
189+
label: Human-readable label. If not provided, generates from name
188190
cel_function: CEL function name
189191
codes: List of vocabulary code records to add to group
190192
**kwargs: Additional fields to pass to create()
@@ -194,12 +196,12 @@ def _create_test_concept_group(cls, name=None, display_name=None, cel_function=N
194196
"""
195197
test_id = getattr(cls, "_test_id", int(time.time() * 1000))
196198
name = name or f"test_group_{test_id}"
197-
display_name = display_name or name.replace("_", " ").title()
199+
label = label or name.replace("_", " ").title()
198200

199201
ConceptGroup = cls.env["spp.vocabulary.concept.group"]
200202
values = {
201203
"name": name,
202-
"display_name": display_name,
204+
"label": label,
203205
}
204206
if cel_function:
205207
values["cel_function"] = cel_function
@@ -208,7 +210,7 @@ def _create_test_concept_group(cls, name=None, display_name=None, cel_function=N
208210
group = ConceptGroup.create(values)
209211

210212
if codes:
211-
group.write({"code_ids": [(6, 0, [c.id for c in codes])]})
213+
group.write({"code_ids": [Command.set([c.id for c in codes])]})
212214

213215
return group
214216

spp_cel_vocabulary/README.md

Lines changed: 68 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
This module extends the CEL (Common Expression Language) system with vocabulary-aware
66
functions 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
1418
Resolve 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

2428
Check 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

3337
Safe 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

7785
1. Full URI (e.g., `"urn:iso:std:iso:5218#2"`)
7886
2. Code value in active vocabulary
79-
3. Display name
87+
3. Label (display value)
8088
4. Reference URI mapping (for local codes)
8189

8290
### Concept Groups
@@ -100,7 +108,7 @@ Example:
100108
CEL 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

106114
Translates 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
```
163175
spp_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

182201
1. **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
184204
3. **Function Registry** - Dynamic registration with CEL system
185205
4. **Domain Translation** - AST transformation to Odoo domains
206+
5. **Two-Layer Caching** - `@ormcache` (registry-scoped) + `VocabularyCache`
207+
(session-scoped)
186208

187209
## Testing
188210

189211
Run 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

243266
Or 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

Comments
 (0)