diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py
index 24984619..ab190fa7 100644
--- a/spp_change_request_v2/__manifest__.py
+++ b/spp_change_request_v2/__manifest__.py
@@ -7,7 +7,7 @@
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
"license": "LGPL-3",
- "development_status": "Production/Stable",
+ "development_status": "Stable",
"depends": [
"base",
"mail",
@@ -29,6 +29,8 @@
"views/dms_file_views.xml",
"views/change_request_type_views.xml",
"views/change_request_views.xml",
+ "views/stage_documents_form.xml",
+ "views/stage_review_form.xml",
"views/detail_add_member_views.xml",
"views/detail_edit_individual_views.xml",
"views/detail_edit_group_views.xml",
@@ -67,6 +69,8 @@
"spp_change_request_v2/static/src/xml/create_change_request_template.xml",
"spp_change_request_v2/static/src/xml/search_delay_field.xml",
"spp_change_request_v2/static/src/xml/cr_search_results_field.xml",
+ "spp_change_request_v2/static/src/js/cr_review_documents.js",
+ "spp_change_request_v2/static/src/xml/cr_review_documents.xml",
],
},
"installable": True,
diff --git a/spp_change_request_v2/data/default_types.xml b/spp_change_request_v2/data/default_types.xml
new file mode 100644
index 00000000..8fb39c1c
--- /dev/null
+++ b/spp_change_request_v2/data/default_types.xml
@@ -0,0 +1,280 @@
+
+
+
+
+
+
+ Add Group Member
+ add_member
+ Add a new member to an existing group/household
+ group
+ spp.cr.detail.add_member
+
+ custom
+ spp.cr.apply.add_member
+ fa-user-plus
+ 10
+
+
+
+
+ Edit Individual Information
+ edit_individual
+ Update personal information for an individual registrant
+ individual
+ spp.cr.detail.edit_individual
+
+ field_mapping
+ fa-user-edit
+ 20
+
+
+
+
+
+ given_name
+ given_name
+ 10
+
+
+
+ family_name
+ family_name
+ 20
+
+
+
+ birthdate
+ birthdate
+ 30
+
+
+
+ gender_id
+ gender_id
+ 40
+
+
+
+ phone
+ phone
+ 50
+
+
+
+ email
+ email
+ 60
+
+
+
+ address_line1
+ street
+ 70
+
+
+
+ address_line2
+ street2
+ 80
+
+
+
+ city
+ city
+ 90
+
+
+
+ postal_code
+ zip
+ 100
+
+
+
+
+ Edit Group Information
+ edit_group
+ Update information for a group/household
+ group
+ spp.cr.detail.edit_group
+
+ field_mapping
+ fa-users-cog
+ 30
+
+
+
+
+
+ group_name
+ name
+ 10
+
+
+
+ phone
+ phone
+ 20
+
+
+
+ email
+ email
+ 30
+
+
+
+ address_line1
+ street
+ 40
+
+
+
+ address_line2
+ street2
+ 50
+
+
+
+ city
+ city
+ 60
+
+
+
+ postal_code
+ zip
+ 70
+
+
+
+
+
+
+
+
+ Remove Group Member
+ remove_member
+ Remove a member from an existing group/household
+ group
+ spp.cr.detail.remove_member
+
+ custom
+ spp.cr.apply.remove_member
+ fa-user-minus
+ 40
+
+
+
+
+ Change Head of Household
+ change_hoh
+ Change the head of household for a group
+ group
+ spp.cr.detail.change_hoh
+
+ custom
+ spp.cr.apply.change_hoh
+ fa-user-shield
+ 50
+
+
+
+
+ Transfer Member
+ transfer_member
+ Transfer a member from one group to another
+ group
+ spp.cr.detail.transfer_member
+
+ custom
+ spp.cr.apply.transfer_member
+ fa-exchange-alt
+ 60
+
+
+
+
+
+
+
+
+ Exit Registrant
+ exit_registrant
+ Deactivate or exit a registrant from the system
+ both
+ spp.cr.detail.exit_registrant
+
+ custom
+ spp.cr.apply.exit_registrant
+ fa-user-slash
+ 70
+
+
+
+
+ Update ID Document
+ update_id
+ Add, update, or remove identification documents
+ both
+ spp.cr.detail.update_id
+
+ custom
+ spp.cr.apply.update_id
+ fa-id-card
+ 80
+
+
+
+
+
+
+
+
+ Create New Group
+ create_group
+ Create a new group/household
+ group
+ 0
+ spp.cr.detail.create_group
+
+ custom
+ spp.cr.apply.create_group
+ fa-home
+ 90
+
+
+
+
+ Split Household
+ split_household
+ Split a household into two separate groups
+ group
+ spp.cr.detail.split_household
+
+ custom
+ spp.cr.apply.split_household
+ fa-code-branch
+ 100
+
+
+
+
+
+
+
+
+ Merge Registrants
+ merge_registrants
+ Merge duplicate registrant records
+ both
+ spp.cr.detail.merge_registrants
+
+ custom
+ spp.cr.apply.merge_registrants
+ fa-compress-arrows-alt
+ 110
+
+
+
diff --git a/spp_change_request_v2/data/dms_directories.xml b/spp_change_request_v2/data/dms_directories.xml
index 52244c6b..fd93b873 100644
--- a/spp_change_request_v2/data/dms_directories.xml
+++ b/spp_change_request_v2/data/dms_directories.xml
@@ -1,8 +1,8 @@
-
+
Change Request
-
+
diff --git a/spp_change_request_v2/data/event_types.xml b/spp_change_request_v2/data/event_types.xml
index b1becbf7..c0a62e8c 100644
--- a/spp_change_request_v2/data/event_types.xml
+++ b/spp_change_request_v2/data/event_types.xml
@@ -6,9 +6,7 @@
cr_auditmanualboth
- Audit trail events for change request state transitions
+ Audit trail events for change request state transitions
@@ -17,8 +15,7 @@
cr_conflictmanualboth
- Audit trail events for conflict detection and resolution
+ Audit trail events for conflict detection and resolution
+
diff --git a/spp_change_request_v2/data/sequences.xml b/spp_change_request_v2/data/sequences.xml
index 60710594..d45aef37 100644
--- a/spp_change_request_v2/data/sequences.xml
+++ b/spp_change_request_v2/data/sequences.xml
@@ -6,6 +6,7 @@
spp.change.requestCR/%(year)s/5
-
+
+
diff --git a/spp_change_request_v2/data/user_roles.xml b/spp_change_request_v2/data/user_roles.xml
index 041c3788..8a955ff4 100644
--- a/spp_change_request_v2/data/user_roles.xml
+++ b/spp_change_request_v2/data/user_roles.xml
@@ -5,14 +5,13 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
User roles for Change Request module.
-->
+
CR Requestorglobal
- Can create and submit change requests for approval.
+ Can create and submit change requests for approval.
+
diff --git a/spp_change_request_v2/details/add_member.py b/spp_change_request_v2/details/add_member.py
index c76739ac..0bbeb5b7 100644
--- a/spp_change_request_v2/details/add_member.py
+++ b/spp_change_request_v2/details/add_member.py
@@ -29,9 +29,7 @@ class SPPCRDetailAddMember(models.Model):
relationship_id = fields.Many2one(
"spp.vocabulary.code",
string="Relationship to Head",
- domain=(
- "[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'), ('code', '!=', 'head')]"
- ),
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'), ('code', '!=', 'head')]",
tracking=True,
)
id_number = fields.Char(string="ID Number", tracking=True)
diff --git a/spp_change_request_v2/details/change_hoh.py b/spp_change_request_v2/details/change_hoh.py
index e8b34ea0..6451f1e9 100644
--- a/spp_change_request_v2/details/change_hoh.py
+++ b/spp_change_request_v2/details/change_hoh.py
@@ -41,9 +41,7 @@ class SPPCRDetailChangeHOH(models.Model):
previous_head_new_role_id = fields.Many2one(
"spp.vocabulary.code",
string="Previous Head's New Role",
- domain=(
- "[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'), ('code', '!=', 'head')]"
- ),
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'), ('code', '!=', 'head')]",
tracking=True,
help="The new role for the previous head (e.g., Spouse, Other Adult)",
)
diff --git a/spp_change_request_v2/details/merge_registrants.py b/spp_change_request_v2/details/merge_registrants.py
index 569760cf..b3806aed 100644
--- a/spp_change_request_v2/details/merge_registrants.py
+++ b/spp_change_request_v2/details/merge_registrants.py
@@ -132,7 +132,7 @@ def _check_registrant_types(self):
for dup in rec.duplicate_registrant_ids:
if dup.is_group != primary_is_group:
raise ValidationError(
- "Cannot merge individuals with groups. All registrants must be of the same type."
+ "Cannot merge individuals with groups. " "All registrants must be of the same type."
)
if dup.id == rec.primary_registrant_id.id:
raise ValidationError("Primary registrant cannot be in the duplicates list.")
diff --git a/spp_change_request_v2/details/split_household.py b/spp_change_request_v2/details/split_household.py
index 5788d8cc..c9111425 100644
--- a/spp_change_request_v2/details/split_household.py
+++ b/spp_change_request_v2/details/split_household.py
@@ -94,7 +94,7 @@ class SPPCRDetailSplitHousehold(models.Model):
# New group address
copy_address = fields.Boolean(
string="Copy Address from Source",
- default=True,
+ default=False,
tracking=True,
)
address_line1 = fields.Char(string="Address Line 1", tracking=True)
@@ -166,9 +166,8 @@ def _compute_available_member_ids(self):
)
# Filter out head member
- non_head_memberships = memberships.filtered(
- lambda m, _head_type=head_type: _head_type not in m.membership_type_ids
- )
+ _head_type = head_type
+ non_head_memberships = memberships.filtered(lambda m, ht=_head_type: ht not in m.membership_type_ids)
rec.available_member_ids = non_head_memberships.mapped("individual")
@@ -244,12 +243,12 @@ def _check_minimum_remaining(self):
)
if len(rec.members_to_split_ids) >= total:
raise ValidationError(
- "Cannot move all members. At least one member must remain in the source household."
+ "Cannot move all members. At least one member must remain " "in the source household."
)
@api.onchange("copy_address")
def _onchange_copy_address(self):
- """Copy address from source group when toggled."""
+ """Copy address from source group when toggled on, clear when toggled off."""
if self.copy_address and self.source_group_id:
self.address_line1 = self.source_group_id.street
self.address_line2 = self.source_group_id.street2
@@ -259,3 +258,12 @@ def _onchange_copy_address(self):
self.country_id = self.source_group_id.country_id
self.phone = self.source_group_id.phone
self.email = self.source_group_id.email
+ elif not self.copy_address:
+ self.address_line1 = False
+ self.address_line2 = False
+ self.city = False
+ self.state_id = False
+ self.postal_code = False
+ self.country_id = False
+ self.phone = False
+ self.email = False
diff --git a/spp_change_request_v2/details/transfer_member.py b/spp_change_request_v2/details/transfer_member.py
index 5101adaa..46dba4a4 100644
--- a/spp_change_request_v2/details/transfer_member.py
+++ b/spp_change_request_v2/details/transfer_member.py
@@ -48,9 +48,7 @@ class SPPCRDetailTransferMember(models.Model):
new_role_id = fields.Many2one(
"spp.vocabulary.code",
string="Role in New Group",
- domain=(
- "[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'), ('code', '!=', 'head')]"
- ),
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'), ('code', '!=', 'head')]",
tracking=True,
help="The role/relationship in the new group",
)
diff --git a/spp_change_request_v2/models/__init__.py b/spp_change_request_v2/models/__init__.py
index f2ef01ca..934504e5 100644
--- a/spp_change_request_v2/models/__init__.py
+++ b/spp_change_request_v2/models/__init__.py
@@ -9,3 +9,4 @@
from . import dms_directory
from . import dms_file
from . import res_partner
+from . import change_request_log
diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py
index d7ac98e3..cef1c242 100644
--- a/spp_change_request_v2/models/change_request.py
+++ b/spp_change_request_v2/models/change_request.py
@@ -45,6 +45,29 @@ class SPPChangeRequest(models.Model):
store=True,
index=True,
)
+ allow_document_download = fields.Boolean(
+ related="request_type_id.allow_document_download",
+ )
+
+ stage = fields.Selection(
+ [
+ ("details", "Edit Details"),
+ ("documents", "Upload Documents"),
+ ("review", "Review & Submit"),
+ ],
+ string="Stage",
+ default="details",
+ tracking=True,
+ )
+
+ is_cr_manager = fields.Boolean(
+ compute="_compute_is_cr_manager",
+ )
+
+ def _compute_is_cr_manager(self):
+ is_manager = self.env.user.has_group("spp_change_request_v2.group_cr_manager")
+ for rec in self:
+ rec.is_cr_manager = is_manager
# ══════════════════════════════════════════════════════════════════════════
# REGISTRANT & APPLICANT
@@ -53,7 +76,6 @@ class SPPChangeRequest(models.Model):
registrant_id = fields.Many2one(
"res.partner",
string="Registrant",
- required=True,
index=True,
tracking=True,
)
@@ -111,6 +133,16 @@ class SPPChangeRequest(models.Model):
applied_by_id = fields.Many2one("res.users", readonly=True)
apply_error = fields.Text(readonly=True)
+ # ══════════════════════════════════════════════════════════════════════════
+ # LOG
+ # ══════════════════════════════════════════════════════════════════════════
+
+ log_ids = fields.One2many(
+ "spp.change.request.log",
+ "change_request_id",
+ string="Change Request Log",
+ )
+
# ══════════════════════════════════════════════════════════════════════════
# DOCUMENTS & NOTES
# ══════════════════════════════════════════════════════════════════════════
@@ -136,8 +168,8 @@ class SPPChangeRequest(models.Model):
("pending", "Under Review"),
("revision", "Needs Changes"),
("approved", "Approved"),
- ("rejected", "Declined"),
- ("applied", "Completed"),
+ ("rejected", "Rejected"),
+ ("applied", "Applied"),
],
compute="_compute_display_state",
store=True,
@@ -148,15 +180,30 @@ class SPPChangeRequest(models.Model):
compute="_compute_preview_html",
sanitize=False,
)
+ review_comparison_html = fields.Html(
+ string="Review Comparison",
+ compute="_compute_review_comparison_html",
+ sanitize=False,
+ )
preview_html_snapshot = fields.Html(
string="Preview Snapshot",
help="Stored snapshot of preview taken before applying changes",
sanitize=False,
)
+ review_comparison_html_snapshot = fields.Html(
+ string="Review Comparison Snapshot",
+ help="Stored snapshot of review comparison taken before applying changes",
+ sanitize=False,
+ )
preview_json_snapshot = fields.Text(
string="Preview JSON Snapshot",
help="Stored JSON snapshot of preview taken before applying changes",
)
+ review_documents_html = fields.Html(
+ string="Review Documents",
+ compute="_compute_review_documents_html",
+ sanitize=False,
+ )
registrant_summary_html = fields.Html(
string="Registrant Summary",
compute="_compute_registrant_summary_html",
@@ -183,6 +230,26 @@ class SPPChangeRequest(models.Model):
help="Message indicating what type of registrant this CR type applies to",
)
+ missing_required_document_ids = fields.Many2many(
+ "spp.vocabulary.code",
+ compute="_compute_missing_required_documents",
+ string="Missing Required Documents",
+ )
+ documents_complete = fields.Boolean(
+ compute="_compute_missing_required_documents",
+ string="All Required Documents Uploaded",
+ )
+ stage_banner_html = fields.Html(
+ compute="_compute_stage_banner_html",
+ sanitize=False,
+ string="Stage Banner",
+ )
+ required_documents_html = fields.Html(
+ compute="_compute_required_documents_html",
+ sanitize=False,
+ string="Required Documents Status",
+ )
+
def _compute_is_creator(self):
"""Check if current user is the creator of this CR."""
for rec in self:
@@ -198,9 +265,11 @@ def _compute_has_proposed_changes(self):
continue
try:
- # Use the strategy's preview method to check for actual changes
- strategy = rec.request_type_id.get_apply_strategy()
- changes = strategy.preview(rec) or {}
+ # Use sudo to bypass record rules (e.g. global disabled-registrant
+ # rules on spp.group.membership) — this is read-only preview logic.
+ sudo_rec = rec.sudo()
+ strategy = sudo_rec.request_type_id.get_apply_strategy()
+ changes = strategy.preview(sudo_rec) or {}
# Remove metadata keys that don't represent actual changes
changes.pop("_action", None)
@@ -237,22 +306,31 @@ def _compute_multitier_approval_message(self):
tier_reviews = active_review.tier_review_ids
approved_tiers = tier_reviews.filtered(lambda t: t.status == "approved")
pending_tiers = tier_reviews.filtered(lambda t: t.status == "pending")
+ waiting_tiers = tier_reviews.filtered(lambda t: t.status == "waiting")
if pending_tiers:
- # Get the current approver group name from the tier
- pending_tier = pending_tiers.sorted("sequence")[:1]
- tier = pending_tier.tier_id
+ current_tier = pending_tiers.sorted("sequence")[:1]
group_name = ""
- if tier and tier.approval_group_id:
- group_name = tier.approval_group_id.name
+ if current_tier.tier_id and current_tier.tier_id.approval_group_id:
+ group_name = current_tier.tier_id.approval_group_id.name
if group_name:
- if approved_tiers:
- rec.multitier_approval_message = (
- _("Approved at previous level. Awaiting approval from: %s") % group_name
- )
- else:
- rec.multitier_approval_message = _("Awaiting approval from: %s") % group_name
+ total_tiers = len(tier_reviews)
+ completed = len(approved_tiers)
+ msg = _("Awaiting approval from: %s (Level %d of %d)") % (
+ group_name,
+ completed + 1,
+ total_tiers,
+ )
+
+ # Show next approver group if there are waiting tiers
+ if waiting_tiers:
+ next_tier = waiting_tiers.sorted("sequence")[:1]
+ if next_tier.tier_id and next_tier.tier_id.approval_group_id:
+ next_group = next_tier.tier_id.approval_group_id.name
+ msg += "\n" + _("Next: %s") % next_group
+
+ rec.multitier_approval_message = msg
else:
# Single-tier approval - get group from definition
definition = active_review.definition_id
@@ -276,6 +354,75 @@ def _compute_target_type_message(self):
else:
rec.target_type_message = _("This request type applies to both individuals and groups/households.")
+ @api.depends("name", "request_type_id", "registrant_id")
+ def _compute_stage_banner_html(self):
+ for rec in self:
+ cr_ref = rec.name or ""
+ cr_type = rec.request_type_id.name if rec.request_type_id else ""
+ html = (
+ f'{cr_ref}'
+ f'|'
+ f"{cr_type}"
+ )
+ if rec.registrant_id:
+ registrant = rec.registrant_id.name or ""
+ html += (
+ f'|'
+ f''
+ f"{registrant}"
+ )
+ rec.stage_banner_html = html
+
+ @api.depends("document_ids", "document_ids.document_type_id", "request_type_id.required_document_ids")
+ def _compute_missing_required_documents(self):
+ for rec in self:
+ required = rec.request_type_id.required_document_ids if rec.request_type_id else None
+ if not required:
+ rec.missing_required_document_ids = self.env["spp.vocabulary.code"]
+ rec.documents_complete = True
+ continue
+ uploaded = rec.document_ids.mapped("document_type_id").filtered(lambda c: c)
+ missing = required - uploaded
+ rec.missing_required_document_ids = missing
+ rec.documents_complete = not bool(missing)
+
+ @api.depends("document_ids", "document_ids.document_type_id", "request_type_id.required_document_ids")
+ def _compute_required_documents_html(self):
+ for rec in self:
+ required = rec.request_type_id.required_document_ids if rec.request_type_id else None
+ if not required:
+ rec.required_documents_html = (
+ '
'
+ ''
+ "Documents are optional for this request type. "
+ "You may upload supporting documents or proceed to the next step."
+ "
"
+ )
+ continue
+
+ uploaded_types = rec.document_ids.mapped("document_type_id").filtered(lambda c: c)
+ items = []
+ for doc_type in required:
+ if doc_type in uploaded_types:
+ items.append(
+ f'
'
+ f''
+ f"{doc_type.display_name}
"
+ )
+ else:
+ items.append(
+ f'
'
+ f''
+ f"{doc_type.display_name}
"
+ )
+
+ rec.required_documents_html = (
+ '
'
+ 'Required Documents:'
+ f'
{"".join(items)}
'
+ "
"
+ )
+
@api.depends("approval_state", "is_applied")
def _compute_display_state(self):
for rec in self:
@@ -312,6 +459,60 @@ def _compute_preview_html(self):
# Generate fresh preview
rec.preview_html = rec._generate_preview_html()
+ def _compute_review_comparison_html(self):
+ """Compute side-by-side comparison HTML for the review stage.
+
+ For field-mapping CR types: shows a three-column table (Field | Current | Proposed).
+ For action CR types: shows a clean summary table of the action details.
+ Uses stored snapshot after apply (since current == proposed post-apply).
+ """
+ for rec in self:
+ if rec.is_applied and rec.review_comparison_html_snapshot:
+ rec.review_comparison_html = rec.review_comparison_html_snapshot
+ else:
+ rec.review_comparison_html = rec._generate_review_comparison_html()
+
+ @api.depends("document_ids")
+ def _compute_review_documents_html(self):
+ """Compute HTML table for documents matching the proposed changes table style."""
+ for rec in self:
+ if not rec.document_ids:
+ rec.review_documents_html = (
+ '
'
+ ''
+ "No documents attached."
+ "
"
+ )
+ continue
+
+ html = ['
']
+ html.append(
+ "
"
+ '
File
'
+ '
Document Type
'
+ '
Uploaded
'
+ "
"
+ )
+ html.append("")
+
+ for doc in rec.document_ids:
+ doc_name = doc.name or ""
+ doc_type = doc.document_type_id.display_name if doc.document_type_id else ""
+ uploaded = doc.create_date.strftime("%Y-%m-%d") if doc.create_date else ""
+ html.append(
+ f"
")
+ rec.review_documents_html = "".join(html)
+
def _compute_registrant_summary_html(self):
"""Compute HTML summary of the registrant for the review panel."""
for rec in self:
@@ -333,7 +534,9 @@ def _compute_registrant_summary_html(self):
# ID badge
if hasattr(reg, "spp_id") and reg.spp_id:
- html_parts.append(f'
"
)
html_parts.append("")
return "".join(html_parts)
+ def _generate_review_comparison_html(self):
+ """Generate comparison HTML for the review stage.
+
+ For field-mapping types (old/new pairs): renders a three-column
+ comparison table showing Field | Current | Proposed.
+ For action types: renders a summary table of the action details.
+ """
+ self.ensure_one()
+
+ if not self.request_type_id or not self.detail_res_id:
+ return (
+ '
' '' "No changes to review yet." "
"
+ )
+
+ try:
+ # Use sudo() so validators can preview memberships of disabled registrants
+ sudo_self = self.sudo()
+ strategy = sudo_self.request_type_id.get_apply_strategy()
+ changes = strategy.preview(sudo_self) or {}
+ except Exception as e:
+ _logger.warning("Error computing review comparison for CR ID %s: %s", self.id, e)
+ return (
+ '
'
+ ''
+ "Could not load review data."
+ "
"
+ )
+
+ action = changes.pop("_action", 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)
+
+ def _render_comparison_table(self, changes):
+ """Render a three-column comparison table for field-mapping CR types."""
+ html = ['
']
+ html.append(
+ "
"
+ '
'
+ '
Current
'
+ '
Proposed
'
+ "
"
+ )
+ html.append("")
+
+ for key, value in changes.items():
+ if key.startswith("_"):
+ continue
+ display_key = key.replace("_", " ").title()
+
+ if isinstance(value, dict) and "old" in value:
+ old_val = value.get("old")
+ new_val = value.get("new")
+ old_display = self._format_review_value(old_val)
+ new_display = self._format_review_value(new_val)
+
+ # Highlight changed values
+ changed = old_val != new_val
+ new_class = ' class="text-success fw-bold"' if changed else ""
+ old_class = ' class="text-muted"' if changed else ""
+
+ html.append(
+ f"
"
+ f'
{display_key}
'
+ f"
{old_display}
"
+ f"
{new_display}
"
+ f"
"
+ )
+ else:
+ # Non-comparison field — span across both columns
+ display_value = self._format_review_value(value)
+ html.append(
+ f"
"
+ f'
{display_key}
'
+ f'
{display_value}
'
+ f"
"
+ )
+
+ html.append("
")
+ return "".join(html)
+
+ def _render_action_summary(self, action, changes):
+ """Render a summary table for action-based CR types."""
+ html = []
+
+ if not changes:
+ html.append(
+ '
' '' "No details to display." "
"
+ )
+ return "".join(html)
+
+ html.append('
')
+ html.append("
" '
' '
Value
' "
")
+ html.append("")
+
+ for key, value in changes.items():
+ if key.startswith("_"):
+ continue
+ display_key = key.replace("_", " ").title()
+ display_value = self._format_review_value(value)
+ html.append(f'
{display_key}
{display_value}
')
+
+ html.append("
")
+ return "".join(html)
+
+ def _format_review_value(self, value):
+ """Format a single value for display in review tables."""
+ if value is None or value is False or value == "":
+ return '—'
+ if isinstance(value, bool):
+ return 'Yes'
+ if isinstance(value, list):
+ if value:
+ return " ".join(str(v) for v in value)
+ return '—'
+ return str(value)
+
def _capture_preview_snapshot(self):
"""Capture and store the preview HTML and JSON before applying changes."""
self.ensure_one()
import json
self.preview_html_snapshot = self._generate_preview_html()
+ self.review_comparison_html_snapshot = self._generate_review_comparison_html()
- # Also capture the JSON data
- strategy = self.request_type_id.get_apply_strategy()
- changes = strategy.preview(self) or {}
+ # Also capture the JSON data (use sudo for record-rule bypass)
+ sudo_self = self.sudo()
+ strategy = sudo_self.request_type_id.get_apply_strategy()
+ changes = strategy.preview(sudo_self) or {}
self.preview_json_snapshot = json.dumps(changes, indent=2, default=str)
def action_apply(self):
@@ -765,16 +1147,26 @@ def action_apply(self):
}
)
rec._create_audit_event("applied", "approved", "applied")
+ rec._create_log("applied")
except Exception as e:
_logger.exception("Failed to apply change request %s", rec.name)
rec.write({"apply_error": str(e)})
raise
def _do_apply(self):
- """Execute the apply strategy."""
+ """Execute the apply strategy.
+
+ Uses sudo() because the apply operation is a system action that
+ executes already-approved changes. The approval workflow (single
+ or multi-tier) is the security gate — by the time we reach here,
+ approval_state == 'approved' has been verified. Strategies may
+ need to modify models the validator doesn't have direct access
+ to (e.g. spp.group.membership blocked by global record rules).
+ """
self.ensure_one()
- strategy = self.request_type_id.get_apply_strategy()
- strategy.apply(self)
+ sudo_self = self.sudo()
+ strategy = sudo_self.request_type_id.get_apply_strategy()
+ strategy.apply(sudo_self)
def action_preview_changes(self):
"""Preview what changes will be applied (returns data dict)."""
@@ -842,7 +1234,8 @@ def _validate_documents(self):
# Fall back to legacy field if available
if cr_type.required_document_type_ids:
_logger.warning(
- "CR Type %s using deprecated required_document_type_ids. Please migrate to required_document_ids",
+ "CR Type %s using deprecated required_document_type_ids. "
+ "Please migrate to required_document_ids",
cr_type.name,
)
return None
@@ -881,12 +1274,28 @@ def _validate_documents(self):
# AUDIT (ADR-002)
# ══════════════════════════════════════════════════════════════════════════
+ def _create_log(self, action, notes=False):
+ """Create a log entry for this change request."""
+ self.ensure_one()
+ self.env["spp.change.request.log"].sudo().create(
+ {
+ "change_request_id": self.id,
+ "action": action,
+ "user_id": self.env.user.id,
+ "notes": notes,
+ }
+ )
+
def _create_audit_event(self, action, old_state, new_state):
"""Create event data record for audit trail."""
self.ensure_one()
if "spp.event.data" not in self.env:
return
+ # Skip audit event if no registrant (e.g., Create Group type)
+ if not self.registrant_id:
+ return
+
event_type = self.env.ref(
"spp_change_request_v2.event_type_cr_audit",
raise_if_not_found=False,
@@ -894,7 +1303,7 @@ def _create_audit_event(self, action, old_state, new_state):
if not event_type:
return
- self.env["spp.event.data"].sudo().create( # nosemgrep: odoo-sudo-without-context
+ self.env["spp.event.data"].sudo().create(
{
"event_type_id": event_type.id,
"partner_id": self.registrant_id.id,
@@ -934,7 +1343,7 @@ def action_open_detail(self):
"res_model": self.detail_res_model,
"res_id": detail.id,
"view_mode": "form",
- "view_id": view_id,
+ "views": [[view_id, "form"]],
"target": "current",
"context": {
"create": False,
@@ -968,3 +1377,140 @@ def action_upload_document(self):
"default_change_request_id": self.id,
},
}
+
+ # ══════════════════════════════════════════════════════════════════════════
+ # STAGE NAVIGATION
+ # ══════════════════════════════════════════════════════════════════════════
+
+ def action_open_stage_form(self):
+ """Open the appropriate form view based on the current stage.
+
+ For draft/revision CRs: routes to the stage-specific form.
+ For other states: opens the main CR form (for validators/managers).
+ """
+ self.ensure_one()
+
+ if self.approval_state not in ("draft", "revision"):
+ return {
+ "type": "ir.actions.act_window",
+ "name": self.name,
+ "res_model": "spp.change.request",
+ "res_id": self.id,
+ "view_mode": "form",
+ "views": [[False, "form"]],
+ "target": "current",
+ }
+
+ if self.stage == "documents":
+ return self._action_open_documents_form()
+ if self.stage == "review":
+ return self._action_open_review_form()
+
+ # Default: details stage
+ return self.action_open_detail()
+
+ def action_goto_details(self):
+ """Navigate to the details stage (replaces breadcrumb via client action)."""
+ self.ensure_one()
+ self.stage = "details"
+ detail = self._ensure_detail()
+ return {
+ "type": "ir.actions.client",
+ "tag": "navigate_cr_stage",
+ "params": {
+ "name": _("Change Request Details"),
+ "res_model": self.detail_res_model,
+ "res_id": detail.id,
+ "context": {
+ "create": False,
+ "delete": False,
+ "form_view_initial_mode": "edit",
+ },
+ },
+ }
+
+ def action_start_over(self):
+ """Create a new CR with the same type/registrant and open its detail form."""
+ self.ensure_one()
+ cr_vals = {
+ "request_type_id": self.request_type_id.id,
+ "source_type": "manual",
+ }
+ if self.registrant_id:
+ cr_vals["registrant_id"] = self.registrant_id.id
+ new_change_request = self.env["spp.change.request"].create(cr_vals)
+
+ detail = new_change_request.get_detail()
+ if detail:
+ view_id = self.request_type_id.get_detail_form_view_id()
+ return {
+ "type": "ir.actions.client",
+ "tag": "navigate_cr_stage",
+ "params": {
+ "name": _("Change Request Details"),
+ "res_model": new_change_request.detail_res_model,
+ "res_id": detail.id,
+ "view_id": view_id,
+ "context": {
+ "create": False,
+ "delete": False,
+ "form_view_initial_mode": "edit",
+ },
+ },
+ }
+
+ return {
+ "type": "ir.actions.client",
+ "tag": "navigate_cr_stage",
+ "params": {
+ "name": _("Change Request"),
+ "res_model": "spp.change.request",
+ "res_id": new_change_request.id,
+ "context": {
+ "form_view_initial_mode": "edit",
+ },
+ },
+ }
+
+ def action_save_and_go_to_list(self):
+ """Save current state and navigate back to the CR list."""
+ return {
+ "type": "ir.actions.client",
+ "tag": "navigate_cr_list",
+ }
+
+ def action_goto_documents(self):
+ """Navigate to the documents stage (replaces breadcrumb via client action)."""
+ self.ensure_one()
+ self.stage = "documents"
+ return {
+ "type": "ir.actions.client",
+ "tag": "navigate_cr_stage",
+ "params": {
+ "name": _("Documents - %s") % self.name,
+ "res_model": "spp.change.request",
+ "res_id": self.id,
+ "context": {
+ "form_view_ref": "spp_change_request_v2.spp_change_request_documents_form",
+ "form_view_initial_mode": "edit",
+ },
+ },
+ }
+
+ def action_goto_review(self):
+ """Navigate to the review stage (replaces breadcrumb via client action)."""
+ self.ensure_one()
+ self.stage = "review"
+ return {
+ "type": "ir.actions.client",
+ "tag": "navigate_cr_stage",
+ "params": {
+ "name": _("Review - %s") % self.name,
+ "res_model": "spp.change.request",
+ "res_id": self.id,
+ "context": {
+ "form_view_ref": "spp_change_request_v2.spp_change_request_review_form",
+ "form_view_initial_mode": "edit",
+ },
+ },
+ }
diff --git a/spp_change_request_v2/models/change_request_conflict.py b/spp_change_request_v2/models/change_request_conflict.py
index 945cb1f6..b976537c 100644
--- a/spp_change_request_v2/models/change_request_conflict.py
+++ b/spp_change_request_v2/models/change_request_conflict.py
@@ -37,7 +37,7 @@ def create(self, vals_list):
_logger.exception("Conflict detection failed for CR %s", record.name)
# Post message so user is aware of the issue
record.message_post(
- body=_("Warning: Conflict detection failed: %s. Manual review recommended.") % str(e),
+ body=_("Warning: Conflict detection failed: %s. " "Manual review recommended.") % str(e),
message_type="notification",
)
@@ -64,7 +64,7 @@ def write(self, vals):
# Log at error level and notify via message
_logger.exception("Conflict re-check failed for CR %s", record.name)
record.message_post(
- body=_("Warning: Conflict re-check failed: %s. Manual review recommended.") % str(e),
+ body=_("Warning: Conflict re-check failed: %s. " "Manual review recommended.") % str(e),
message_type="notification",
)
diff --git a/spp_change_request_v2/models/change_request_detail_base.py b/spp_change_request_v2/models/change_request_detail_base.py
index 3be7372a..852160bc 100644
--- a/spp_change_request_v2/models/change_request_detail_base.py
+++ b/spp_change_request_v2/models/change_request_detail_base.py
@@ -28,6 +28,15 @@ def _compute_display_name(self):
index=True,
)
+ is_cr_manager = fields.Boolean(
+ compute="_compute_is_cr_manager",
+ )
+
+ def _compute_is_cr_manager(self):
+ is_manager = self.env.user.has_group("spp_change_request_v2.group_cr_manager")
+ for rec in self:
+ rec.is_cr_manager = is_manager
+
# Convenience access to CR fields
registrant_id = fields.Many2one(
related="change_request_id.registrant_id",
@@ -41,6 +50,9 @@ def _compute_display_name(self):
is_applied = fields.Boolean(
related="change_request_id.is_applied",
)
+ stage = fields.Selection(
+ related="change_request_id.stage",
+ )
def action_proceed_to_cr(self):
"""Navigate to the parent Change Request form if there are proposed changes."""
@@ -57,6 +69,17 @@ def action_proceed_to_cr(self):
"target": "current",
}
+ def action_save_and_go_to_list(self):
+ """Save current state and navigate back to the CR list."""
+ return self.change_request_id.action_save_and_go_to_list()
+
+ def action_next_documents(self):
+ """Save and navigate to the documents stage."""
+ self.ensure_one()
+ if not self.change_request_id.has_proposed_changes:
+ raise UserError(_("No proposed changes detected. Please make changes before proceeding."))
+ return self.change_request_id.action_goto_documents()
+
def action_submit_for_approval(self):
"""Submit the parent CR for approval."""
self.ensure_one()
diff --git a/spp_change_request_v2/models/change_request_log.py b/spp_change_request_v2/models/change_request_log.py
new file mode 100644
index 00000000..66fc2f88
--- /dev/null
+++ b/spp_change_request_v2/models/change_request_log.py
@@ -0,0 +1,54 @@
+from odoo import api, fields, models
+
+ACTION_LABELS = {
+ "created": "Created",
+ "submitted": "Submitted for Approval",
+ "approved": "Approved",
+ "rejected": "Rejected",
+ "revision_requested": "Revision Requested",
+ "reset_to_draft": "Reset to Draft",
+ "applied": "Changes Applied",
+ "resubmitted": "Resubmitted for Review",
+}
+
+
+class SPPChangeRequestLog(models.Model):
+ _name = "spp.change.request.log"
+ _description = "Change Request Log"
+ _order = "id desc"
+
+ change_request_id = fields.Many2one(
+ "spp.change.request",
+ required=True,
+ ondelete="cascade",
+ index=True,
+ )
+ action = fields.Selection(
+ [
+ ("created", "Created"),
+ ("submitted", "Submitted for Approval"),
+ ("approved", "Approved"),
+ ("rejected", "Rejected"),
+ ("revision_requested", "Revision Requested"),
+ ("reset_to_draft", "Reset to Draft"),
+ ("applied", "Changes Applied"),
+ ("resubmitted", "Resubmitted for Review"),
+ ],
+ required=True,
+ )
+ action_label = fields.Char(
+ compute="_compute_action_label",
+ string="Action",
+ )
+ user_id = fields.Many2one(
+ "res.users",
+ string="By",
+ default=lambda self: self.env.user,
+ required=True,
+ )
+ notes = fields.Text()
+
+ @api.depends("action")
+ def _compute_action_label(self):
+ for rec in self:
+ rec.action_label = ACTION_LABELS.get(rec.action, rec.action)
diff --git a/spp_change_request_v2/models/change_request_type.py b/spp_change_request_v2/models/change_request_type.py
index b78feb43..7680f964 100644
--- a/spp_change_request_v2/models/change_request_type.py
+++ b/spp_change_request_v2/models/change_request_type.py
@@ -77,6 +77,12 @@ class SPPChangeRequestType(models.Model):
required=True,
)
+ is_requires_registrant = fields.Boolean(
+ default=True,
+ help="Require selecting a registrant when creating this type of change request. "
+ "Disable for types like 'Create New Group' that don't apply to an existing registrant.",
+ )
+
is_requires_applicant = fields.Boolean(
default=False,
help="Require an applicant (person submitting on behalf of registrant)",
@@ -146,6 +152,12 @@ class SPPChangeRequestType(models.Model):
string="Required Documents (Deprecated)",
help="Deprecated: Use required_document_ids instead",
)
+ allow_document_download = fields.Boolean(
+ string="Allow Document Download",
+ default=False,
+ help="Allow users to download attached documents from the change request.",
+ )
+
document_validation_mode = fields.Selection(
[
("none", "No Validation"),
diff --git a/spp_change_request_v2/models/conflict_mixin.py b/spp_change_request_v2/models/conflict_mixin.py
index 59af695e..0c7aa5ed 100644
--- a/spp_change_request_v2/models/conflict_mixin.py
+++ b/spp_change_request_v2/models/conflict_mixin.py
@@ -562,7 +562,7 @@ def _create_conflict_audit_event(self, action, details=None):
if details:
data["details"] = details
- self.env["spp.event.data"].sudo().create( # nosemgrep: odoo-sudo-without-context
+ self.env["spp.event.data"].sudo().create(
{
"event_type_id": event_type.id,
"partner_id": self.registrant_id.id,
diff --git a/spp_change_request_v2/models/conflict_rule.py b/spp_change_request_v2/models/conflict_rule.py
index 2ddb29d6..ad3917d2 100644
--- a/spp_change_request_v2/models/conflict_rule.py
+++ b/spp_change_request_v2/models/conflict_rule.py
@@ -174,7 +174,7 @@ def get_conflict_message(self, conflicting_crs):
)
elif self.action == "warn":
return (
- _("Warning: potential conflict with existing change request(s): %s. Review before proceeding.")
+ _("Warning: potential conflict with existing change request(s): %s. " "Review before proceeding.")
% cr_refs
)
else: # log
diff --git a/spp_change_request_v2/models/dms_file.py b/spp_change_request_v2/models/dms_file.py
index cc533aa7..1ec6a0cc 100644
--- a/spp_change_request_v2/models/dms_file.py
+++ b/spp_change_request_v2/models/dms_file.py
@@ -1,4 +1,15 @@
-from odoo import fields, models
+from odoo import Command, api, fields, models
+
+PREVIEWABLE_MIMETYPES = {
+ "application/pdf",
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+ "image/svg+xml",
+ "video/mp4",
+ "video/webm",
+}
class SPPDMSFile(models.Model):
@@ -13,3 +24,39 @@ class SPPDMSFile(models.Model):
index=True,
help="Type of document from the standard document types vocabulary",
)
+
+ is_previewable = fields.Boolean(
+ compute="_compute_is_previewable",
+ string="Can Preview",
+ )
+
+ @api.depends("mimetype")
+ def _compute_is_previewable(self):
+ for rec in self:
+ rec.is_previewable = rec.mimetype in PREVIEWABLE_MIMETYPES
+
+ def action_preview(self):
+ """Open file preview in the browser."""
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_url",
+ "url": f"/web/content/spp.dms.file/{self.id}/content/{self.name}",
+ "target": "new",
+ }
+
+ def action_download(self):
+ """Download the file."""
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_url",
+ "url": f"/web/content/spp.dms.file/{self.id}/content/{self.name}?download=true",
+ "target": "self",
+ }
+
+ def action_remove_from_cr(self):
+ """Remove this document from its change request and delete the file."""
+ self.ensure_one()
+ change_request = self.env["spp.change.request"].search([("document_ids", "in", self.id)], limit=1)
+ if change_request:
+ change_request.write({"document_ids": [Command.unlink(self.id)]})
+ self.unlink()
diff --git a/spp_change_request_v2/models/duplicate_config.py b/spp_change_request_v2/models/duplicate_config.py
index 6d2252b2..36b5efec 100644
--- a/spp_change_request_v2/models/duplicate_config.py
+++ b/spp_change_request_v2/models/duplicate_config.py
@@ -57,7 +57,7 @@ def _compute_name(self):
)
check_fields = fields.Char(
- help=("Comma-separated list of detail fields to compare. If empty, compares all fields."),
+ help=("Comma-separated list of detail fields to compare. " "If empty, compares all fields."),
)
# ══════════════════════════════════════════════════════════════════════════
diff --git a/spp_change_request_v2/security/compliance.yaml b/spp_change_request_v2/security/compliance.yaml
index eaebe712..70b51034 100644
--- a/spp_change_request_v2/security/compliance.yaml
+++ b/spp_change_request_v2/security/compliance.yaml
@@ -51,8 +51,7 @@ groups:
privilege_id: privilege_cr_conflict_approver
implied_ids: [group_cr_validator]
comment:
- "Permission to override blocking conflicts on change requests. Typically assigned
- to managers or supervisors."
+ "Permission to override blocking conflicts on change requests. Typically assigned to managers or supervisors."
# Admin linkage - manager group links to spp_security.group_spp_admin
admin_link_group: group_cr_manager
@@ -151,8 +150,7 @@ record_rules:
- id: rule_cr_user
model: spp.change.request
groups: [group_cr_user]
- domain_description:
- "Users can see their own CRs and CRs for registrants they have access to"
+ domain_description: "Users can see their own CRs and CRs for registrants they have access to"
perm_read: true
perm_write: true
perm_create: true
diff --git a/spp_change_request_v2/security/groups.xml b/spp_change_request_v2/security/groups.xml
index 25d61d09..9ed114e0 100644
--- a/spp_change_request_v2/security/groups.xml
+++ b/spp_change_request_v2/security/groups.xml
@@ -3,80 +3,68 @@
-
- Change Request: Read
- Technical group for read access to change request models.
-
+
+ Change Request: Read
+ Technical group for read access to change request models.
+
-
- Change Request: Write
- Technical group for write access to change request models.
-
-
+
+ Change Request: Write
+ Technical group for write access to change request models.
+
+
-
-
- User
-
- Can create and submit change requests
-
-
+
+
+ User
+
+ Can create and submit change requests
+
+
-
-
- Validator
-
-
+ Validator
+
+
- Can approve and reject change requests
-
+ ]"/>
+ Can approve and reject change requests
+
-
-
- Validator HQ
-
-
+ Validator HQ
+
+
- Can approve and reject change requests (HQ level)
-
+ ]"/>
+ Can approve and reject change requests (HQ level)
+
-
-
- Manager
-
-
- Full access including configuration
-
+
+
+ Manager
+
+
+ Full access including configuration
+
-
-
-
- Conflict Override Approver
-
- SPECIALIZED ROLE: Permission to override blocking conflicts on change requests. Typically assigned to managers or supervisors. This is a functional role (not part of User/Validator/Manager hierarchy).
-
-
+
+
+
+ Conflict Override Approver
+
+ SPECIALIZED ROLE: Permission to override blocking conflicts on change requests. Typically assigned to managers or supervisors. This is a functional role (not part of User/Validator/Manager hierarchy).
+
+
-
-
-
-
+
+
+
+
diff --git a/spp_change_request_v2/security/ir.model.access.csv b/spp_change_request_v2/security/ir.model.access.csv
index 584aa193..6cb0aa5c 100644
--- a/spp_change_request_v2/security/ir.model.access.csv
+++ b/spp_change_request_v2/security/ir.model.access.csv
@@ -119,3 +119,7 @@ access_ir_model_fields_cr_user,ir.model.fields cr user,base.model_ir_model_field
access_ir_model_fields_cr_validator,ir.model.fields cr validator,base.model_ir_model_fields,group_cr_validator,1,0,0,0
access_ir_model_fields_cr_validator_hq,ir.model.fields cr validator hq,base.model_ir_model_fields,group_cr_validator_hq,1,0,0,0
access_ir_model_fields_cr_manager,ir.model.fields cr manager,base.model_ir_model_fields,group_cr_manager,1,0,0,0
+access_spp_change_request_log_user,spp.change.request.log user,model_spp_change_request_log,group_cr_user,1,0,0,0
+access_spp_change_request_log_validator,spp.change.request.log validator,model_spp_change_request_log,group_cr_validator,1,0,0,0
+access_spp_change_request_log_validator_hq,spp.change.request.log validator hq,model_spp_change_request_log,group_cr_validator_hq,1,0,0,0
+access_spp_change_request_log_manager,spp.change.request.log manager,model_spp_change_request_log,group_cr_manager,1,1,1,1
diff --git a/spp_change_request_v2/security/privileges.xml b/spp_change_request_v2/security/privileges.xml
index b743b980..6f3bbc41 100644
--- a/spp_change_request_v2/security/privileges.xml
+++ b/spp_change_request_v2/security/privileges.xml
@@ -1,11 +1,11 @@
-
+
Change Requests
-
+ Access to change request management system10
@@ -15,10 +15,8 @@
Change Requests: Specialized Roles
-
- Specialized functional roles for change request workflows (Conflict Override Approver)
+
+ Specialized functional roles for change request workflows (Conflict Override Approver)20
diff --git a/spp_change_request_v2/security/rules.xml b/spp_change_request_v2/security/rules.xml
index 8d167580..304e7e1e 100644
--- a/spp_change_request_v2/security/rules.xml
+++ b/spp_change_request_v2/security/rules.xml
@@ -1,56 +1,56 @@
-
+
Change Request: User Access
-
+ [
'|',
('create_uid', '=', user.id),
('registrant_id', 'in', user.partner_id.ids)
]
-
-
-
-
-
+
+
+
+
+ Change Request: Validator Access
-
+ [(1, '=', 1)]
-
-
-
-
-
+
+
+
+
+ Change Request: HQ Validator Access
-
+ [(1, '=', 1)]
-
-
-
-
-
+
+
+
+
+ Change Request: Manager Access
-
+ [(1, '=', 1)]
-
-
-
-
-
+
+
+
+
+
diff --git a/spp_change_request_v2/static/src/components/global_shortcuts/global_shortcuts.js b/spp_change_request_v2/static/src/components/global_shortcuts/global_shortcuts.js
index 29543878..19fef94b 100644
--- a/spp_change_request_v2/static/src/components/global_shortcuts/global_shortcuts.js
+++ b/spp_change_request_v2/static/src/components/global_shortcuts/global_shortcuts.js
@@ -53,25 +53,16 @@ const globalShortcutsService = {
}
if (actionName === "approve" && !cr.can_approve) {
- notification.add(
- "You don't have permission to approve this request",
- {
- type: "warning",
- }
- );
+ notification.add("You don't have permission to approve this request", {
+ type: "warning",
+ });
return;
}
- if (
- (actionName === "reject" || actionName === "revision") &&
- !cr.can_reject
- ) {
- notification.add(
- "You don't have permission to reject this request",
- {
- type: "warning",
- }
- );
+ if ((actionName === "reject" || actionName === "revision") && !cr.can_reject) {
+ notification.add("You don't have permission to reject this request", {
+ type: "warning",
+ });
return;
}
diff --git a/spp_change_request_v2/static/src/components/review_panel/review_panel.js b/spp_change_request_v2/static/src/components/review_panel/review_panel.js
index 7f0aa8fa..8d942a5b 100644
--- a/spp_change_request_v2/static/src/components/review_panel/review_panel.js
+++ b/spp_change_request_v2/static/src/components/review_panel/review_panel.js
@@ -130,11 +130,7 @@ export class CRReviewPanel extends Component {
async loadPreviewData() {
try {
- const result = await this.orm.call(
- "spp.change.request",
- "action_preview_changes",
- [[this.props.crId]]
- );
+ const result = await this.orm.call("spp.change.request", "action_preview_changes", [[this.props.crId]]);
this.state.previewData = result;
} catch (error) {
console.warn("Could not load preview data:", error);
@@ -145,11 +141,11 @@ export class CRReviewPanel extends Component {
try {
// Get approval reviews to determine current tier
if (this.state.crData.approval_review_ids?.length > 0) {
- const reviews = await this.orm.read(
- "spp.approval.review",
- this.state.crData.approval_review_ids,
- ["status", "current_tier", "tier_review_ids"]
- );
+ const reviews = await this.orm.read("spp.approval.review", this.state.crData.approval_review_ids, [
+ "status",
+ "current_tier",
+ "tier_review_ids",
+ ]);
const pendingReview = reviews.find((r) => r.status === "pending");
if (pendingReview) {
this.state.tierInfo = {
@@ -209,14 +205,9 @@ export class CRReviewPanel extends Component {
if (this.state.showApproveComment) {
// Submit approval with comment
try {
- await this.orm.call(
- "spp.change.request",
- "action_approve",
- [[this.props.crId]],
- {
- comment: this.state.approveComment,
- }
- );
+ await this.orm.call("spp.change.request", "action_approve", [[this.props.crId]], {
+ comment: this.state.approveComment,
+ });
this.notification.add(_t("Request approved"), {type: "success"});
this.state.showApproveComment = false;
this.onNext();
@@ -253,10 +244,7 @@ export class CRReviewPanel extends Component {
async onDecline() {
if (this.state.showRejectReason && this.state.rejectReason) {
try {
- await this.orm.call("spp.change.request", "_do_reject", [
- [this.props.crId],
- this.state.rejectReason,
- ]);
+ await this.orm.call("spp.change.request", "_do_reject", [[this.props.crId], this.state.rejectReason]);
this.notification.add(_t("Request declined"), {type: "warning"});
this.state.showRejectReason = false;
this.onNext();
diff --git a/spp_change_request_v2/static/src/components/review_panel/review_panel.xml b/spp_change_request_v2/static/src/components/review_panel/review_panel.xml
index daf8b5a7..c6f0c836 100644
--- a/spp_change_request_v2/static/src/components/review_panel/review_panel.xml
+++ b/spp_change_request_v2/static/src/components/review_panel/review_panel.xml
@@ -1,25 +1,19 @@
-
+