Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6f07239
chore: add maintainers to spp_cel_vocabulary manifest
gonzalesedwin1123 Mar 13, 2026
f6d8254
fix: use label instead of display_name in test concept group helper
gonzalesedwin1123 Mar 13, 2026
8eadd28
docs: fix field names in service docstrings and add sudo() justification
gonzalesedwin1123 Mar 13, 2026
22c692b
fix: improve translator error messages and fix docstring field names
gonzalesedwin1123 Mar 13, 2026
8170630
docs: fix README.md prefix, field names, structure diagram, and examples
gonzalesedwin1123 Mar 13, 2026
68703ce
fix: replace display_name with label and migrate to Command API in tests
gonzalesedwin1123 Mar 13, 2026
04a1f19
docs: fix USAGE.md prefix, field names, patterns, and add decision guide
gonzalesedwin1123 Mar 13, 2026
15295ff
docs: fix data/README.md menu path, XML IDs, label, and Command API
gonzalesedwin1123 Mar 13, 2026
82c965b
docs: add readme/USAGE.md for OCA readme generator
gonzalesedwin1123 Mar 13, 2026
87c54c3
style: apply ruff-format to cel_vocabulary_translator.py
gonzalesedwin1123 Mar 13, 2026
f3c4727
style: apply prettier formatting to markdown files
gonzalesedwin1123 Mar 13, 2026
f352eab
chore: remove readme/USAGE.md placeholder pending QA testing guide
gonzalesedwin1123 Mar 13, 2026
21e5f71
fix: address expert review findings for stable release
gonzalesedwin1123 Mar 13, 2026
b9a2815
docs: regenerate README.rst via oca-gen-addon-readme
gonzalesedwin1123 Mar 13, 2026
00919a9
docs: add UI testing guide for QA in readme/USAGE.md
gonzalesedwin1123 Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions spp_cel_domain/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import time

from odoo.fields import Command


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

@classmethod
def _create_test_concept_group(cls, name=None, display_name=None, cel_function=None, codes=None, **kwargs):
def _create_test_concept_group(cls, name=None, label=None, cel_function=None, codes=None, **kwargs):
"""Create a test concept group.

Args:
name: Group name. If not provided, generates unique name
display_name: Display name. If not provided, uses name
label: Human-readable label. If not provided, generates from name
cel_function: CEL function name
codes: List of vocabulary code records to add to group
**kwargs: Additional fields to pass to create()
Expand All @@ -194,12 +196,12 @@ def _create_test_concept_group(cls, name=None, display_name=None, cel_function=N
"""
test_id = getattr(cls, "_test_id", int(time.time() * 1000))
name = name or f"test_group_{test_id}"
display_name = display_name or name.replace("_", " ").title()
label = label or name.replace("_", " ").title()

ConceptGroup = cls.env["spp.vocabulary.concept.group"]
values = {
"name": name,
"display_name": display_name,
"label": label,
}
if cel_function:
values["cel_function"] = cel_function
Expand All @@ -208,7 +210,7 @@ def _create_test_concept_group(cls, name=None, display_name=None, cel_function=N
group = ConceptGroup.create(values)

if codes:
group.write({"code_ids": [(6, 0, [c.id for c in codes])]})
group.write({"code_ids": [Command.set([c.id for c in codes])]})

return group

Expand Down
111 changes: 68 additions & 43 deletions spp_cel_vocabulary/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
This module extends the CEL (Common Expression Language) system with vocabulary-aware
functions that enable robust eligibility rules across different deployment vocabularies.

> **Note:** Both `r` and `me` are valid prefixes for the registrant symbol. This guide
> uses `r` (matching ADR-008). The `me` alias is available via YAML profile
> configuration.

## Features

### Core Functions
Expand All @@ -14,26 +18,26 @@ functions that enable robust eligibility rules across different deployment vocab
Resolve a vocabulary code by URI or alias.

```cel
me.gender == code("urn:iso:std:iso:5218#2") # By URI
me.gender == code("female") # By alias
me.gender == code("babae") # Local alias (Philippines)
r.gender_id == code("urn:iso:std:iso:5218#2") # By URI
r.gender_id == code("female") # By alias
r.gender_id == code("babae") # Local alias (Philippines)
```

#### `in_group(code_field, group_name)`

Check if a vocabulary code belongs to a concept group.

```cel
in_group(me.gender, "feminine_gender")
members.exists(m, in_group(m.gender, "feminine_gender"))
in_group(r.gender_id, "feminine_gender")
members.exists(m, in_group(m.gender_id, "feminine_gender"))
```

#### `code_eq(code_field, identifier)`

Safe code comparison handling local code mappings.

```cel
code_eq(me.gender, "female")
code_eq(r.gender_id, "female")
```

### Semantic Helpers
Expand All @@ -44,28 +48,32 @@ User-friendly functions for common checks:
- `is_male(code_field)` - Check if code is in masculine_gender group
- `is_head(code_field)` - Check if code is in head_of_household group
- `is_pregnant(code_field)` - Check if code is in pregnant_eligible group
- `head(member)` - Check if a member is the head of household (takes a member record,
not a code field)

### Example Usage

#### Simple Gender Check

```cel
is_female(me.gender)
is_female(r.gender_id)
```

#### Complex Eligibility Rule

```cel
# Pregnant women or mothers with children under 5
is_pregnant(me.pregnancy_status) or
# Note: pregnancy_status_id is provided by country-specific modules
is_pregnant(r.pregnancy_status_id) or
members.exists(m, age_years(m.birthdate) < 5)
```

#### Local Code Support

```cel
# Works in any deployment, even with local terminology
in_group(me.hazard_type, "climate_hazards")
# Note: hazard_type_id is provided by country-specific modules
in_group(r.hazard_type_id, "climate_hazards")
```

## How It Works
Expand All @@ -76,7 +84,7 @@ The `code()` function resolves identifiers in this order:

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

### Concept Groups
Expand All @@ -100,7 +108,7 @@ Example:
CEL expressions are translated to Odoo domains:

```cel
in_group(me.gender, "feminine_gender")
in_group(r.gender_id, "feminine_gender")
```

Translates to:
Expand All @@ -120,24 +128,28 @@ Translates to:

## Configuration

### Defining Concept Groups
### Concept Groups

Standard concept groups are created automatically via `post_init_hook` on module
installation (search-or-create pattern, safe for upgrades). They have no XML IDs — look
them up by name.

Create concept groups via UI or data files:
To add codes to a group via data files:

```xml
<record id="group_feminine_gender" model="spp.vocabulary.concept.group">
<field name="name">feminine_gender</field>
<field name="display_name">Feminine Gender</field>
<field name="cel_function">is_female</field>
<field name="description">Codes representing feminine gender identity</field>
<field
name="code_ids"
eval="[
(4, ref('spp_vocabulary.code_female')),
(4, ref('spp_vocabulary_ph.code_babae'))
]"
/>
</record>
<!-- Look up by name since groups are created by hook, not XML -->
<function model="spp.vocabulary.concept.group" name="search">
<!-- Use write() to add codes after finding the group -->
</function>
```

Or via Python:

```python
group = env['spp.vocabulary.concept.group'].search(
[('name', '=', 'feminine_gender')], limit=1
)
group.write({'code_ids': [Command.link(female_code.id)]})
```

### Local Code Mapping
Expand All @@ -161,36 +173,45 @@ Map local codes to standard codes:

```
spp_cel_vocabulary/
├── __init__.py
├── __init__.py # post_init_hook, concept group creation
├── __manifest__.py
├── models/
│ ├── __init__.py
│ ├── cel_vocabulary_functions.py # Function registration
│ └── cel_vocabulary_translator.py # Domain translation
│ ├── cel_vocabulary_functions.py # Function registration
│ └── cel_vocabulary_translator.py # Domain translation
├── services/
│ ├── __init__.py
│ └── cel_vocabulary_functions.py # Pure Python functions
│ ├── cel_vocabulary_functions.py # Pure Python functions
│ └── vocabulary_cache.py # Session-scoped cache
├── tests/
│ ├── __init__.py
│ └── test_cel_vocabulary.py # Comprehensive tests
└── security/
└── ir.model.access.csv
│ ├── test_cel_vocabulary.py # Core function and translation tests
│ ├── test_vocabulary_cache.py # Cache behavior tests
│ ├── test_vocabulary_in_exists.py # Vocabulary in exists() predicates
│ └── test_init_and_coverage.py # Init, edge cases, coverage
├── security/
│ └── ir.model.access.csv
└── data/
├── concept_groups.xml # Documentation (groups created by hook)
└── README.md # Data configuration guide
```

### Design Patterns

1. **Pure Functions** - Services contain stateless Python functions
2. **Environment Injection** - Models wrap functions with Odoo env
2. **Environment Injection** - Functions marked with `_cel_needs_env=True`; CEL service
injects fresh env at evaluation time
3. **Function Registry** - Dynamic registration with CEL system
4. **Domain Translation** - AST transformation to Odoo domains
5. **Two-Layer Caching** - `@ormcache` (registry-scoped) + `VocabularyCache`
(session-scoped)

## Testing

Run tests:

```bash
# From openspp-odoo-19-migration/ directory
invoke test-spp-deps --modules=spp_cel_vocabulary --skip=queue_job --mode=update --db-filter='^devel$'
./scripts/test_single_module.sh spp_cel_vocabulary
```

## Related Documentation
Expand All @@ -207,20 +228,20 @@ invoke test-spp-deps --modules=spp_cel_vocabulary --skip=queue_job --mode=update
**Before (fragile):**

```cel
me.gender == "female"
r.gender_id == "female"
```

**After (robust):**

```cel
# Option 1: Semantic helper
is_female(me.gender)
is_female(r.gender_id)

# Option 2: Concept group
in_group(me.gender, "feminine_gender")
in_group(r.gender_id, "feminine_gender")

# Option 3: Safe comparison
code_eq(me.gender, "female")
code_eq(r.gender_id, "female")
```

### From Hardcoded Values
Expand All @@ -235,15 +256,18 @@ if member.pregnancy_status_id.code == "pregnant":
**After:**

```python
pregnant_group = env.ref('spp_vocabulary.group_pregnant_eligible')
if pregnant_group.contains(member.pregnancy_status_id):
group = env['spp.vocabulary.concept.group'].search(
[('name', '=', 'pregnant_eligible')], limit=1
)
if group.contains(member.pregnancy_status_id):
grant_maternal_benefit()
```

Or use CEL:

```cel
in_group(me.pregnancy_status, "pregnant_eligible")
# Note: pregnancy_status_id is provided by country-specific modules
in_group(r.pregnancy_status_id, "pregnant_eligible")
```

## Benefits
Expand All @@ -258,6 +282,7 @@ in_group(me.pregnancy_status, "pregnant_eligible")

- Code resolution uses `@ormcache` for fast lookups
- Concept group URIs are pre-computed and stored as JSON
- Session-scoped `VocabularyCache` eliminates N+1 queries during evaluation
- Domain translation happens once at compile time
- No per-record overhead in query execution

Expand Down
Loading
Loading