Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 hiding 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
* 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);
36 changes: 36 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,36 @@
<?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
t-attf-class="form-check o_radio_item #{isItemDisabled(item[0]) ? 'd-none' : ''}"
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>
86 changes: 81 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 All @@ -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
Expand All @@ -69,17 +80,82 @@ 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."""
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."),
}

# 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}
28 changes: 19 additions & 9 deletions spp_change_request_v2/models/change_request.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Comment on lines +1158 to +1159

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
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 +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")
Expand Down Expand Up @@ -1203,10 +1210,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 +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'<tr><td class="bg-light"><strong>{display_key}</strong></td><td>{display_value}</td></tr>')

Expand All @@ -1233,9 +1243,9 @@ def _format_review_value(self, value):
return '<span class="badge text-bg-success">Yes</span>'
if isinstance(value, list):
if value:
return "<br/>".join(str(v) for v in value)
return "<br/>".join(html_escape(str(v)) for v in value)
return '<span class="text-muted">—</span>'
return str(value)
return html_escape(str(value))

def _capture_preview_snapshot(self):
"""Capture and store the preview HTML and JSON before applying changes."""
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
Loading
Loading