Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions spp_base_common/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions spp_base_common/static/src/js/filterable_radio_field.js
Original file line number Diff line number Diff line change
@@ -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 disabling individual options based on
* boolean fields on the record.
*
* Usage in XML views:
* <field
* name="operation"
* widget="filterable_radio"
* options="{
* 'disabled_map': {
* 'update': 'allow_id_edit',
* 'remove': 'allow_id_remove'
* }
* }"
* />
*
* The `disabled_map` option maps selection values to boolean field names.
* When the boolean field is falsy, the corresponding radio option is
* rendered as disabled (grayed out, not clickable).
*/
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);
33 changes: 33 additions & 0 deletions spp_base_common/static/src/xml/filterable_radio_field.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">

<t t-name="spp_base_common.FilterableRadioField">
<div
role="radiogroup"
t-attf-class="o_{{ props.orientation }}"
t-att-aria-label="props.label"
>
<t t-foreach="items" t-as="item" t-key="item[0]">
<div class="form-check o_radio_item" aria-atomic="true">
<input
type="radio"
class="form-check-input o_radio_input"
t-att-checked="item[0] === value"
t-att-disabled="isItemDisabled(item[0])"
t-att-name="id"
t-att-data-value="item[0]"
t-att-data-index="item_index"
t-att-id="`${id}_${item[0]}`"
t-on-change="() => this.onChange(item)"
/>
<label
t-att-for="`${id}_${item[0]}`"
t-attf-class="form-check-label o_form_label #{isItemDisabled(item[0]) ? 'text-muted' : ''}"
t-esc="item[1]"
/>
</div>
</t>
</div>
</t>

</templates>
61 changes: 56 additions & 5 deletions spp_change_request_v2/details/update_id.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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",
Expand All @@ -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(
Expand Down Expand Up @@ -69,17 +71,66 @@ 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.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."""
if self.existing_id_record_id and self.operation in ("update", "remove"):
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."),
}

if self.operation == "add":
self.existing_id_record_id = False
self.id_type_id = False
self.id_value = False
self.expiry_date = False
elif self.operation in ("update", "remove"):
self.id_type_id = False
self.id_value = False
self.expiry_date = False
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's some code duplication in how fields are cleared for different operations. The fields id_type_id, id_value, and expiry_date are reset in both the if and elif blocks. You can simplify this by clearing these fields once before the conditional logic to improve readability and maintainability.

Suggested change
if self.operation == "add":
self.existing_id_record_id = False
self.id_type_id = False
self.id_value = False
self.expiry_date = False
elif self.operation in ("update", "remove"):
self.id_type_id = False
self.id_value = False
self.expiry_date = False
self.id_type_id = False
self.id_value = False
self.expiry_date = False
if self.operation == 'add':
self.existing_id_record_id = False

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d94eef — deduplicated the field clearing.


if warning:
return {"warning": warning}
22 changes: 15 additions & 7 deletions spp_change_request_v2/models/change_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -1147,17 +1147,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)
Comment on lines +1158 to +1159
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The functions _render_comparison_table and _render_action_summary render HTML for the review page. They use the internal helper _format_review_value to format values. This helper does not escape string values, which could lead to a Cross-Site Scripting (XSS) vulnerability if a user-controlled value (like an ID value) contains malicious HTML or JavaScript. Since review_comparison_html is rendered with sanitize=False, it's crucial to escape all user-provided data.

I recommend updating _format_review_value to use odoo.tools.html_escape. For example:

# In _format_review_value method:
from odoo.tools import html_escape

# ... at the end of the function, instead of just str(value):
return html_escape(str(value))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d94eef — added html_escape via markupsafe.escape to _format_review_value.


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 = ['<table class="table table-sm table-bordered mb-0" style="width:100%">']
html = []
if header:
html.append(f"<h4>{header}</h4>")
html.append('<table class="table table-sm table-bordered mb-0" style="width:100%">')
html.append(
"<thead><tr>"
'<th class="bg-light"></th>'
Expand All @@ -1170,7 +1174,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")
Expand Down Expand Up @@ -1203,10 +1208,13 @@ def _render_comparison_table(self, changes):
html.append("</tbody></table>")
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"<h4>{header}</h4>")

if not changes:
html.append('<p class="text-muted mb-0"><i class="fa fa-info-circle me-2"></i>No details to display.</p>')
return "".join(html)
Expand All @@ -1218,7 +1226,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'<tr><td class="bg-light"><strong>{display_key}</strong></td><td>{display_value}</td></tr>')

Expand Down
20 changes: 20 additions & 0 deletions spp_change_request_v2/models/change_request_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ══════════════════════════════════════════════════════════════════════════
Expand Down
60 changes: 49 additions & 11 deletions spp_change_request_v2/strategies/update_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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 {}
3 changes: 2 additions & 1 deletion spp_change_request_v2/tests/test_update_id_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading
Loading