diff --git a/spp_base_common/__manifest__.py b/spp_base_common/__manifest__.py
index 1b4e609f..6cda488f 100644
--- a/spp_base_common/__manifest__.py
+++ b/spp_base_common/__manifest__.py
@@ -36,6 +36,8 @@
"spp_base_common/static/src/scss/navbar.scss",
"spp_base_common/static/src/js/custom_list_create.js",
"spp_base_common/static/src/xml/custom_list_create_template.xml",
+ "spp_base_common/static/src/js/filterable_radio_field.js",
+ "spp_base_common/static/src/xml/filterable_radio_field.xml",
],
"web._assets_primary_variables": [
"spp_base_common/static/src/scss/colors.scss",
diff --git a/spp_base_common/static/src/js/filterable_radio_field.js b/spp_base_common/static/src/js/filterable_radio_field.js
new file mode 100644
index 00000000..8a9f8845
--- /dev/null
+++ b/spp_base_common/static/src/js/filterable_radio_field.js
@@ -0,0 +1,77 @@
+/** @odoo-module **/
+
+import {RadioField, radioField} from "@web/views/fields/radio/radio_field";
+import {registry} from "@web/core/registry";
+import {_t} from "@web/core/l10n/translation";
+
+/**
+ * A radio widget that supports hiding individual options based on
+ * boolean fields on the record.
+ *
+ * Usage in XML views:
+ *
+ *
+ * The `disabled_map` option maps selection values to boolean field names.
+ * When the boolean field is falsy, the corresponding radio option is
+ * hidden from the user.
+ */
+export class FilterableRadioField extends RadioField {
+ static template = "spp_base_common.FilterableRadioField";
+ static props = {
+ ...RadioField.props,
+ disabledMap: {type: Object, optional: true},
+ };
+ static defaultProps = {
+ ...RadioField.defaultProps,
+ disabledMap: {},
+ };
+
+ /**
+ * Check whether a given selection value should be disabled.
+ * @param {any} value - The selection key to check
+ * @returns {Boolean}
+ */
+ isItemDisabled(value) {
+ if (this.props.readonly) {
+ return true;
+ }
+ const map = this.props.disabledMap;
+ if (!map || !(value in map)) {
+ return false;
+ }
+ const boolField = map[value];
+ return !this.props.record.data[boolField];
+ }
+}
+
+export const filterableRadioField = {
+ ...radioField,
+ component: FilterableRadioField,
+ displayName: _t("Filterable Radio"),
+ supportedOptions: [
+ ...radioField.supportedOptions,
+ {
+ label: _t("Disabled map (value → boolean field)"),
+ name: "disabled_map",
+ type: "object",
+ },
+ ],
+ extractProps: (fieldInfo, dynamicInfo) => {
+ const baseProps = radioField.extractProps(fieldInfo, dynamicInfo);
+ return {
+ ...baseProps,
+ disabledMap: fieldInfo.options.disabled_map || {},
+ };
+ },
+};
+
+registry.category("fields").add("filterable_radio", filterableRadioField);
diff --git a/spp_base_common/static/src/xml/filterable_radio_field.xml b/spp_base_common/static/src/xml/filterable_radio_field.xml
new file mode 100644
index 00000000..4c5cbae5
--- /dev/null
+++ b/spp_base_common/static/src/xml/filterable_radio_field.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
diff --git a/spp_change_request_v2/details/update_id.py b/spp_change_request_v2/details/update_id.py
index 3d536edc..f2b35942 100644
--- a/spp_change_request_v2/details/update_id.py
+++ b/spp_change_request_v2/details/update_id.py
@@ -1,4 +1,5 @@
-from odoo import api, fields, models
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
class SPPCRDetailUpdateID(models.Model):
@@ -15,7 +16,7 @@ class SPPCRDetailUpdateID(models.Model):
operation = fields.Selection(
[
("add", "Add New ID"),
- ("update", "Update Existing ID"),
+ ("update", "Edit ID"),
("remove", "Remove ID"),
],
string="Operation",
@@ -30,8 +31,9 @@ class SPPCRDetailUpdateID(models.Model):
help="Select existing ID to update or remove",
)
id_type_id = fields.Many2one(
- "spp.id.type",
+ "spp.vocabulary.code",
string="ID Type",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:id-type')]",
tracking=True,
)
id_value = fields.Char(
@@ -53,6 +55,15 @@ class SPPCRDetailUpdateID(models.Model):
help="Upload scanned copies or photos of the ID document",
)
remarks = fields.Text(string="Remarks", tracking=True)
+ is_operation_locked = fields.Boolean(
+ string="Operation Locked",
+ default=False,
+ help="Set to True when user proceeds to Documents or Review stage, locking the operation selection.",
+ )
+ operation_display = fields.Char(
+ string="Operation",
+ compute="_compute_operation_display",
+ )
# ══════════════════════════════════════════════════════════════════════════
# COMPUTED FIELDS
@@ -69,6 +80,47 @@ class SPPCRDetailUpdateID(models.Model):
readonly=True,
)
+ allow_id_add = fields.Boolean(
+ related="change_request_id.request_type_id.allow_id_add",
+ readonly=True,
+ )
+ allow_id_edit = fields.Boolean(
+ related="change_request_id.request_type_id.allow_id_edit",
+ readonly=True,
+ )
+ allow_id_remove = fields.Boolean(
+ related="change_request_id.request_type_id.allow_id_remove",
+ readonly=True,
+ )
+
+ @api.depends("operation")
+ def _compute_operation_display(self):
+ labels = dict(self._fields["operation"].selection)
+ for rec in self:
+ rec.operation_display = labels.get(rec.operation, "")
+
+ def action_next_documents(self):
+ """Lock operation before proceeding to documents stage."""
+ self.write({"is_operation_locked": True})
+ return super().action_next_documents()
+
+ def action_skip_to_review(self):
+ """Lock operation before proceeding to review stage."""
+ self.write({"is_operation_locked": True})
+ return super().action_skip_to_review()
+
+ @api.constrains("operation")
+ def _check_operation_allowed(self):
+ """Validate that the chosen operation is allowed by the CR type config."""
+ for rec in self:
+ if not rec.change_request_id or not rec.change_request_id.request_type_id:
+ continue
+ cr_type = rec.change_request_id.request_type_id
+ if rec.operation == "update" and not cr_type.allow_id_edit:
+ raise ValidationError(_("Edit ID operation is not allowed for this change request type."))
+ if rec.operation == "remove" and not cr_type.allow_id_remove:
+ raise ValidationError(_("Remove ID operation is not allowed for this change request type."))
+
@api.onchange("existing_id_record_id")
def _onchange_existing_id(self):
"""Pre-fill fields when updating existing ID."""
@@ -76,10 +128,34 @@ def _onchange_existing_id(self):
self.id_type_id = self.existing_id_record_id.id_type_id
self.id_value = self.existing_id_record_id.value
self.expiry_date = self.existing_id_record_id.expiry_date
- self.description = self.existing_id_record_id.description
@api.onchange("operation")
def _onchange_operation(self):
- """Clear fields when operation changes."""
+ """Clear fields when operation changes. Reset if operation not allowed."""
+ # Check if selected operation is allowed
+ warning = None
+ if self.operation == "update" and not self.allow_id_edit:
+ self.operation = "add"
+ warning = {
+ "title": _("Operation Not Allowed"),
+ "message": _("Edit ID is disabled for this change request type."),
+ }
+ elif self.operation == "remove" and not self.allow_id_remove:
+ self.operation = "add"
+ warning = {
+ "title": _("Operation Not Allowed"),
+ "message": _("Remove ID is disabled for this change request type."),
+ }
+
+ # Unlock operation when changed (user came back to edit)
+ self.is_operation_locked = False
+
+ # Clear fields common to all operations
+ self.id_type_id = False
+ self.id_value = False
+ self.expiry_date = False
if self.operation == "add":
self.existing_id_record_id = False
+
+ if warning:
+ return {"warning": warning}
diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py
index b5e8382c..6fa9c23e 100644
--- a/spp_change_request_v2/models/change_request.py
+++ b/spp_change_request_v2/models/change_request.py
@@ -1,5 +1,7 @@
import logging
+from markupsafe import escape as html_escape
+
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
@@ -1147,17 +1149,21 @@ def _generate_review_comparison_html(self):
)
action = changes.pop("_action", None)
+ header = changes.pop("_header", None)
# Determine if this is a field-mapping type (has old/new dicts)
has_comparison = any(isinstance(v, dict) and "old" in v and "new" in v for v in changes.values())
if has_comparison:
- return self._render_comparison_table(changes)
- return self._render_action_summary(action, changes)
+ return self._render_comparison_table(changes, header=header)
+ return self._render_action_summary(action, changes, header=header)
- def _render_comparison_table(self, changes):
+ def _render_comparison_table(self, changes, header=None):
"""Render a three-column comparison table for field-mapping CR types."""
- html = ['
']
+ html = []
+ if header:
+ html.append(f"{header}
")
+ html.append('')
html.append(
""
' | '
@@ -1170,7 +1176,8 @@ def _render_comparison_table(self, changes):
for key, value in changes.items():
if key.startswith("_"):
continue
- display_key = key.replace("_", " ").title()
+ # Use key as-is if it contains spaces (human-readable), otherwise convert
+ display_key = key if " " in key else key.replace("_", " ").title()
if isinstance(value, dict) and "old" in value:
old_val = value.get("old")
@@ -1203,10 +1210,13 @@ def _render_comparison_table(self, changes):
html.append("
")
return "".join(html)
- def _render_action_summary(self, action, changes):
+ def _render_action_summary(self, action, changes, header=None):
"""Render a summary table for action-based CR types."""
html = []
+ if header:
+ html.append(f"{header}
")
+
if not changes:
html.append('No details to display.
')
return "".join(html)
@@ -1218,7 +1228,7 @@ def _render_action_summary(self, action, changes):
for key, value in changes.items():
if key.startswith("_"):
continue
- display_key = key.replace("_", " ").title()
+ display_key = key if " " in key else key.replace("_", " ").title()
display_value = self._format_review_value(value)
html.append(f'| {display_key} | {display_value} |
')
@@ -1233,9 +1243,9 @@ def _format_review_value(self, value):
return 'Yes'
if isinstance(value, list):
if value:
- return "
".join(str(v) for v in value)
+ return "
".join(html_escape(str(v)) for v in value)
return '—'
- return str(value)
+ return html_escape(str(value))
def _capture_preview_snapshot(self):
"""Capture and store the preview HTML and JSON before applying changes."""
diff --git a/spp_change_request_v2/models/change_request_type.py b/spp_change_request_v2/models/change_request_type.py
index 2309997f..3d4da4ae 100644
--- a/spp_change_request_v2/models/change_request_type.py
+++ b/spp_change_request_v2/models/change_request_type.py
@@ -251,6 +251,26 @@ def _onchange_available_document_ids(self):
help="Configuration for duplicate detection",
)
+ # ══════════════════════════════════════════════════════════════════════════
+ # ID OPERATIONS CONFIGURATION (for update_id type)
+ # ══════════════════════════════════════════════════════════════════════════
+
+ allow_id_add = fields.Boolean(
+ string="Allow Add ID",
+ default=True,
+ help="Allow adding new ID documents via this CR type.",
+ )
+ allow_id_edit = fields.Boolean(
+ string="Allow Edit ID",
+ default=True,
+ help="Allow editing existing ID documents via this CR type.",
+ )
+ allow_id_remove = fields.Boolean(
+ string="Allow Remove ID",
+ default=True,
+ help="Allow removing ID documents via this CR type.",
+ )
+
# ══════════════════════════════════════════════════════════════════════════
# APPLY CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════
diff --git a/spp_change_request_v2/strategies/update_id.py b/spp_change_request_v2/strategies/update_id.py
index 09927bc0..30f61542 100644
--- a/spp_change_request_v2/strategies/update_id.py
+++ b/spp_change_request_v2/strategies/update_id.py
@@ -51,7 +51,8 @@ def _apply_add(self, registrant, detail, change_request):
)
if existing:
raise UserError(
- _("Registrant already has an ID of type '%s'. Use 'Update' operation instead.") % detail.id_type_id.name
+ _("Registrant already has an ID of type '%s'. Use 'Update' operation instead.")
+ % detail.id_type_id.display_name
)
# Create new ID record
@@ -68,7 +69,7 @@ def _apply_add(self, registrant, detail, change_request):
_logger.info(
"Added ID type=%s for registrant partner_id=%s via CR %s",
- detail.id_type_id.name,
+ detail.id_type_id.display_name,
registrant.id,
change_request.name,
)
@@ -119,16 +120,53 @@ def _apply_remove(self, registrant, detail, change_request):
return True
def preview(self, change_request):
- """Preview what will be changed."""
+ """Preview what will be changed, with different layouts per operation."""
detail = change_request.get_detail()
if not detail:
return {}
- return {
- "_action": f"{detail.operation}_id",
- "registrant": change_request.registrant_id.name,
- "operation": detail.operation,
- "id_type": detail.id_type_id.name if detail.id_type_id else None,
- "id_value": detail.id_value,
- "existing_value": detail.current_id_value,
- }
+ operation = detail.operation
+
+ if operation == "update":
+ # Show old/new comparison for edit operations
+ result = {
+ "_action": "update_id",
+ "_header": "Edit Existing ID",
+ }
+ if detail.existing_id_record_id:
+ existing = detail.existing_id_record_id
+ result["ID Type"] = {
+ "old": existing.id_type_id.display_name if existing.id_type_id else None,
+ "new": detail.id_type_id.display_name if detail.id_type_id else None,
+ }
+ result["ID Number/Value"] = {
+ "old": existing.value or None,
+ "new": detail.id_value or None,
+ }
+ result["Expiry Date"] = {
+ "old": str(existing.expiry_date) if existing.expiry_date else None,
+ "new": str(detail.expiry_date) if detail.expiry_date else None,
+ }
+ return result
+
+ if operation == "add":
+ return {
+ "_action": "add_id",
+ "_header": "Add New ID",
+ "ID Type": detail.id_type_id.display_name if detail.id_type_id else None,
+ "ID Number/Value": detail.id_value or None,
+ "Expiry Date": str(detail.expiry_date) if detail.expiry_date else None,
+ }
+
+ if operation == "remove":
+ result = {
+ "_action": "remove_id",
+ "_header": "Remove Existing ID",
+ }
+ if detail.existing_id_record_id:
+ existing = detail.existing_id_record_id
+ result["ID Type"] = existing.id_type_id.display_name if existing.id_type_id else None
+ result["ID Number/Value"] = existing.value or None
+ return result
+
+ return {}
diff --git a/spp_change_request_v2/tests/test_update_id_strategy.py b/spp_change_request_v2/tests/test_update_id_strategy.py
index 791ec412..1c888e4e 100644
--- a/spp_change_request_v2/tests/test_update_id_strategy.py
+++ b/spp_change_request_v2/tests/test_update_id_strategy.py
@@ -262,4 +262,5 @@ def test_update_id_preview(self):
self.assertIn("_action", preview)
self.assertEqual(preview["_action"], "add_id")
- self.assertEqual(preview["operation"], "add")
+ self.assertIn("_header", preview)
+ self.assertEqual(preview["_header"], "Add New ID")
diff --git a/spp_change_request_v2/views/change_request_type_views.xml b/spp_change_request_v2/views/change_request_type_views.xml
index 4f90f423..1ecc9c2b 100644
--- a/spp_change_request_v2/views/change_request_type_views.xml
+++ b/spp_change_request_v2/views/change_request_type_views.xml
@@ -113,6 +113,25 @@
/>
+
+
+
+
+
+
+
+
+ Add ID is always enabled.
+ Toggle Edit ID and Remove ID
+ to control which operations are available to users.
+
+
+
diff --git a/spp_change_request_v2/views/detail_update_id_views.xml b/spp_change_request_v2/views/detail_update_id_views.xml
index c17b9d18..5df1d536 100644
--- a/spp_change_request_v2/views/detail_update_id_views.xml
+++ b/spp_change_request_v2/views/detail_update_id_views.xml
@@ -44,41 +44,72 @@
/>
+
+
Update ID Document
+
+
-
+
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
-
-
-
+
+
+
+
diff --git a/spp_cr_types_base/data/cr_types.xml b/spp_cr_types_base/data/cr_types.xml
index 7e845ecc..f4f94171 100644
--- a/spp_cr_types_base/data/cr_types.xml
+++ b/spp_cr_types_base/data/cr_types.xml
@@ -1,5 +1,5 @@
-
+
@@ -220,6 +220,7 @@
spp.cr.apply.update_id
fa-id-card
80
+
True
True
diff --git a/spp_registry/models/reg_id.py b/spp_registry/models/reg_id.py
index bfd1c41e..fc5b5bed 100644
--- a/spp_registry/models/reg_id.py
+++ b/spp_registry/models/reg_id.py
@@ -90,17 +90,22 @@ def _compute_available_id_type_ids(self):
def _compute_display_name(self):
res = super()._compute_display_name()
for rec in self:
- name = ""
- if rec.partner_id:
- name = rec.partner_id.name
- rec.display_name = name
+ id_type = rec.id_type_id.display_name or _("Unknown Type")
+ value = rec.value or ""
+ rec.display_name = f"{id_type} - {value}" if value else id_type
return res
@api.model
def _name_search(self, name, domain=None, operator="ilike", limit=100, order=None):
domain = domain or []
if name:
- domain = [("partner_id", operator, name)] + domain
+ domain = [
+ "|",
+ "|",
+ ("id_type_id.display", operator, name),
+ ("value", operator, name),
+ ("partner_id", operator, name),
+ ] + domain
return self._search(domain, limit=limit, order=order)
@api.depends("verification_method")