Skip to content

Commit 82005a6

Browse files
authored
Merge pull request #104 from OpenSPP/feat/update-id-cr-improvements
feat(spp_change_request_v2): improve Update ID Document CR type
2 parents 9c2376e + 9d94eef commit 82005a6

File tree

12 files changed

+367
-67
lines changed

12 files changed

+367
-67
lines changed

spp_base_common/__manifest__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
"spp_base_common/static/src/scss/navbar.scss",
3737
"spp_base_common/static/src/js/custom_list_create.js",
3838
"spp_base_common/static/src/xml/custom_list_create_template.xml",
39+
"spp_base_common/static/src/js/filterable_radio_field.js",
40+
"spp_base_common/static/src/xml/filterable_radio_field.xml",
3941
],
4042
"web._assets_primary_variables": [
4143
"spp_base_common/static/src/scss/colors.scss",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/** @odoo-module **/
2+
3+
import {RadioField, radioField} from "@web/views/fields/radio/radio_field";
4+
import {registry} from "@web/core/registry";
5+
import {_t} from "@web/core/l10n/translation";
6+
7+
/**
8+
* A radio widget that supports hiding individual options based on
9+
* boolean fields on the record.
10+
*
11+
* Usage in XML views:
12+
* <field
13+
* name="operation"
14+
* widget="filterable_radio"
15+
* options="{
16+
* 'disabled_map': {
17+
* 'update': 'allow_id_edit',
18+
* 'remove': 'allow_id_remove'
19+
* }
20+
* }"
21+
* />
22+
*
23+
* The `disabled_map` option maps selection values to boolean field names.
24+
* When the boolean field is falsy, the corresponding radio option is
25+
* hidden from the user.
26+
*/
27+
export class FilterableRadioField extends RadioField {
28+
static template = "spp_base_common.FilterableRadioField";
29+
static props = {
30+
...RadioField.props,
31+
disabledMap: {type: Object, optional: true},
32+
};
33+
static defaultProps = {
34+
...RadioField.defaultProps,
35+
disabledMap: {},
36+
};
37+
38+
/**
39+
* Check whether a given selection value should be disabled.
40+
* @param {any} value - The selection key to check
41+
* @returns {Boolean}
42+
*/
43+
isItemDisabled(value) {
44+
if (this.props.readonly) {
45+
return true;
46+
}
47+
const map = this.props.disabledMap;
48+
if (!map || !(value in map)) {
49+
return false;
50+
}
51+
const boolField = map[value];
52+
return !this.props.record.data[boolField];
53+
}
54+
}
55+
56+
export const filterableRadioField = {
57+
...radioField,
58+
component: FilterableRadioField,
59+
displayName: _t("Filterable Radio"),
60+
supportedOptions: [
61+
...radioField.supportedOptions,
62+
{
63+
label: _t("Disabled map (value → boolean field)"),
64+
name: "disabled_map",
65+
type: "object",
66+
},
67+
],
68+
extractProps: (fieldInfo, dynamicInfo) => {
69+
const baseProps = radioField.extractProps(fieldInfo, dynamicInfo);
70+
return {
71+
...baseProps,
72+
disabledMap: fieldInfo.options.disabled_map || {},
73+
};
74+
},
75+
};
76+
77+
registry.category("fields").add("filterable_radio", filterableRadioField);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<templates xml:space="preserve">
3+
4+
<t t-name="spp_base_common.FilterableRadioField">
5+
<div
6+
role="radiogroup"
7+
t-attf-class="o_{{ props.orientation }}"
8+
t-att-aria-label="props.label"
9+
>
10+
<t t-foreach="items" t-as="item" t-key="item[0]">
11+
<div
12+
t-attf-class="form-check o_radio_item #{isItemDisabled(item[0]) ? 'd-none' : ''}"
13+
aria-atomic="true"
14+
>
15+
<input
16+
type="radio"
17+
class="form-check-input o_radio_input"
18+
t-att-checked="item[0] === value"
19+
t-att-disabled="isItemDisabled(item[0])"
20+
t-att-name="id"
21+
t-att-data-value="item[0]"
22+
t-att-data-index="item_index"
23+
t-att-id="`${id}_${item[0]}`"
24+
t-on-change="() => this.onChange(item)"
25+
/>
26+
<label
27+
t-att-for="`${id}_${item[0]}`"
28+
t-attf-class="form-check-label o_form_label #{isItemDisabled(item[0]) ? 'text-muted' : ''}"
29+
t-esc="item[1]"
30+
/>
31+
</div>
32+
</t>
33+
</div>
34+
</t>
35+
36+
</templates>

spp_change_request_v2/details/update_id.py

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from odoo import api, fields, models
1+
from odoo import _, api, fields, models
2+
from odoo.exceptions import ValidationError
23

34

45
class SPPCRDetailUpdateID(models.Model):
@@ -15,7 +16,7 @@ class SPPCRDetailUpdateID(models.Model):
1516
operation = fields.Selection(
1617
[
1718
("add", "Add New ID"),
18-
("update", "Update Existing ID"),
19+
("update", "Edit ID"),
1920
("remove", "Remove ID"),
2021
],
2122
string="Operation",
@@ -30,8 +31,9 @@ class SPPCRDetailUpdateID(models.Model):
3031
help="Select existing ID to update or remove",
3132
)
3233
id_type_id = fields.Many2one(
33-
"spp.id.type",
34+
"spp.vocabulary.code",
3435
string="ID Type",
36+
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:id-type')]",
3537
tracking=True,
3638
)
3739
id_value = fields.Char(
@@ -53,6 +55,15 @@ class SPPCRDetailUpdateID(models.Model):
5355
help="Upload scanned copies or photos of the ID document",
5456
)
5557
remarks = fields.Text(string="Remarks", tracking=True)
58+
is_operation_locked = fields.Boolean(
59+
string="Operation Locked",
60+
default=False,
61+
help="Set to True when user proceeds to Documents or Review stage, locking the operation selection.",
62+
)
63+
operation_display = fields.Char(
64+
string="Operation",
65+
compute="_compute_operation_display",
66+
)
5667

5768
# ══════════════════════════════════════════════════════════════════════════
5869
# COMPUTED FIELDS
@@ -69,17 +80,82 @@ class SPPCRDetailUpdateID(models.Model):
6980
readonly=True,
7081
)
7182

83+
allow_id_add = fields.Boolean(
84+
related="change_request_id.request_type_id.allow_id_add",
85+
readonly=True,
86+
)
87+
allow_id_edit = fields.Boolean(
88+
related="change_request_id.request_type_id.allow_id_edit",
89+
readonly=True,
90+
)
91+
allow_id_remove = fields.Boolean(
92+
related="change_request_id.request_type_id.allow_id_remove",
93+
readonly=True,
94+
)
95+
96+
@api.depends("operation")
97+
def _compute_operation_display(self):
98+
labels = dict(self._fields["operation"].selection)
99+
for rec in self:
100+
rec.operation_display = labels.get(rec.operation, "")
101+
102+
def action_next_documents(self):
103+
"""Lock operation before proceeding to documents stage."""
104+
self.write({"is_operation_locked": True})
105+
return super().action_next_documents()
106+
107+
def action_skip_to_review(self):
108+
"""Lock operation before proceeding to review stage."""
109+
self.write({"is_operation_locked": True})
110+
return super().action_skip_to_review()
111+
112+
@api.constrains("operation")
113+
def _check_operation_allowed(self):
114+
"""Validate that the chosen operation is allowed by the CR type config."""
115+
for rec in self:
116+
if not rec.change_request_id or not rec.change_request_id.request_type_id:
117+
continue
118+
cr_type = rec.change_request_id.request_type_id
119+
if rec.operation == "update" and not cr_type.allow_id_edit:
120+
raise ValidationError(_("Edit ID operation is not allowed for this change request type."))
121+
if rec.operation == "remove" and not cr_type.allow_id_remove:
122+
raise ValidationError(_("Remove ID operation is not allowed for this change request type."))
123+
72124
@api.onchange("existing_id_record_id")
73125
def _onchange_existing_id(self):
74126
"""Pre-fill fields when updating existing ID."""
75127
if self.existing_id_record_id and self.operation in ("update", "remove"):
76128
self.id_type_id = self.existing_id_record_id.id_type_id
77129
self.id_value = self.existing_id_record_id.value
78130
self.expiry_date = self.existing_id_record_id.expiry_date
79-
self.description = self.existing_id_record_id.description
80131

81132
@api.onchange("operation")
82133
def _onchange_operation(self):
83-
"""Clear fields when operation changes."""
134+
"""Clear fields when operation changes. Reset if operation not allowed."""
135+
# Check if selected operation is allowed
136+
warning = None
137+
if self.operation == "update" and not self.allow_id_edit:
138+
self.operation = "add"
139+
warning = {
140+
"title": _("Operation Not Allowed"),
141+
"message": _("Edit ID is disabled for this change request type."),
142+
}
143+
elif self.operation == "remove" and not self.allow_id_remove:
144+
self.operation = "add"
145+
warning = {
146+
"title": _("Operation Not Allowed"),
147+
"message": _("Remove ID is disabled for this change request type."),
148+
}
149+
150+
# Unlock operation when changed (user came back to edit)
151+
self.is_operation_locked = False
152+
153+
# Clear fields common to all operations
154+
self.id_type_id = False
155+
self.id_value = False
156+
self.expiry_date = False
84157
if self.operation == "add":
85158
self.existing_id_record_id = False
159+
160+
if warning:
161+
return {"warning": warning}

spp_change_request_v2/models/change_request.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
22

3+
from markupsafe import escape as html_escape
4+
35
from odoo import _, api, fields, models
46
from odoo.exceptions import UserError, ValidationError
57

@@ -1147,17 +1149,21 @@ def _generate_review_comparison_html(self):
11471149
)
11481150

11491151
action = changes.pop("_action", None)
1152+
header = changes.pop("_header", None)
11501153

11511154
# Determine if this is a field-mapping type (has old/new dicts)
11521155
has_comparison = any(isinstance(v, dict) and "old" in v and "new" in v for v in changes.values())
11531156

11541157
if has_comparison:
1155-
return self._render_comparison_table(changes)
1156-
return self._render_action_summary(action, changes)
1158+
return self._render_comparison_table(changes, header=header)
1159+
return self._render_action_summary(action, changes, header=header)
11571160

1158-
def _render_comparison_table(self, changes):
1161+
def _render_comparison_table(self, changes, header=None):
11591162
"""Render a three-column comparison table for field-mapping CR types."""
1160-
html = ['<table class="table table-sm table-bordered mb-0" style="width:100%">']
1163+
html = []
1164+
if header:
1165+
html.append(f"<h4>{header}</h4>")
1166+
html.append('<table class="table table-sm table-bordered mb-0" style="width:100%">')
11611167
html.append(
11621168
"<thead><tr>"
11631169
'<th class="bg-light"></th>'
@@ -1170,7 +1176,8 @@ def _render_comparison_table(self, changes):
11701176
for key, value in changes.items():
11711177
if key.startswith("_"):
11721178
continue
1173-
display_key = key.replace("_", " ").title()
1179+
# Use key as-is if it contains spaces (human-readable), otherwise convert
1180+
display_key = key if " " in key else key.replace("_", " ").title()
11741181

11751182
if isinstance(value, dict) and "old" in value:
11761183
old_val = value.get("old")
@@ -1203,10 +1210,13 @@ def _render_comparison_table(self, changes):
12031210
html.append("</tbody></table>")
12041211
return "".join(html)
12051212

1206-
def _render_action_summary(self, action, changes):
1213+
def _render_action_summary(self, action, changes, header=None):
12071214
"""Render a summary table for action-based CR types."""
12081215
html = []
12091216

1217+
if header:
1218+
html.append(f"<h4>{header}</h4>")
1219+
12101220
if not changes:
12111221
html.append('<p class="text-muted mb-0"><i class="fa fa-info-circle me-2"></i>No details to display.</p>')
12121222
return "".join(html)
@@ -1218,7 +1228,7 @@ def _render_action_summary(self, action, changes):
12181228
for key, value in changes.items():
12191229
if key.startswith("_"):
12201230
continue
1221-
display_key = key.replace("_", " ").title()
1231+
display_key = key if " " in key else key.replace("_", " ").title()
12221232
display_value = self._format_review_value(value)
12231233
html.append(f'<tr><td class="bg-light"><strong>{display_key}</strong></td><td>{display_value}</td></tr>')
12241234

@@ -1233,9 +1243,9 @@ def _format_review_value(self, value):
12331243
return '<span class="badge text-bg-success">Yes</span>'
12341244
if isinstance(value, list):
12351245
if value:
1236-
return "<br/>".join(str(v) for v in value)
1246+
return "<br/>".join(html_escape(str(v)) for v in value)
12371247
return '<span class="text-muted">—</span>'
1238-
return str(value)
1248+
return html_escape(str(value))
12391249

12401250
def _capture_preview_snapshot(self):
12411251
"""Capture and store the preview HTML and JSON before applying changes."""

spp_change_request_v2/models/change_request_type.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,26 @@ def _onchange_available_document_ids(self):
251251
help="Configuration for duplicate detection",
252252
)
253253

254+
# ══════════════════════════════════════════════════════════════════════════
255+
# ID OPERATIONS CONFIGURATION (for update_id type)
256+
# ══════════════════════════════════════════════════════════════════════════
257+
258+
allow_id_add = fields.Boolean(
259+
string="Allow Add ID",
260+
default=True,
261+
help="Allow adding new ID documents via this CR type.",
262+
)
263+
allow_id_edit = fields.Boolean(
264+
string="Allow Edit ID",
265+
default=True,
266+
help="Allow editing existing ID documents via this CR type.",
267+
)
268+
allow_id_remove = fields.Boolean(
269+
string="Allow Remove ID",
270+
default=True,
271+
help="Allow removing ID documents via this CR type.",
272+
)
273+
254274
# ══════════════════════════════════════════════════════════════════════════
255275
# APPLY CONFIGURATION
256276
# ══════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)