diff --git a/spp_code_generator/COMPUTE_METHOD_ARCHITECTURE.md b/spp_code_generator/COMPUTE_METHOD_ARCHITECTURE.md new file mode 100644 index 000000000..9691baf3e --- /dev/null +++ b/spp_code_generator/COMPUTE_METHOD_ARCHITECTURE.md @@ -0,0 +1,448 @@ +# Compute Method Architecture - Indicator Fields + +## Overview + +This document explains the architecture for generated compute methods in indicator fields, showing how +individual and group indicators are processed differently. + +--- + +## Two-Strategy Approach + +### Strategy Matrix + +| Indicator Type | Method Used | CEL Profile | Returns | Use Case | +| --------------------- | ----------------------------------- | -------------------- | ------------- | --------------------- | +| **Individual (indv)** | `_exec()` | registry_individuals | Boolean | Individual conditions | +| **Group (grp)** | `compute_count_and_set_indicator()` | registry_groups | Boolean/Count | Member aggregation | + +--- + +## Individual Indicators (indv) + +### Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Individual Indicator │ +│ (e.g., x_ind_indv_age_yrs) │ +└────────────────┬────────────────────────────────┘ + │ + ▼ + ┌───────────────┐ + │ _exec() │ ← Direct CEL execution + └───────┬───────┘ + │ + ▼ + ┌──────────────────────┐ + │ CEL Executor │ + │ (registry_indiv) │ + └──────────┬───────────┘ + │ + ▼ + ┌───────────────┐ + │ Return True │ if record matches + │ or False │ + └───────────────┘ +``` + +### Generated Code Pattern + +```python +for record in self: + try: + if record.is_group: + # Skip groups for individual indicators + record.x_ind_indv_field_name = None + continue + + # Execute CEL expression using _exec helper + result = record._exec( + 'CEL_EXPRESSION_HERE', + profile='registry_individuals' + ) + + # Check if current record matches + record.x_ind_indv_field_name = bool(record.id in result.get('ids', [])) + except Exception as e: + record.x_ind_indv_field_name = None +``` + +### Example - Age Calculation + +**YAML**: + +```yaml +- id: "age_yrs" + label: "Age (years)" + expression: "age_years(me.birthdate) >= 18" + dependencies: ["birthdate"] +``` + +**Generated Compute Method**: + +```python +def _compute_age_yrs(self): + for record in self: + try: + if record.is_group: + record.x_ind_indv_age_yrs = None + continue + + result = record._exec( + 'age_years(me.birthdate) >= 18', + profile='registry_individuals' + ) + + record.x_ind_indv_age_yrs = bool(record.id in result.get('ids', [])) + except Exception as e: + record.x_ind_indv_age_yrs = None +``` + +**Flow**: + +1. Check if record is individual +2. Call `_exec()` with expression +3. CEL executor evaluates expression +4. Returns True if individual is 18+ +5. Field updated automatically + +--- + +## Group Indicators (grp) + +### Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Group Indicator │ +│ (e.g., x_ind_grp_hh_has_pregnant_member) │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────┐ +│ compute_count_and_set_indicator() │ ← Extended in group.py +└────────────────┬───────────────────────┘ + │ + ▼ + ┌───────────────┐ + │ _exec() │ ← Called internally + └───────┬───────┘ + │ + ▼ + ┌──────────────────────┐ + │ CEL Executor │ + │ (registry_groups) │ + └──────────┬───────────┘ + │ + ▼ + ┌──────────────────────┐ + │ Domain Conversion │ ← CEL → Odoo domain + └──────────┬───────────┘ + │ + ▼ + ┌──────────────────────┐ + │ Filter Members │ + └──────────┬───────────┘ + │ + ▼ + ┌───────────────┐ + │ Return │ Boolean (exists) or + │ Result │ Integer (count) + └───────────────┘ +``` + +### Generated Code Pattern + +```python +for record in self: + try: + if not record.is_group: + # Skip individuals for group indicators + record.x_ind_grp_field_name = None + continue + + # Use compute_count_and_set_indicator with CEL expression + kinds = None + domain = [] + cel_expression = 'CEL_EXPRESSION_HERE' + + # Extended method handles CEL → domain conversion + record.compute_count_and_set_indicator( + 'x_ind_grp_field_name', + kinds, + domain, + presence_only=True, # Boolean for exists checks + cel_expression=cel_expression + ) + except Exception as e: + record.x_ind_grp_field_name = None +``` + +### Example - Has Pregnant Member + +**YAML**: + +```yaml +- id: "hh_has_pregnant_member" + label: "HH Has Pregnant Member" + expression: "members.exists(m, m.pregnancy_start_date >= months_ago(9) and m.pregnancy_end_date == null)" + dependencies: [] +``` + +**Generated Compute Method**: + +```python +def _compute_hh_has_pregnant_member(self): + for record in self: + try: + if not record.is_group: + record.x_ind_grp_hh_has_pregnant_member = None + continue + + kinds = None + domain = [] + cel_expression = 'members.exists(m, m.pregnancy_start_date >= months_ago(9) and m.pregnancy_end_date == null)' + + record.compute_count_and_set_indicator( + 'x_ind_grp_hh_has_pregnant_member', + kinds, + domain, + presence_only=True, + cel_expression=cel_expression + ) + except Exception as e: + record.x_ind_grp_hh_has_pregnant_member = None +``` + +**Flow**: + +1. Check if record is group +2. Call `compute_count_and_set_indicator()` +3. Method calls `_exec()` internally with `registry_groups` +4. CEL expression converted to Odoo domain +5. Domain filters group members +6. Returns True if any member matches +7. Field updated automatically + +--- + +## Extended Method in group.py + +### Implementation + +```python +class GroupRegistry(models.Model): + _inherit = "res.partner" + + def _exec(self, expr, profile="registry_individuals"): + """Helper to execute CEL expression.""" + registry = self.env["cel.registry"] + cfg = registry.load_profile(profile) + executor = self.env["cel.executor"].with_context(cel_profile=profile, cel_cfg=cfg) + model = cfg.get("root_model", "res.partner") + return executor.compile_and_preview(model, expr, limit=50) + + def compute_count_and_set_indicator(self, field_name, kinds, domain, presence_only=False, cel_expression=None): + if cel_expression: + # Convert CEL expression to domain + domain += self._exec(cel_expression, profile="registry_groups").get("domain", []) + _logger.info(f"CEL expression: {cel_expression} \n => domain: {domain}") + + # Call parent method with augmented domain + return super().compute_count_and_set_indicator(field_name, kinds, domain, presence_only, cel_expression=None) +``` + +### Key Points + +1. **CEL Expression Handling**: + + - Accepts CEL expression as parameter + - Calls `_exec()` with `registry_groups` profile + - Extracts domain from result + +2. **Domain Augmentation**: + + - Adds CEL-generated domain to existing domain + - Passes to parent method + +3. **Logging**: + - Logs CEL expression and resulting domain + - Useful for debugging + +--- + +## Comparison Table + +| Aspect | Individual (indv) | Group (grp) | +| ----------------- | --------------------- | ----------------------------------- | +| **Entry Point** | `_exec()` directly | `compute_count_and_set_indicator()` | +| **CEL Profile** | registry_individuals | registry_groups | +| **Context** | Single record (me) | Members collection | +| **Execution** | Direct evaluation | Domain conversion + filtering | +| **Result Format** | Boolean match | Boolean (exists) or Integer (count) | +| **Use Case** | Individual properties | Member aggregation | +| **Example** | Age check, date range | Has children, count eligible | + +--- + +## Benefits of This Architecture + +### 1. **Separation of Concerns** + +- Individual logic isolated from group logic +- Clear, maintainable code structure +- Easy to debug and test + +### 2. **Leverages OpenSPP Infrastructure** + +- Uses existing `compute_count_and_set_indicator` method +- Consistent with spp_custom_field patterns +- Integrates with spp_custom_fields_ui + +### 3. **CEL Integration** + +- Full CEL expression support +- Proper domain conversion +- Helper functions available (age_years, months_ago, etc.) + +### 4. **Performance** + +- Stored fields for query performance +- Efficient domain-based filtering for groups +- Automatic caching and invalidation + +### 5. **Flexibility** + +- Easy to extend with new CEL functions +- Can handle complex expressions +- Supports both boolean and count results + +--- + +## Code Generation Process + +### 1. Determine Indicator Type + +```python +def _determine_indicator_type(self, expression, dependencies): + group_patterns = [ + "members.exists", + "members.count", + "group_membership_ids", + ] + + if any(pattern in expression.lower() for pattern in group_patterns): + return "grp" + return "indv" +``` + +### 2. Generate Appropriate Code + +```python +def _generate_compute_method_code(self, field_name, expression, indicator_type, dependencies): + if indicator_type == "indv": + return self._generate_individual_code(field_name, expression) + elif indicator_type == "grp": + return self._generate_group_code(field_name, expression) +``` + +### 3. Create Field with Compute Method + +```python +field_values = { + "name": field_name, + "ttype": field_type, + "store": True, + "compute": f"_compute_{field_id}", + "target_type": indicator_type, + "field_category": "ind", + ... +} +``` + +--- + +## Testing Examples + +### Test Individual Indicator + +```python +# Create individual with birthdate +individual = env['res.partner'].create({ + 'name': 'Test Person', + 'is_group': False, + 'birthdate': '2000-01-01' +}) + +# Trigger computation +individual._compute_age_yrs() + +# Check result +print(individual.x_ind_indv_age_yrs) # True (age >= 18) +``` + +### Test Group Indicator + +```python +# Create group with members +group = env['res.partner'].create({ + 'name': 'Test Household', + 'is_group': True +}) + +# Add member +member = env['res.partner'].create({ + 'name': 'Pregnant Member', + 'is_group': False, + 'pregnancy_start_date': fields.Date.today(), +}) + +# Add to group +env['g2p.group.membership'].create({ + 'group': group.id, + 'individual': member.id +}) + +# Trigger computation +group._compute_hh_has_pregnant_member() + +# Check result +print(group.x_ind_grp_hh_has_pregnant_member) # True +``` + +--- + +## Troubleshooting + +### Issue: Individual indicator not computing + +**Check**: + +1. Record is individual (`is_group=False`) +2. `_exec()` method exists in model +3. CEL profile `registry_individuals` exists +4. Expression syntax is valid + +### Issue: Group indicator returning None + +**Check**: + +1. Record is group (`is_group=True`) +2. `compute_count_and_set_indicator()` extended in group.py +3. CEL profile `registry_groups` exists +4. Group has members (group_membership_ids) + +--- + +## References + +- **group.py**: Extended compute_count_and_set_indicator +- **cel_executor.py**: CEL expression execution +- **cel_functions.py**: Helper functions (age_years, months_ago, etc.) +- **spp_custom_field**: UI rendering and tab organization +- **spp_custom_fields_ui**: Field management and filtering + +--- + +**Last Updated**: 2025-10-27 **Module**: spp_code_generator v17.0.1.3.1 **Author**: OpenSPP.org diff --git a/spp_code_generator/DERIVED_FIELDS_GUIDE.md b/spp_code_generator/DERIVED_FIELDS_GUIDE.md new file mode 100644 index 000000000..5ec24a24b --- /dev/null +++ b/spp_code_generator/DERIVED_FIELDS_GUIDE.md @@ -0,0 +1,596 @@ +# Derived Fields (Indicators) Processing Guide + +## Overview + +This guide explains how the Code Generator module processes `derived_fields` from YAML files to create +computed indicator fields in OpenSPP. Derived fields are computed/calculated fields that automatically update +based on other field values or member data. + +## Architecture + +### Data Flow + +``` +YAML derived_fields → Parse → Determine Type → Generate Compute Code → Create Field +``` + +### Key Components + +1. **Model**: `spp.code.generator` (`models/code_generator.py`) +2. **CEL Functions**: Helper functions from `spp_cel_domain` (`cel_functions.py`) +3. **Target**: `res.partner` model with computed indicator fields +4. **Integration**: `spp_custom_fields_ui` for UI display + +--- + +## Field Naming Convention + +### Indicator Field Prefixes + +Derived fields use **indicator** prefixes (not custom): + +- **Group Indicators**: `x_ind_grp_{field_id}` +- **Individual Indicators**: `x_ind_indv_{field_id}` + +### Examples + +| YAML Field ID | Entity Type | Odoo Field Name | Purpose | +| ---------------------- | ----------- | -------------------------------- | ------------------- | +| age_yrs | Individual | x_ind_indv_age_yrs | Age in years | +| ind_pregnant_now | Individual | x_ind_indv_ind_pregnant_now | Currently pregnant | +| hh_has_pregnant_member | Group | x_ind_grp_hh_has_pregnant_member | Has pregnant member | + +--- + +## YAML Structure + +### Derived Fields Section + +```yaml +derived_fields: + - id: "age_yrs" + label: "Age (years)" + expression: "age_years(me.birthdate)" + purpose: "eligibility, reporting" + dependencies: ["birthdate"] + + - id: "ind_pregnant_now" + label: "Currently Pregnant" + expression: "me.pregnancy_start_date >= months_ago(9) and me.pregnancy_end_date == null" + purpose: "eligibility, health condition routing" + dependencies: ["pregnancy_start_date", "pregnancy_end_date"] + + - id: "hh_has_pregnant_member" + label: "HH Has Pregnant Member" + expression: "members.exists(m, m.pregnancy_start_date >= months_ago(9) and m.pregnancy_end_date == null)" + purpose: "eligibility, health grant" + dependencies: [] +``` + +### Field Specification + +Each derived field requires: + +- **id** (string, required): Unique field identifier +- **label** (string, required): Human-readable field label +- **expression** (string, required): CEL expression for computation +- **purpose** (string, optional): Business purpose/usage description +- **dependencies** (array, optional): List of field names this indicator depends on + +--- + +## Expression Types + +### 1. Individual Expressions (indv) + +**Pattern**: CEL expressions evaluated for individual records + +```yaml +expression: "age_years(me.birthdate) >= 18" +``` + +**Generated Code**: + +```python +for record in self: + try: + if record.is_group: + record.x_ind_indv_age_yrs = None + continue + + # Execute CEL expression using _exec helper + result = record._exec( + 'age_years(me.birthdate) >= 18', + profile='registry_individuals' + ) + + # Check if current record matches the expression + record.x_ind_indv_age_yrs = bool(record.id in result.get('ids', [])) + except Exception as e: + record.x_ind_indv_age_yrs = None +``` + +**How it works**: + +- Uses `_exec()` helper method +- Calls CEL executor with `registry_individuals` profile +- Returns True/False based on whether record matches expression +- Skips group records (only for individuals) + +**Use Cases**: + +- Age calculations and comparisons +- Date range checks +- Complex individual conditions + +--- + +### 2. Group Aggregation Expressions (grp) + +**Pattern**: CEL expressions with member aggregation (exists, count, etc.) + +```yaml +expression: "members.exists(m, m.pregnancy_start_date >= months_ago(9) and m.pregnancy_end_date == null)" +``` + +**Generated Code**: + +```python +for record in self: + try: + if not record.is_group: + record.x_ind_grp_hh_has_pregnant_member = None + continue + + # Use compute_count_and_set_indicator with CEL expression + # The extended method in group.py will handle domain conversion + kinds = None + domain = [] + cel_expression = 'members.exists(m, m.pregnancy_start_date >= months_ago(9) and m.pregnancy_end_date == null)' + + # Call the extended method that processes CEL expressions + record.compute_count_and_set_indicator( + 'x_ind_grp_hh_has_pregnant_member', + kinds, + domain, + presence_only=True, # Returns boolean for exists-style checks + cel_expression=cel_expression + ) + except Exception as e: + record.x_ind_grp_hh_has_pregnant_member = None +``` + +**How it works**: + +- Uses `compute_count_and_set_indicator()` method +- Extended in `group.py` to handle CEL expressions +- Converts CEL expression to Odoo domain +- Uses `_exec()` with `registry_groups` profile +- Filters group members based on the criteria +- Returns boolean (presence_only=True) or count + +**Use Cases**: + +- Household has children under 18 +- Group has disabled members +- Family has eligible students +- Count of members matching criteria + +--- + +### 3. Implementation Notes + +Both strategies use the same underlying CEL engine but with different entry points: + +**Individual Strategy (\_exec)**: + +- Directly calls CEL executor +- Uses `registry_individuals` profile +- Evaluates expression in context of individual records +- Returns boolean based on match + +**Group Strategy (compute_count_and_set_indicator)**: + +- Calls extended method from `group.py` +- Uses `registry_groups` profile +- Converts CEL expression to Odoo domain +- Filters group members +- Supports both boolean (presence_only=True) and count results + +**Benefits**: + +- Leverages existing OpenSPP infrastructure +- Consistent with spp_custom_fields_ui patterns +- CEL expressions validated and executed properly +- Domain conversion handled automatically + +--- + +## CEL Helper Functions + +The following functions are available in expressions (from `cel_functions.py`): + +### Date/Time Functions + +```python +today() # Returns today's date +now() # Returns current datetime +days_ago(n) # Date n days ago +months_ago(n) # Date n months ago +years_ago(n) # Date n years ago +age_years(date) # Calculate age in years from birthdate +``` + +### Comparison Functions + +```python +between(x, a, b) # Check if x is between a and b (inclusive) +``` + +### Usage Examples + +```yaml +# Age calculation +expression: "age_years(me.birthdate)" + +# Pregnancy check (last 9 months) +expression: "me.pregnancy_start_date >= months_ago(9)" + +# Age range check +expression: "between(age_years(me.birthdate), 0, 18)" +``` + +--- + +## Indicator Type Detection + +The system automatically determines if an indicator is for groups or individuals: + +### Group Indicators + +Detected by presence of: + +- `members.exists` +- `members.count` +- `group_membership_ids` +- `compute_count_and_set_indicator` + +### Individual Indicators + +Default for expressions that: + +- Use `me.` for self-reference +- Don't contain group aggregation patterns +- Reference individual fields directly + +--- + +## Field Metadata + +### Automatically Set Fields + +When creating indicator fields, the system sets: + +```python +{ + "ttype": "boolean" or "integer", # Based on expression analysis + "store": True, # Stored for performance + "compute": "_compute_{field_id}", # Compute method name + "state": "manual", # Manual field (can be deleted) + "target_type": "grp" or "indv", # For UI filtering + "field_category": "ind", # Indicator (not custom) + "draft_name": field_id, # Original YAML field ID + "depends": "field1,field2", # Dependency fields + "help": "...", # Purpose + expression + dependencies +} +``` + +### Field Type Determination + +```python +if "exists" or "has" or "is_" in expression: + field_type = "boolean" +elif "count" or "sum" or "age_years" in expression: + field_type = "integer" +else: + field_type = "boolean" # Default +``` + +--- + +## Processing Workflow + +### Step-by-Step Process + +1. **Load YAML File** + + - Parse YAML content + - Extract `derived_fields` section + - Validate field specifications + +2. **For Each Derived Field**: + + - Extract field metadata (id, label, expression, etc.) + - Determine indicator type (group/individual) + - Generate field name with appropriate prefix + - Check if field already exists (skip if yes) + - Generate compute method code + - Determine field type + - Create field in `ir.model.fields` + +3. **Generate Processing Log**: + + - Log each field created/skipped/errored + - Show expression for each field + - Generate summary statistics + +4. **Update Record**: + - Append to processing log + - Post summary in chatter + - Show success notification + +--- + +## UI Integration + +### Form View Buttons + +``` +┌─────────────────────────────────────────┐ +│ [Process Entities] [Process Derived │ ← Two buttons +│ Fields] ● Draft │ +├─────────────────────────────────────────┤ +``` + +### Processing Order + +**Recommended**: + +1. First: Process Entities (creates base fields) +2. Second: Process Derived Fields (creates indicators) + +**Reason**: Derived fields may depend on entity fields + +### Field Display + +Indicator fields automatically appear in: + +- **Indicators Tab** (via `spp_custom_field` module) +- **Group Forms** (for grp indicators) +- **Individual Forms** (for indv indicators) +- **Custom Fields Management** (via `spp_custom_fields_ui`) + +--- + +## Example Processing Log + +``` +================================================================================ +Processing Derived Fields from: 4ps_best_practice_example_v7.yaml +Started at: 2025-10-23 15:30:00 +================================================================================ + + ✓ Created: age_yrs (Age (years)) - Type: indv indicator + Expression: age_years(me.birthdate) + ✓ Created: ind_pregnant_now (Currently Pregnant) - Type: indv indicator + Expression: me.pregnancy_start_date >= months_ago(9) and me.pregnancy_end_date == null + ✓ Created: hh_has_pregnant_member (HH Has Pregnant Member) - Type: grp indicator + Expression: members.exists(m, m.pregnancy_start_date >= months_ago(9) and m.pregnancy_end_date == null) + +================================================================================ +SUMMARY +================================================================================ +Indicator fields created: 3 +Indicator fields skipped: 0 +Errors encountered: 0 +Completed at: 2025-10-23 15:30:15 +================================================================================ +``` + +--- + +## Error Handling + +### Common Errors + +**1. Invalid Expression Syntax** + +``` +Error: Failed to parse CEL expression +Solution: Check expression syntax, ensure proper CEL format +``` + +**2. Missing Dependencies** + +``` +Error: Field 'birthdate' not found +Solution: Process entities first, or create dependent fields manually +``` + +**3. Duplicate Field** + +``` +Skipped: Field x_ind_indv_age_yrs already exists +Solution: Field already created, this is expected behavior +``` + +### Error Recovery + +- Errors are logged but don't stop processing +- Other fields continue to be processed +- Error count shown in summary +- Detailed error messages in processing log + +--- + +## Performance Considerations + +### Stored vs Computed + +All indicator fields are: + +- **Stored**: `store=True` for query performance +- **Computed**: Calculate on demand when dependencies change +- **Indexed**: For fields used in filters + +### Dependency Management + +The system tracks field dependencies: + +```python +depends="birthdate,pregnancy_start_date" +``` + +This ensures: + +- Automatic recomputation when dependencies change +- Efficient update propagation +- Cache invalidation + +--- + +## Advanced Usage + +### Custom Compute Logic + +For complex scenarios, you can modify the generated compute code by: + +1. Creating the field through YAML processing +2. Manually editing the compute method in a custom Python module +3. Inheriting `res.partner` and overriding the compute method + +### CEL Expression Testing + +Test expressions before adding to YAML: + +```python +# In Odoo shell +registry = env['cel.registry'] +cfg = registry.load_profile('registry_individuals') +executor = env['cel.executor'].with_context(cel_profile='registry_individuals', cel_cfg=cfg) +result = executor.compile_and_preview( + 'res.partner', + 'age_years(me.birthdate) >= 18', + limit=10 +) +print(result) +``` + +--- + +## Best Practices + +### 1. Clear Naming + +Use descriptive field IDs: + +```yaml +✓ Good: age_yrs, ind_pregnant_now, hh_has_children +✗ Bad: field1, calc2, ind3 +``` + +### 2. Document Purpose + +Always include purpose: + +```yaml +purpose: "eligibility, reporting, payments" +``` + +### 3. List Dependencies + +Explicitly declare dependencies: + +```yaml +dependencies: ["birthdate", "pregnancy_start_date"] +``` + +### 4. Test Expressions + +Test expressions with sample data before YAML upload + +### 5. Incremental Processing + +- Process entities first +- Then process derived fields +- Verify fields appear correctly in UI + +--- + +## Troubleshooting + +### Issue: Indicator Not Appearing + +**Check**: + +1. Field created successfully (check processing log) +2. Target type matches form (grp for groups, indv for individuals) +3. Field category is "ind" +4. Odoo server restarted (for new fields) + +### Issue: Computation Error + +**Check**: + +1. Dependencies exist +2. Expression syntax is valid +3. CEL functions are available +4. Data types match expression expectations + +### Issue: Performance Slow + +**Solutions**: + +1. Ensure fields are stored (`store=True`) +2. Add indexes to frequently filtered fields +3. Optimize complex CEL expressions +4. Consider caching for expensive computations + +--- + +## Integration with Other Modules + +### spp_custom_field + +- Provides UI rendering for indicator fields +- Creates "Indicators" tab in forms +- Marks fields as readonly (computed) + +### spp_custom_fields_ui + +- Enables field management +- Provides `target_type` and `field_category` fields +- Filters fields by entity type + +### spp_cel_domain + +- Provides CEL expression parser +- Handles complex query logic +- Supplies helper functions + +--- + +## Future Enhancements + +Potential additions: + +1. **Expression Validation**: Pre-validate CEL expressions before field creation +2. **Compute Optimization**: Smart caching for expensive computations +3. **Batch Recomputation**: Efficient bulk recalculation +4. **Expression Builder**: UI for building CEL expressions +5. **Test Data**: Generate test cases from expressions +6. **Performance Metrics**: Track computation time per indicator + +--- + +## References + +- OpenSPP Field Naming: See `openspp-fields-naming` cursor rule +- CEL Functions: `spp_cel_domain/services/cel_functions.py` +- Custom Fields UI: `spp_custom_fields_ui/models/custom_fields_ui.py` +- Group Indicators: `spp_code_generator/models/group.py` +- YAML Specification: `4ps_best_practice_example_v7.yaml` + +--- + +**Last Updated**: 2025-10-23 **Module Version**: 17.0.1.3.1 **Author**: OpenSPP.org diff --git a/spp_code_generator/IMPLEMENTATION_GUIDE.md b/spp_code_generator/IMPLEMENTATION_GUIDE.md new file mode 100644 index 000000000..aed5efa55 --- /dev/null +++ b/spp_code_generator/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,470 @@ +# Code Generator Module - Entity Processing Implementation Guide + +## Overview + +This document explains the implementation of the YAML entity processing feature in the `spp_code_generator` +module. The feature reads YAML specification files and automatically creates custom fields in `res.partner` +based on the entity definitions. + +## Architecture + +### Data Flow + +``` +YAML File Upload → Validation → Parse → Extract Entities → Create Fields → Log Results +``` + +### Components + +1. **Model**: `spp.code.generator` (`models/code_generator.py`) +2. **View**: Form with "Process Entities" button (`views/code_generator_views.xml`) +3. **Target**: `res.partner` model (where fields are created) + +--- + +## Model Fields + +### Core Fields + +| Field | Type | Description | +| ---------------- | --------- | ----------------------------------- | +| `name` | Char | YAML filename (auto-populated) | +| `yaml_file` | Binary | The uploaded YAML file | +| `description` | Text | User-provided description | +| `state` | Selection | Processing status (draft/processed) | +| `processing_log` | Text | Detailed log of processing results | + +### States + +- **draft**: Initial state when YAML is uploaded +- **processed**: After successful entity processing + +--- + +## Key Methods + +### 1. `_load_yaml_content()` + +**Purpose**: Load and parse the YAML file content + +**Process**: + +1. Check if YAML file exists +2. Decode binary content (base64) +3. Parse YAML using `yaml.safe_load()` +4. Return Python dictionary + +**Returns**: `dict` - Parsed YAML content + +**Raises**: `UserError` if file missing or parsing fails + +--- + +### 2. `_get_odoo_field_type(yaml_field_type)` + +**Purpose**: Map YAML field types to Odoo field types + +**Type Mappings**: + +```python +"string" → "char" +"date" → "date" +"datetime" → "datetime" +"enum" → "selection" +"boolean" → "boolean" +"integer" → "integer" +"float" → "float" +"text" → "text" +"admin_code"→ "char" +``` + +**Args**: + +- `yaml_field_type` (str): Type from YAML spec + +**Returns**: `str` - Odoo field type + +--- + +### 3. `_create_field_from_spec(entity_name, field_spec)` + +**Purpose**: Create a single field in `res.partner` from YAML specification + +**Process**: + +1. Extract field specification (id, label, type, required, etc.) +2. Generate field name with OpenSPP prefix: `z_cst_{field_id}` +3. Check if field already exists (skip if yes) +4. Map YAML type to Odoo type +5. Prepare field values dict +6. Handle special cases (enum/selection fields) +7. Create field using `ir.model.fields` + +**Field Naming Convention**: + +``` +YAML Entity: "Household", Field: "hh_id" +Odoo: "x_cst_grp_hh_id" + +YAML Entity: "Individual", Field: "person_id" +Odoo: "x_cst_indv_person_id" + +Prefix breakdown: +- x_ : Odoo custom field prefix +- cst_ : Custom (not indicator) +- grp_ : Group/Household fields +- indv_ : Individual fields +- field_id : Original field ID from YAML + +This matches the spp_custom_fields_ui module conventions for proper UI integration. +``` + +**Args**: + +- `entity_name` (str): Entity name (e.g., "Household") +- `field_spec` (dict): Field specification from YAML + +**Field Spec Structure**: + +```yaml +{ + "id": "field_name", # Required: Technical name + "label": "Field Label", # Required: Display label + "type": "string", # Required: Field type + "required": True/False, # Optional: Is required? + "values": [...], # Optional: For enums + "description": "...", # Optional: Help text +} +``` + +**Returns**: `ir.model.fields` record or `None` if exists + +--- + +### 4. `action_process_entities()` + +**Purpose**: Main processing method - button action + +**Process**: + +1. Load YAML content +2. Extract "entities" section +3. Initialize processing log +4. For each entity: + - Get entity details (name, label, fields) + - For each field: + - Call `_create_field_from_spec()` + - Log result (created/skipped/error) +5. Generate summary +6. Update `state` to "processed" +7. Post message in chatter +8. Show success notification + +**Returns**: `dict` - Client action (notification) + +**Logging**: Creates detailed processing log showing: + +- Each entity processed +- Each field created/skipped +- Any errors encountered +- Summary statistics + +--- + +## YAML Structure Example + +Based on `4ps_best_practice_example_v7.yaml`: + +```yaml +entities: + - name: "Household" + label: "4Ps Household" + fields: + - id: "hh_id" + label: "Household ID" + type: "string" + required: true + - id: "province" + label: "Province" + type: "admin_code" + + - name: "Individual" + label: "Member" + fields: + - id: "person_id" + label: "Member ID" + type: "string" + required: true + - id: "birthdate" + label: "Date of Birth" + type: "date" + required: true + - id: "gender" + label: "Gender" + type: "enum" + values: ["Female", "Male"] + required: true +``` + +--- + +## Field Creation Results + +### Example Output + +For the above YAML, the following fields would be created in `res.partner`: + +| Entity | YAML Field ID | Odoo Field Name | Type | Required | +| ---------- | ------------- | -------------------- | --------- | -------- | +| Household | hh_id | x_cst_grp_hh_id | Char | Yes | +| Household | province | x_cst_grp_province | Char | No | +| Individual | person_id | x_cst_indv_person_id | Char | Yes | +| Individual | birthdate | x_cst_indv_birthdate | Date | Yes | +| Individual | gender | x_cst_indv_gender | Selection | Yes | + +### Selection Field Example + +For `gender` enum field in Individual entity: + +```python +Field: x_cst_indv_gender +Type: Selection +Values: [('Female', 'Female'), ('Male', 'Male')] +Target Type: indv +Field Category: cst +``` + +--- + +## User Interface + +### Form View Features + +1. **Header**: + + - Status bar (Draft → Processed) + - "Process Entities" button (visible only in draft state) + +2. **Main Section**: + + - YAML filename (large title, readonly) + - File upload widget + - Description field + +3. **Processing Log Tab**: + + - Shows detailed processing results + - Only visible after processing + +4. **Chatter**: + - Posts summary message after processing + - Shows fields created/skipped counts + +### Tree View + +- Displays filename, state (with colored badge), and description +- State badge colors: + - Draft: Blue + - Processed: Green + +### Search/Filters + +- Filter by state (Draft/Processed) +- Filter by has YAML file +- Group by status + +--- + +## Processing Log Example + +``` +================================================================================ +Processing YAML File: 4ps_best_practice_example_v7.yaml +Started at: 2025-10-23 10:30:00 +================================================================================ + +Entity: Household (4Ps Household) +---------------------------------------- + ✓ Created: hh_id (Household ID) - Type: string + ✓ Created: province (Province) - Type: admin_code + +Entity: Individual (Member) +---------------------------------------- + ✓ Created: person_id (Member ID) - Type: string + ✓ Created: birthdate (Date of Birth) - Type: date + ✓ Created: gender (Gender) - Type: enum + ⊗ Skipped: education_enrollment_status (Education Status) - Already exists + +================================================================================ +SUMMARY +================================================================================ +Total fields created: 5 +Total fields skipped: 1 +Completed at: 2025-10-23 10:30:15 +================================================================================ +``` + +--- + +## Error Handling + +### Validation Errors + +- File extension validation (must be .yaml or .yml) +- YAML syntax validation +- Missing required fields in spec + +### Processing Errors + +- No YAML file uploaded → `UserError` +- Invalid YAML syntax → `UserError` with details +- No "entities" section → `UserError` +- Field creation failure → Logged, processing continues + +### Duplicate Fields + +- Existing fields are skipped (not recreated) +- Logged as "Skipped" in processing log +- No error raised + +--- + +## OpenSPP Conventions + +### Field Naming + +Follows `spp_custom_fields_ui` field naming patterns: + +- `x_`: Odoo custom field prefix (standard) +- `cst_`: Custom/arbitrary fields (not indicators `ind_`) +- `grp_`: Group/Household entity fields +- `indv_`: Individual entity fields + +**Examples**: + +- Group field: `x_cst_grp_hh_id` +- Individual field: `x_cst_indv_person_id` + +### Why This Pattern? + +1. **x\_**: Standard Odoo prefix for custom fields +2. **cst\_**: Distinguishes from computed indicators (`ind_`) +3. **grp\_/indv\_**: Entity-specific prefix for UI filtering +4. **Integration**: Matches `spp_custom_fields_ui` for proper display in registry UIs +5. **Prevents conflicts**: With standard Odoo and OpenSPP fields + +### spp_custom_fields_ui Integration + +Created fields include metadata for `spp_custom_fields_ui`: + +- `target_type`: "grp" or "indv" +- `field_category`: "cst" (custom) +- `draft_name`: Original field ID from YAML + +This ensures fields appear correctly in: + +- Group/Household forms +- Individual forms +- Custom field management UI + +--- + +## Best Practices + +### 1. YAML File Preparation + +- Ensure all required fields (id, label, type) are present +- Use consistent naming conventions +- Provide descriptions for clarity +- Validate YAML syntax before upload + +### 2. Field Types + +- Use appropriate types (date for dates, enum for choices) +- Provide enum values for selection fields +- Mark fields as required when appropriate + +### 3. Processing + +- Review the processing log after processing +- Check for skipped fields (may indicate duplicates) +- Check chatter for summary message + +### 4. Testing + +- Test with small YAML files first +- Verify fields are created correctly in res.partner +- Check field properties (type, required, etc.) + +--- + +## Troubleshooting + +### Issue: "No YAML file uploaded" + +**Solution**: Upload a YAML file before clicking "Process Entities" + +### Issue: "Invalid YAML file" + +**Solution**: Validate YAML syntax using online validator or IDE + +### Issue: "No 'entities' section found" + +**Solution**: Ensure YAML has an `entities` key at the root level + +### Issue: Fields not appearing + +**Solution**: + +1. Check processing log for errors +2. Verify field names don't conflict with existing fields +3. Restart Odoo server to reload model definitions + +--- + +## Future Enhancements + +Potential additions for future versions: + +1. **Derived Fields Processing**: Handle computed/indicator fields +2. **View Generation**: Auto-create form/tree views for entities +3. **Relationship Handling**: Process one-to-many, many-to-many relationships +4. **Rollback Feature**: Ability to delete created fields +5. **Preview Mode**: Show what would be created before actually creating +6. **Batch Processing**: Process multiple YAML files at once +7. **Field Updates**: Update existing fields instead of skipping + +--- + +## Technical Notes + +### Performance + +- Fields are created one at a time (not batched) +- Processing is synchronous (no queue jobs) +- Suitable for <100 fields per YAML file + +### Database Impact + +- Creates records in `ir.model.fields` +- Modifies PostgreSQL schema (adds columns to `res_partner` table) +- Changes are permanent (manual deletion required to remove) + +### Security + +- Requires System Administrator access to create fields +- Follows Odoo's standard security model +- Field creation logged in processing log and chatter + +--- + +## References + +- OpenSPP Field Naming Conventions: See `openspp-fields-naming` cursor rule +- YAML Specification: `4ps_best_practice_example_v7.yaml` +- Odoo Fields Documentation: + https://www.odoo.com/documentation/17.0/developer/reference/backend/orm.html#fields + +--- + +**Last Updated**: 2025-10-23 **Module Version**: 17.0.1.3.1 **Author**: OpenSPP.org diff --git a/spp_code_generator/README.rst b/spp_code_generator/README.rst new file mode 100644 index 000000000..c5a3484c3 --- /dev/null +++ b/spp_code_generator/README.rst @@ -0,0 +1,93 @@ +======================= +OpenSPP Code Generator +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:... + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2Fopenspp--modules-lightgray.png?logo=github + :target: https://github.com/OpenSPP/openspp-modules/tree/17.0/spp_code_generator + :alt: OpenSPP/openspp-modules + +|badge1| |badge2| |badge3| + +This module provides a YAML-based code generator for OpenSPP program registrants and groups. + +**Table of contents** + +.. contents:: + :local: + +Features +======== + +* Upload and validate YAML configuration files +* Automatic YAML syntax validation +* Unique filename constraint +* Integration with OpenSPP registry system + +Configuration +============= + +To configure this module: + +#. Go to Code Generator > Code Generators +#. Click Create +#. Enter a filename (must end with .yaml or .yml) +#. Upload your YAML configuration file +#. The system will automatically validate the YAML syntax + +Usage +===== + +To use this module: + +#. Navigate to Code Generator menu +#. Create a new code generator record +#. Upload your YAML file - the system will validate it automatically +#. The filename must be unique across all code generators + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Contributors +------------ + +* Jeremi Joslin +* Edwin Gonzales + +Maintainers +----------- + +This module is maintained by OpenSPP.org. + +.. image:: https://openspp.org/logo.png + :alt: OpenSPP + :target: https://openspp.org + +OpenSPP is a digital platform for social protection programs. + diff --git a/spp_code_generator/__init__.py b/spp_code_generator/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/spp_code_generator/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/spp_code_generator/__manifest__.py b/spp_code_generator/__manifest__.py new file mode 100644 index 000000000..f1fda103e --- /dev/null +++ b/spp_code_generator/__manifest__.py @@ -0,0 +1,38 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenSPP Code Generator", + "category": "OpenSPP", + "version": "17.0.1.3.1", + "summary": "This module generates codes for program registrants and groups based on specifications written in a YAML file.", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/openspp-modules", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "base", + "mail", + "g2p_registry_base", + "g2p_registry_individual", + "g2p_registry_group", + "g2p_registry_membership", + "g2p_programs", + "spp_custom_field", + "spp_custom_fields_ui", + "spp_cel_domain", + ], + "external_dependencies": { + "python": ["PyYAML"], + }, + "data": [ + "security/ir.model.access.csv", + "views/code_generator_views.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, +} diff --git a/spp_code_generator/models/__init__.py b/spp_code_generator/models/__init__.py new file mode 100644 index 000000000..bbfce1ae4 --- /dev/null +++ b/spp_code_generator/models/__init__.py @@ -0,0 +1,2 @@ +from . import code_generator +from . import group diff --git a/spp_code_generator/models/code_generator.py b/spp_code_generator/models/code_generator.py new file mode 100644 index 000000000..fa95899df --- /dev/null +++ b/spp_code_generator/models/code_generator.py @@ -0,0 +1,655 @@ +import base64 +import logging + +import yaml + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class CodeGenerator(models.Model): + _name = "spp.code.generator" + _description = "Code Generator" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char(string="YAML Filename", required=True, index=True) + description = fields.Text(string="Description") + yaml_file = fields.Binary(string="YAML File", attachment=True, tracking=True) + state = fields.Selection( + [ + ("draft", "Draft"), + ("processed", "Processed"), + ], + string="Status", + default="draft", + tracking=True, + ) + processing_log = fields.Text(string="Processing Log", readonly=True) + + _sql_constraints = [ + ("name_unique", "UNIQUE(name)", "The YAML filename must be unique!"), + ] + + @api.constrains("yaml_file", "name") + def _validate_yaml_file(self): + """Validate that the uploaded file is a valid YAML file""" + for record in self: + if record.yaml_file and record.name: + # Check file extension + if not record.name.lower().endswith((".yaml", ".yml")): + raise ValidationError(_("Invalid file format. Filename must end with .yaml or .yml extension.")) + + # Validate YAML content + try: + yaml_content = base64.b64decode(record.yaml_file) + yaml.safe_load(yaml_content) + except yaml.YAMLError as e: + raise ValidationError(_("Invalid YAML file. Error: %s") % str(e)) from e + except Exception as e: + raise ValidationError(_("Failed to process YAML file. Error: %s") % str(e)) from e + + def _exec(self, expr, profile="registry_individuals"): + """Helper to execute CEL expression.""" + registry = self.env["cel.registry"] + cfg = registry.load_profile(profile) + executor = self.env["cel.executor"].with_context(cel_profile=profile, cel_cfg=cfg) + model = cfg.get("root_model", "res.partner") + return executor.compile_and_preview(model, expr, limit=50) + + def _load_yaml_content(self): + """ + Load and parse the YAML file content. + + Returns: + dict: Parsed YAML content as a Python dictionary + + Raises: + UserError: If YAML file is not uploaded or cannot be parsed + """ + self.ensure_one() + + if not self.yaml_file: + raise UserError(_("No YAML file uploaded. Please upload a YAML file first.")) + + try: + # Decode the binary content + yaml_content = base64.b64decode(self.yaml_file) + # Parse YAML + yaml_data = yaml.safe_load(yaml_content) + _logger.info("Successfully loaded YAML file: %s", self.name) + return yaml_data + except yaml.YAMLError as e: + raise UserError(_("Failed to parse YAML file: %s") % str(e)) from e + except Exception as e: + raise UserError(_("Error loading YAML file: %s") % str(e)) from e + + def _get_odoo_field_type(self, yaml_field_type): + """ + Map YAML field type to Odoo field type. + + Args: + yaml_field_type (str): Field type from YAML (e.g., 'string', 'date', 'enum') + + Returns: + str: Corresponding Odoo field type (e.g., 'char', 'date', 'selection') + """ + type_mapping = { + "string": "char", + "date": "date", + "datetime": "datetime", + "enum": "selection", + "boolean": "boolean", + "integer": "integer", + "float": "float", + "text": "text", + "admin_code": "char", # Administrative code, treated as char + } + return type_mapping.get(yaml_field_type, "char") + + def _get_entity_prefix(self, entity_name): + """ + Determine the field prefix based on entity type. + + Args: + entity_name (str): Name of the entity (e.g., 'Household', 'Individual') + + Returns: + str: Field prefix following OpenSPP conventions + - 'x_cst_grp_' for groups/households + - 'x_cst_indv_' for individuals + """ + entity_lower = entity_name.lower() + + # Check if entity is a group/household + if any(keyword in entity_lower for keyword in ["household", "group", "family"]): + return "x_cst_grp" + # Check if entity is an individual + elif any(keyword in entity_lower for keyword in ["individual", "member", "person"]): + return "x_cst_indv" + else: + # Default to group if uncertain + _logger.warning( + "Unknown entity type '%s', defaulting to group prefix. " + "Consider using 'Household' or 'Individual' as entity names.", + entity_name, + ) + return "x_cst_grp" + + def _create_field_from_spec(self, entity_name, field_spec): + """ + Create an Odoo field in res.partner based on field specification from YAML. + + Args: + entity_name (str): Name of the entity (e.g., 'Household', 'Individual') + field_spec (dict): Field specification containing id, label, type, etc. + + Returns: + ir.model.fields: Created field record or None if field already exists + + Field Specification Structure: + { + 'id': 'field_name', # Required: Field technical name + 'label': 'Field Label', # Required: Field display label + 'type': 'string', # Required: Field type + 'required': True/False, # Optional: Whether field is required + 'values': [...], # Optional: For enum types, list of allowed values + 'description': '...' # Optional: Help text for the field + } + + Field Naming Convention (matching spp_custom_fields_ui): + - Groups/Households: x_cst_grp_{field_id} + - Individuals: x_cst_indv_{field_id} + """ + self.ensure_one() + + # Extract field specifications + field_id = field_spec.get("id") + field_label = field_spec.get("label") + field_type = field_spec.get("type") + is_required = field_spec.get("required", False) + enum_values = field_spec.get("values", []) + field_description = field_spec.get("description", "") + + if not all([field_id, field_label, field_type]): + _logger.warning("Skipping field with incomplete specification: %s", field_spec) + return None + + # Prepare field name with prefix based on entity type + # Follows spp_custom_fields_ui convention: x_cst_grp_ or x_cst_indv_ + prefix = self._get_entity_prefix(entity_name) + field_name = f"{prefix}_{field_id}" + + # Check if field already exists + IrModelFields = self.env["ir.model.fields"] + existing_field = IrModelFields.search([("model", "=", "res.partner"), ("name", "=", field_name)], limit=1) + + if existing_field: + _logger.info("Field %s already exists in res.partner, skipping creation", field_name) + return existing_field + + # Get the res.partner model ID + partner_model = self.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + if not partner_model: + raise UserError(_("res.partner model not found in the system")) + + # Map YAML type to Odoo type + odoo_field_type = self._get_odoo_field_type(field_type) + + # Determine target type for spp_custom_fields_ui integration + target_type = "grp" if "grp" in prefix else "indv" + + # Prepare field values + field_values = { + "name": field_name, + "field_description": field_label, + "model_id": partner_model.id, + "model": "res.partner", + "ttype": odoo_field_type, + "required": is_required, + "help": field_description or f"Custom field for {entity_name}: {field_label}", + "state": "manual", # Manual fields can be deleted + "target_type": target_type, # For spp_custom_fields_ui: 'grp' or 'indv' + "field_category": "cst", # For spp_custom_fields_ui: 'cst' (custom) + "draft_name": field_id, # Original field ID from YAML + } + + # Handle selection/enum fields + if odoo_field_type == "selection" and enum_values: + # Convert list of values to Odoo selection format + # Format for selection_ids: [(0, 0, {'value': 'key', 'name': 'Label'}), ...] + field_values["selection_ids"] = [(0, 0, {"value": val, "name": val}) for val in enum_values] + + try: + # Create the field + new_field = IrModelFields.create(field_values) + _logger.info("Created field %s in res.partner for entity %s", field_name, entity_name) + return new_field + except Exception as e: + _logger.error("Failed to create field %s: %s", field_name, str(e)) + raise UserError(_("Failed to create field %s: %s") % (field_name, str(e))) from e + + def _determine_indicator_type(self, expression, dependencies): + """ + Determine if the indicator is for groups or individuals based on the expression. + + Args: + expression (str): CEL expression for the indicator + dependencies (list): List of field dependencies + + Returns: + str: 'grp' for group indicators, 'indv' for individual indicators + """ + expression_lower = expression.lower() + + # Check for group aggregation patterns + group_patterns = [ + "members.exists", + "members.count", + "group_membership_ids", + "compute_count_and_set_indicator", + ] + + if any(pattern in expression_lower for pattern in group_patterns): + return "grp" + + # Default to individual for simple expressions + return "indv" + + def _generate_compute_method_code(self, field_name, expression, indicator_type, field_type): + """ + Generate Python code for the compute method of an indicator field. + + Args: + field_name (str): Full field name (e.g., x_ind_indv_age_yrs) + expression (str): CEL expression + indicator_type (str): 'grp' or 'indv' + field_type (str): Field type (e.g., 'boolean', 'integer') + + Returns: + str: Python code for the compute method + + Strategy: + - Individual (indv): Use _exec() with CEL expression + - Group (grp): Use compute_count_and_set_indicator() with CEL expression + """ + + code = None + if indicator_type == "indv": + # Individual indicator: Use _exec to evaluate CEL expression + code = f""" +for record in self: + try: + if record.is_group: + # This is an individual indicator, skip groups + record.write({{'{field_name}': None}}) + continue + + # Execute CEL expression using _exec helper + result = record._exec( + '''{expression}''', + profile='registry_individuals' + ) +""" + if field_type == "integer": + code += f""" + record.write({{'{field_name}': result}}) +""" + else: + code += f""" + record.write({{'{field_name}': bool(record.id in result.get('ids', []))}}) +""" + code += f""" + except Exception as e: + record.write({{'{field_name}': None}}) +""" + return code + + elif indicator_type == "grp": + # Group indicator: Use compute_count_and_set_indicator with CEL expression + # This method is extended in group.py to handle CEL expressions + code = f""" +kinds = None +domain = [] +cel_expression = '''{expression}''' +self.compute_count_and_set_indicator( + '{field_name}', + kinds, + domain, + cel_expression=cel_expression +) +""" + return code + + def _create_indicator_field(self, derived_spec): + """ + Create an indicator (computed) field in res.partner from derived_fields specification. + + Args: + derived_spec (dict): Derived field specification from YAML + + Returns: + ir.model.fields: Created field record or None if already exists + + Derived Field Specification: + { + 'id': 'age_yrs', + 'label': 'Age (years)', + 'expression': 'age_years(me.birthdate)', + 'purpose': 'eligibility, reporting', + 'dependencies': ['birthdate'] + } + """ + self.ensure_one() + + # Extract specifications + field_id = derived_spec.get("id") + field_label = derived_spec.get("label") + expression = derived_spec.get("expression", "") + purpose = derived_spec.get("purpose", "") + dependencies = derived_spec.get("dependencies", []) + + if not all([field_id, field_label, expression]): + _logger.warning("Skipping derived field with incomplete specification: %s", derived_spec) + return None + + # Determine if it's a group or individual indicator + indicator_type = self._determine_indicator_type(expression, dependencies) + + # Create field name with indicator prefix + if indicator_type == "grp": + field_name = f"x_ind_grp_{field_id}" + else: + field_name = f"x_ind_indv_{field_id}" + + # Check if field already exists + IrModelFields = self.env["ir.model.fields"] + existing_field = IrModelFields.search([("model", "=", "res.partner"), ("name", "=", field_name)], limit=1) + + if existing_field: + _logger.info("Indicator field %s already exists in res.partner, skipping", field_name) + return None + + # Get the res.partner model ID + partner_model = self.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + if not partner_model: + raise UserError(_("res.partner model not found in the system")) + + # Determine field type based on expression + # Most indicators are boolean or integer + if any(keyword in expression.lower() for keyword in ["exists", "has", "is_"]): + field_type = "boolean" + elif any(keyword in expression.lower() for keyword in ["count", "sum", "age_years"]): + field_type = "integer" + else: + field_type = "boolean" # Default + + # Generate compute method code + compute_code = self._generate_compute_method_code( + field_name, expression, indicator_type, dependencies, field_type + ) + + # Prepare field values + field_values = { + "name": field_name, + "field_description": field_label, + "model_id": partner_model.id, + "model": "res.partner", + "ttype": field_type, + "store": True, + "compute": compute_code, + "help": f"{purpose}", + "state": "manual", + "target_type": indicator_type, + "field_category": "ind", + "draft_name": field_id, + } + + # Add dependency fields if specified + if dependencies: + # Convert dependencies to proper format + dep_field_names = [] + for dep in dependencies: + # Handle both simple names and prefixed names + if not dep.startswith("x_"): + # Try to find the actual field name + dep_field = IrModelFields.search( + [("model", "=", "res.partner"), ("name", "ilike", f"%{dep}%")], limit=1 + ) + if dep_field: + dep_field_names.append(dep_field.name) + else: + dep_field_names.append(dep) + else: + dep_field_names.append(dep) + + field_values["depends"] = ",".join(dep_field_names) + + try: + # Create the field + new_field = IrModelFields.create(field_values) + _logger.info("Created indicator field %s in res.partner (type: %s)", field_name, indicator_type) + + # Log the compute code for debugging + _logger.debug("Compute code for %s:\n%s", field_name, compute_code) + + return new_field + except Exception as e: + _logger.error("Failed to create indicator field %s: %s", field_name, str(e)) + raise UserError(_("Failed to create indicator field %s: %s") % (field_name, str(e))) from e + + def action_process_derived_fields(self): + """ + Process the 'derived_fields' section from YAML and create indicator fields. + + This method: + 1. Loads and parses the YAML file + 2. Extracts the 'derived_fields' section + 3. For each derived field, creates corresponding indicator field in res.partner + 4. Generates compute methods with CEL expression handling + 5. Logs the processing results + + Returns: + dict: Action to show success notification + """ + self.ensure_one() + + # Load YAML content + yaml_data = self._load_yaml_content() + + # Extract derived_fields section + derived_fields = yaml_data.get("derived_fields", []) + if not derived_fields: + raise UserError(_("No 'derived_fields' section found in the YAML file")) + + # Initialize processing log + log_lines = [ + "=" * 80, + f"Processing Derived Fields from: {self.name}", + f"Started at: {fields.Datetime.now()}", + "=" * 80, + "", + ] + + created_count = 0 + skipped_count = 0 + error_count = 0 + + # Process each derived field + for derived_spec in derived_fields: + field_id = derived_spec.get("id") + field_label = derived_spec.get("label") + expression = derived_spec.get("expression") + indicator_type = self._determine_indicator_type(expression, derived_spec.get("dependencies", [])) + + try: + result = self._create_indicator_field(derived_spec) + if result: + created_count += 1 + log_lines.append(f" ✓ Created: {field_id} ({field_label}) - Type: {indicator_type} indicator") + log_lines.append(f" Expression: {expression}") + else: + skipped_count += 1 + log_lines.append(f" ⊗ Skipped: {field_id} ({field_label}) - Already exists") + except Exception as e: + error_count += 1 + log_lines.append(f" ✗ Error: {field_id} - {str(e)}") + _logger.error("Error creating indicator field %s: %s", field_id, str(e)) + + # Summary + log_lines.extend( + [ + "", + "=" * 80, + "SUMMARY", + "=" * 80, + f"Indicator fields created: {created_count}", + f"Indicator fields skipped: {skipped_count}", + f"Errors encountered: {error_count}", + f"Completed at: {fields.Datetime.now()}", + "=" * 80, + ] + ) + + # Append to existing log + existing_log = self.processing_log or "" + new_log = "\n".join(log_lines) + + self.write( + { + "processing_log": existing_log + "\n\n" + new_log if existing_log else new_log, + } + ) + + # Post message in chatter + self.message_post( + body=_( + "Derived fields processing completed.\n" + "Indicators created: %s\n" + "Indicators skipped: %s\n" + "Errors: %s" + ) + % (created_count, skipped_count, error_count), + subject="Derived Fields Processing Complete", + ) + + # Return success notification + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _("Derived fields processing completed. Created %s indicators, skipped %s, errors %s.") + % (created_count, skipped_count, error_count), + "type": "success", + "sticky": False, + }, + } + + def action_process_entities(self): + """ + Process the 'entities' section from the YAML file and create fields in res.partner. + + This method: + 1. Loads and parses the YAML file + 2. Extracts the 'entities' section + 3. For each entity, creates corresponding fields in res.partner + 4. Logs the processing results + 5. Updates the record state to 'processed' + + Returns: + dict: Action to reload the form view with a success message + """ + self.ensure_one() + + # Load YAML content + yaml_data = self._load_yaml_content() + + # Extract entities section + entities = yaml_data.get("entities", []) + if not entities: + raise UserError(_("No 'entities' section found in the YAML file")) + + # Initialize processing log + log_lines = [ + "=" * 80, + f"Processing YAML File: {self.name}", + f"Started at: {fields.Datetime.now()}", + "=" * 80, + "", + ] + + created_fields_count = 0 + skipped_fields_count = 0 + + # Process each entity + for entity in entities: + entity_name = entity.get("name") + entity_label = entity.get("label", entity_name) + entity_fields = entity.get("fields", []) + + log_lines.append(f"Entity: {entity_name} ({entity_label})") + log_lines.append("-" * 40) + + # Process each field in the entity + for field_spec in entity_fields: + field_id = field_spec.get("id") + field_label = field_spec.get("label") + field_type = field_spec.get("type") + + try: + result = self._create_field_from_spec(entity_name, field_spec) + if result: + created_fields_count += 1 + log_lines.append(f" ✓ Created: {field_id} ({field_label}) - Type: {field_type}") + else: + skipped_fields_count += 1 + log_lines.append(f" ⊗ Skipped: {field_id} ({field_label}) - Already exists") + except Exception as e: + log_lines.append(f" ✗ Error: {field_id} - {str(e)}") + _logger.error("Error creating field %s: %s", field_id, str(e)) + + log_lines.append("") + + # Summary + log_lines.extend( + [ + "=" * 80, + "SUMMARY", + "=" * 80, + f"Total fields created: {created_fields_count}", + f"Total fields skipped: {skipped_fields_count}", + f"Completed at: {fields.Datetime.now()}", + "=" * 80, + ] + ) + + # Update processing log + processing_log = "\n".join(log_lines) + self.write( + { + "processing_log": processing_log, + "state": "processed", + } + ) + + # Post message in chatter + self.message_post( + body=_("Entity processing completed successfully.\n" "Fields created: %s\n" "Fields skipped: %s") + % (created_fields_count, skipped_fields_count), + subject="YAML Processing Complete", + ) + + # Return action to show success message and reload form + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _("Entity processing completed. Created %s fields, skipped %s fields.") + % (created_fields_count, skipped_fields_count), + "type": "success", + "sticky": False, + }, + } diff --git a/spp_code_generator/models/group.py b/spp_code_generator/models/group.py new file mode 100644 index 000000000..d5607fbe3 --- /dev/null +++ b/spp_code_generator/models/group.py @@ -0,0 +1,23 @@ +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class GroupRegistry(models.Model): + _inherit = "res.partner" + + def _exec(self, expr, profile="registry_individuals"): + """Helper to execute CEL expression.""" + registry = self.env["cel.registry"] + cfg = registry.load_profile(profile) + executor = self.env["cel.executor"].with_context(cel_profile=profile, cel_cfg=cfg) + model = cfg.get("root_model", "res.partner") + return executor.compile_and_preview(model, expr, limit=50) + + def compute_count_and_set_indicator(self, field_name, kinds, domain, presence_only=False, cel_expression=None): + if cel_expression: + domain += self._exec(cel_expression, profile="registry_groups").get("domain", []) + _logger.info(f"CEL expression: {cel_expression} \n => domain: {domain}") + return super().compute_count_and_set_indicator(field_name, kinds, domain, presence_only, cel_expression=None) diff --git a/spp_code_generator/pyproject.toml b/spp_code_generator/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/spp_code_generator/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_code_generator/security/ir.model.access.csv b/spp_code_generator/security/ir.model.access.csv new file mode 100644 index 000000000..5a2892ebf --- /dev/null +++ b/spp_code_generator/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_code_generator_system,Code Generator System Access,model_spp_code_generator,base.group_system,1,1,1,1 +access_spp_code_generator_admin,Code Generator Admin Access,model_spp_code_generator,g2p_registry_base.group_g2p_admin,1,1,1,1 +access_spp_code_generator_user,Code Generator User Access,model_spp_code_generator,base.group_user,1,0,0,0 diff --git a/spp_code_generator/views/code_generator_views.xml b/spp_code_generator/views/code_generator_views.xml new file mode 100644 index 000000000..1720bf3b7 --- /dev/null +++ b/spp_code_generator/views/code_generator_views.xml @@ -0,0 +1,123 @@ + + + + + spp.code.generator.tree + spp.code.generator + + + + + + + + + + + + spp.code.generator.form + spp.code.generator + +
+
+
+ +
+
+ + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + + spp.code.generator.search + spp.code.generator + + + + + + + + + + + + + + + + + Code Generators + spp.code.generator + tree,form + + +

+ Create your first YAML Configuration +

+

+ Upload YAML files to configure code generation. The system will validate the YAML syntax automatically. +

+
+
+ + + + + +