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")