Skip to content

Commit a4f3701

Browse files
Merge pull request #84 from OpenSPP/feat/dynamic-approval-conflict-detection
feat(spp_change_request_v2): add dynamic approval-aware conflict & duplicate detection
2 parents 32329d6 + 9da3e01 commit a4f3701

14 files changed

+3836
-20
lines changed

spp_change_request_v2/README.rst

Lines changed: 646 additions & 0 deletions
Large diffs are not rendered by default.

spp_change_request_v2/models/change_request.py

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

spp_change_request_v2/models/change_request_conflict.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@ def create(self, vals_list):
2626
records = super().create(vals_list)
2727

2828
for record in records:
29-
# Run conflict checks if enabled for this CR type
29+
# Run conflict checks if enabled for this CR type.
30+
# Skip for dynamic approval — field_to_modify isn't set yet.
31+
# Checks run when selected_field_name is set (see write() trigger).
3032
if record.request_type_id and (
3133
record.request_type_id.enable_conflict_detection or record.request_type_id.enable_duplicate_detection
3234
):
35+
if record.request_type_id.use_dynamic_approval:
36+
continue
3337
try:
3438
record._run_conflict_checks()
3539
except Exception as e:
@@ -47,8 +51,9 @@ def write(self, vals):
4751
"""Re-run conflict detection if relevant fields change."""
4852
result = super().write(vals)
4953

50-
# Re-check conflicts if registrant or type changed
51-
if "registrant_id" in vals or "request_type_id" in vals:
54+
# Re-check conflicts if registrant, type, or selected field changed
55+
trigger_fields = {"registrant_id", "request_type_id", "selected_field_name"}
56+
if trigger_fields & set(vals):
5257
for record in self:
5358
if (
5459
record.request_type_id

spp_change_request_v2/models/change_request_detail_base.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,44 @@ def _compute_is_cr_manager(self):
5353
stage = fields.Selection(
5454
related="change_request_id.stage",
5555
)
56+
use_dynamic_approval = fields.Boolean(
57+
related="change_request_id.request_type_id.use_dynamic_approval",
58+
)
59+
field_to_modify = fields.Selection(
60+
selection="_get_field_to_modify_selection",
61+
string="Field to Modify",
62+
help="Select which field to update in this change request",
63+
)
64+
65+
@api.model
66+
def _get_field_to_modify_selection(self):
67+
"""Return available field options for field-level change requests.
68+
69+
Override in concrete detail models to provide the list of modifiable fields.
70+
Returns a list of (value, label) tuples, e.g.:
71+
[("poverty_status_id", "Poverty Status"), ("set_group_id", "Set Group")]
72+
"""
73+
return []
74+
75+
def write(self, vals):
76+
result = super().write(vals)
77+
if "field_to_modify" in vals:
78+
for rec in self:
79+
if rec.change_request_id:
80+
rec._sync_field_to_modify()
81+
else:
82+
for rec in self:
83+
if rec.field_to_modify and rec.field_to_modify in vals and rec.change_request_id:
84+
rec._sync_field_to_modify()
85+
return result
86+
87+
@api.model_create_multi
88+
def create(self, vals_list):
89+
records = super().create(vals_list)
90+
for rec in records:
91+
if rec.field_to_modify and rec.change_request_id:
92+
rec._sync_field_to_modify()
93+
return records
5694

5795
def action_proceed_to_cr(self):
5896
"""Navigate to the parent Change Request form if there are proposed changes."""
@@ -120,6 +158,52 @@ def action_request_revision(self):
120158
self.ensure_one()
121159
return self.change_request_id.action_request_revision()
122160

161+
# ══════════════════════════════════════════════════════════════════════════
162+
# DYNAMIC APPROVAL SYNC
163+
# ══════════════════════════════════════════════════════════════════════════
164+
165+
def _sync_field_to_modify(self):
166+
"""Sync field_to_modify and its old/new values to the parent CR."""
167+
self.ensure_one()
168+
cr = self.change_request_id
169+
if not cr:
170+
return
171+
# Only sync for dynamic-approval CR types
172+
if not cr.request_type_id.use_dynamic_approval:
173+
return
174+
175+
field_name = self.field_to_modify
176+
cr_vals = {
177+
"selected_field_name": field_name,
178+
"selected_field_old_value": False,
179+
"selected_field_new_value": False,
180+
}
181+
182+
if field_name:
183+
mapping = cr.request_type_id.apply_mapping_ids.filtered(lambda m: m.source_field == field_name)[:1]
184+
185+
if mapping:
186+
registrant = cr.registrant_id
187+
old_raw = getattr(registrant, mapping.target_field, None)
188+
cr_vals["selected_field_old_value"] = self._format_value_for_display(old_raw)
189+
190+
new_raw = getattr(self, field_name, None)
191+
cr_vals["selected_field_new_value"] = self._format_value_for_display(new_raw)
192+
193+
cr.write(cr_vals)
194+
195+
def _format_value_for_display(self, value):
196+
"""Format a field value as a human-readable string for audit display."""
197+
# Boolean check MUST come before the falsy check,
198+
# otherwise False displays as "" instead of "No"
199+
if isinstance(value, bool):
200+
return _("Yes") if value else _("No")
201+
if value is None or value is False:
202+
return ""
203+
if hasattr(value, "display_name"):
204+
return value.display_name or ""
205+
return str(value)
206+
123207
# ══════════════════════════════════════════════════════════════════════════
124208
# PREFILL FROM REGISTRANT
125209
# ══════════════════════════════════════════════════════════════════════════

spp_change_request_v2/models/change_request_type.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,21 @@ def _onchange_available_document_ids(self):
201201
string="Approval Workflow",
202202
)
203203
auto_approve_from_event = fields.Boolean(default=False)
204+
use_dynamic_approval = fields.Boolean(
205+
string="Dynamic Approval",
206+
default=False,
207+
help="When enabled, user selects a single field to modify per CR. "
208+
"The selected field determines which approval workflow applies.",
209+
)
210+
candidate_definition_ids = fields.Many2many(
211+
"spp.approval.definition",
212+
"cr_type_candidate_definition_rel",
213+
"type_id",
214+
"definition_id",
215+
string="Candidate Approval Definitions",
216+
help="Evaluated in sequence order; first matching CEL condition wins. "
217+
"If none match, the default Approval Workflow is used.",
218+
)
204219

205220
# ══════════════════════════════════════════════════════════════════════════
206221
# CONFLICT DETECTION

0 commit comments

Comments
 (0)