@@ -133,6 +133,27 @@ def _compute_is_cr_manager(self):
133133 applied_by_id = fields .Many2one ("res.users" , readonly = True )
134134 apply_error = fields .Text (readonly = True )
135135
136+ # ══════════════════════════════════════════════════════════════════════════
137+ # DYNAMIC APPROVAL
138+ # ══════════════════════════════════════════════════════════════════════════
139+
140+ selected_field_name = fields .Char (
141+ string = "Field Being Modified" ,
142+ readonly = True ,
143+ help = "The detail field selected for modification (set when detail is saved). "
144+ "Used by CEL conditions to determine the approval workflow." ,
145+ )
146+ selected_field_old_value = fields .Char (
147+ string = "Old Value" ,
148+ readonly = True ,
149+ help = "Human-readable old value of the selected field (from registrant). Stored for audit trail." ,
150+ )
151+ selected_field_new_value = fields .Char (
152+ string = "New Value" ,
153+ readonly = True ,
154+ help = "Human-readable new value of the selected field (from detail). Stored for audit trail." ,
155+ )
156+
136157 # ══════════════════════════════════════════════════════════════════════════
137158 # LOG
138159 # ══════════════════════════════════════════════════════════════════════════
@@ -596,7 +617,11 @@ def create(self, vals_list):
596617 record ._create_audit_event ("created" , None , "draft" )
597618 record ._create_log ("created" )
598619 # Run conflict detection after creation
599- if hasattr (record , "_run_conflict_checks" ):
620+ # Skip for dynamic approval — field_to_modify isn't set yet at create time.
621+ # Checks run when selected_field_name is written (see conflict model's write()).
622+ if hasattr (record , "_run_conflict_checks" ) and not (
623+ record .request_type_id and record .request_type_id .use_dynamic_approval
624+ ):
600625 record ._run_conflict_checks ()
601626 return records
602627
@@ -813,17 +838,137 @@ def action_submit_for_approval(self):
813838
814839 def _get_approval_definition (self ):
815840 self .ensure_one ()
816- definition = self .request_type_id .approval_definition_id
841+ cr_type = self .request_type_id
842+
843+ # Dynamic approval: evaluate candidates using selected field + values
844+ if cr_type .use_dynamic_approval and cr_type .candidate_definition_ids :
845+ definition = self ._resolve_dynamic_approval ()
846+ if definition :
847+ return definition
848+
849+ # Fallback to static definition (existing behavior)
850+ definition = cr_type .approval_definition_id
817851 if not definition :
818852 raise UserError (
819853 _ (
820854 "No approval workflow configured for request type '%s'. "
821- "Please configure an approval definition in Change Request > Configuration > CR Types."
855+ "Please configure an approval definition in Change Request > "
856+ "Configuration > CR Types."
822857 )
823- % self . request_type_id .name
858+ % cr_type .name
824859 )
825860 return definition
826861
862+ def _resolve_dynamic_approval (self ):
863+ """Evaluate candidate definitions against selected field and values.
864+
865+ Iterates candidates in sequence order; first match wins.
866+ Returns spp.approval.definition record, or None if no candidate matches.
867+ """
868+ self .ensure_one ()
869+
870+ if not self .selected_field_name :
871+ return None
872+
873+ extra_context = self ._compute_field_values_for_cel ()
874+ evaluator = self .env ["spp.cel.evaluator" ]
875+
876+ for candidate in self .request_type_id .candidate_definition_ids .sorted ("sequence" ):
877+ if not candidate .use_cel_condition or not candidate .cel_condition :
878+ # No condition = catch-all (matches everything)
879+ return candidate
880+ try :
881+ result = evaluator .evaluate (candidate .cel_condition , self , extra_context )
882+ if result :
883+ return candidate
884+ except Exception :
885+ _logger .warning (
886+ "CEL evaluation failed for candidate definition '%s' on CR %s, skipping" ,
887+ candidate .name ,
888+ self .name ,
889+ exc_info = True ,
890+ )
891+ continue
892+
893+ return None
894+
895+ def _compute_field_values_for_cel (self ):
896+ """Compute typed old and new values for CEL evaluation.
897+
898+ Returns a dict with old_value and new_value typed according to field type.
899+ """
900+ self .ensure_one ()
901+ field_name = self .selected_field_name
902+ cr_type = self .request_type_id
903+
904+ if not field_name :
905+ return {"old_value" : None , "new_value" : None }
906+
907+ mapping = cr_type .apply_mapping_ids .filtered (lambda m : m .source_field == field_name )[:1 ]
908+
909+ detail = self .get_detail ()
910+ registrant = self .registrant_id
911+
912+ old_raw = None
913+ new_raw = None
914+
915+ if mapping and registrant :
916+ old_raw = getattr (registrant , mapping .target_field , None )
917+ if detail :
918+ new_raw = getattr (detail , field_name , None )
919+
920+ return {
921+ "old_value" : self ._normalize_value_for_cel (old_raw , registrant , mapping .target_field if mapping else None ),
922+ "new_value" : self ._normalize_value_for_cel (new_raw , detail , field_name ),
923+ }
924+
925+ def _normalize_value_for_cel (self , value , record , field_name ):
926+ """Normalize an Odoo field value for use in CEL expressions."""
927+ if value is None or value is False :
928+ if record and field_name and field_name in record ._fields :
929+ field = record ._fields [field_name ]
930+ if field .type == "boolean" :
931+ return False
932+ if field .type in ("integer" , "float" , "monetary" ):
933+ return 0
934+ return None
935+
936+ if record and field_name and field_name in record ._fields :
937+ field = record ._fields [field_name ]
938+ if field .type in ("char" , "text" , "selection" , "html" ):
939+ return value or ""
940+ if field .type in ("integer" , "float" , "monetary" ):
941+ return value or 0
942+ if field .type == "boolean" :
943+ return bool (value )
944+ if field .type in ("date" , "datetime" ):
945+ return value
946+ if field .type == "many2one" :
947+ # IDs are exposed for internal CEL evaluation only, not for external APIs.
948+ result = {
949+ "id" : value .id if value else 0 ,
950+ "name" : value .display_name if value else "" ,
951+ }
952+ # Vocabulary models: expose machine-readable code for stable CEL matching
953+ if value and "code" in value ._fields :
954+ result ["code" ] = value .code or ""
955+ # Hierarchical vocabularies: expose parent category
956+ if value and "parent_id" in value ._fields and value .parent_id :
957+ parent = value .parent_id
958+ result ["parent" ] = {
959+ "id" : parent .id ,
960+ "name" : parent .display_name ,
961+ "code" : parent .code if "code" in parent ._fields else "" ,
962+ }
963+ return result
964+ if field .type in ("one2many" , "many2many" ):
965+ return {
966+ "ids" : value .ids if value else [],
967+ "count" : len (value ) if value else 0 ,
968+ }
969+
970+ return value
971+
827972 def _on_approve (self ):
828973 super ()._on_approve ()
829974 # Signal ORM that approval_state changed (set via raw SQL in _do_approve)
@@ -840,10 +985,19 @@ def _on_reject(self, reason):
840985 self ._create_log ("rejected" , notes = reason )
841986
842987 def _check_can_submit (self ):
843- """Override to allow resubmission from revision state ."""
988+ """Override to allow resubmission and validate dynamic approval field selection ."""
844989 self .ensure_one ()
845990 if self .approval_state not in ("draft" , "revision" ):
846991 raise UserError (_ ("Only draft or revision-requested records can be submitted for approval." ))
992+ cr_type = self .request_type_id
993+ if cr_type .use_dynamic_approval and not self .selected_field_name :
994+ raise ValidationError (
995+ _ (
996+ "Please select a field to modify on the detail form before "
997+ "submitting for approval. This CR type requires a single "
998+ "field selection for dynamic approval routing."
999+ )
1000+ )
8471001
8481002 def _on_submit (self ):
8491003 # Run conflict checks before submission
0 commit comments