diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py
index 24984619..0e2bbf18 100644
--- a/spp_change_request_v2/__manifest__.py
+++ b/spp_change_request_v2/__manifest__.py
@@ -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",
@@ -57,6 +59,9 @@
"data/event_types.xml",
"data/user_roles.xml",
],
+ "oca_data_manual": [
+ "data/default_types.xml",
+ ],
"assets": {
"web.assets_backend": [
"spp_change_request_v2/static/src/components/**/*",
@@ -67,6 +72,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..1945e07e
--- /dev/null
+++ b/spp_change_request_v2/data/default_types.xml
@@ -0,0 +1,337 @@
+
+
+
+
+
+
+ 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/details/add_member.py b/spp_change_request_v2/details/add_member.py
index c76739ac..5c41c39f 100644
--- a/spp_change_request_v2/details/add_member.py
+++ b/spp_change_request_v2/details/add_member.py
@@ -29,9 +29,8 @@ 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..d87a0f56 100644
--- a/spp_change_request_v2/details/change_hoh.py
+++ b/spp_change_request_v2/details/change_hoh.py
@@ -41,9 +41,8 @@ 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/split_household.py b/spp_change_request_v2/details/split_household.py
index 5788d8cc..4e44ab87 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")
@@ -249,7 +248,7 @@ def _check_minimum_remaining(self):
@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..8bfd8dea 100644
--- a/spp_change_request_v2/details/transfer_member.py
+++ b/spp_change_request_v2/details/transfer_member.py
@@ -48,9 +48,8 @@ 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..65b6ec0f 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() # nosemgrep: odoo-sudo-without-context
+ 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,67 @@ 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}|{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'
{doc_type.display_name}
'
+ )
+ else:
+ items.append(
+ 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 +451,57 @@ 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:
@@ -404,6 +594,7 @@ def create(self, vals_list):
# Auto-create detail record
record._ensure_detail()
record._create_audit_event("created", None, "draft")
+ record._create_log("created")
# Run conflict detection after creation
if hasattr(record, "_run_conflict_checks"):
record._run_conflict_checks()
@@ -537,6 +728,28 @@ def _ensure_detail(self):
# APPROVAL ACTIONS
# ══════════════════════════════════════════════════════════════════════════
+ def action_approve(self, comment=None):
+ """Override to log intermediate tier approvals in multi-tier workflow.
+
+ The base _on_approve hook only fires after ALL tiers are approved.
+ This captures each intermediate tier approval in the CR log.
+ """
+ # Capture pre-approval state per record
+ pre_states = {}
+ for record in self:
+ if record.approval_state == "pending" and record.is_multitier_approval:
+ pre_states[record.id] = record.current_tier_name
+
+ result = super().action_approve(comment=comment)
+
+ # Log intermediate tier approvals
+ # (final approval is already logged by _on_approve)
+ for record in self:
+ if record.id in pre_states and record.approval_state == "pending":
+ record._create_log("approved")
+
+ return result
+
def action_submit_for_approval(self):
"""Submit for approval with document and required field validation.
@@ -562,26 +775,35 @@ def action_submit_for_approval(self):
doc_validation_result = record._validate_documents()
# Proceed with submission
- result = super(SPPChangeRequest, record).action_submit_for_approval()
+ super(SPPChangeRequest, record).action_submit_for_approval()
+
+ # Build success notification with redirect to CR list
+ list_action = {
+ "type": "ir.actions.client",
+ "tag": "navigate_cr_list",
+ }
- # If warning mode and documents missing, show notification after submission
+ type_name = record.request_type_id.name or ""
+ success_message = _("%s %s successfully submitted for approval.") % (
+ record.name,
+ type_name,
+ )
+
+ # If warning mode and documents missing, append doc warning to message
if doc_validation_result and doc_validation_result.get("notification"):
notification = doc_validation_result["notification"]
+ success_message += "\n" + notification["message"]
- return {
- "type": "ir.actions.client",
- "tag": "display_notification",
- "params": {
- "title": notification["title"],
- "message": notification["message"],
- "type": notification["type"],
- "sticky": notification.get("sticky", False),
- "next": result if isinstance(result, dict) else {"type": "ir.actions.act_window_close"},
- },
- }
-
- # Return normal result if no notification needed
- return result
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "message": success_message,
+ "type": "success",
+ "sticky": False,
+ "next": list_action,
+ },
+ }
return super().action_submit_for_approval()
@@ -604,13 +826,18 @@ def _get_approval_definition(self):
def _on_approve(self):
super()._on_approve()
+ # Signal ORM that approval_state changed (set via raw SQL in _do_approve)
+ # so stored computed fields like display_state get recomputed
+ self.modified(["approval_state"])
self._create_audit_event("approved", "pending", "approved")
+ self._create_log("approved")
if self.request_type_id.auto_apply_on_approve:
self.action_apply()
def _on_reject(self, reason):
super()._on_reject(reason)
self._create_audit_event("rejected", "pending", "rejected")
+ self._create_log("rejected", notes=reason)
def _check_can_submit(self):
"""Override to allow resubmission from revision state."""
@@ -629,15 +856,21 @@ def _on_submit(self):
super()._on_submit()
old_state = "draft" if self.approval_state == "draft" else "revision"
+ action = "resubmitted" if old_state == "revision" else "submitted"
self._create_audit_event("submitted", old_state, "pending")
+ self._create_log(action)
def _on_request_revision(self, notes):
super()._on_request_revision(notes)
self._create_audit_event("revision_requested", "pending", "revision")
+ self._create_log("revision_requested", notes=notes)
+ self.stage = "review"
def _on_reset_to_draft(self):
super()._on_reset_to_draft()
self._create_audit_event("reset_to_draft", self.approval_state, "draft")
+ self._create_log("reset_to_draft")
+ self.stage = "details"
# ══════════════════════════════════════════════════════════════════════════
# APPLY
@@ -651,8 +884,10 @@ def _generate_preview_html(self):
return '
No changes to preview yet.
'
try:
- strategy = self.request_type_id.get_apply_strategy()
- changes = strategy.preview(self) or {}
+ # Use sudo() so validators can preview memberships of disabled registrants
+ sudo_self = self.sudo() # nosemgrep: odoo-sudo-without-context
+ strategy = sudo_self.request_type_id.get_apply_strategy()
+ changes = strategy.preview(sudo_self) or {}
except Exception as e:
_logger.warning("Error computing preview for CR ID %s: %s", self.id, e)
return (
@@ -731,16 +966,135 @@ def _generate_preview_html(self):
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() # nosemgrep: odoo-sudo-without-context
+ 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() # nosemgrep: odoo-sudo-without-context
+ 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 +1119,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() # nosemgrep: odoo-sudo-without-context
+ 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)."""
@@ -881,12 +1245,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( # nosemgrep: odoo-sudo-without-context
+ {
+ "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,
@@ -934,7 +1314,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 +1348,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_detail_base.py b/spp_change_request_v2/models/change_request_detail_base.py
index 3be7372a..950b1b5a 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,37 @@ 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_skip_to_review(self):
+ """Skip documents stage and go directly to review if all required docs are uploaded."""
+ self.ensure_one()
+ change_req = self.change_request_id
+ if not change_req.has_proposed_changes:
+ raise UserError(_("No proposed changes detected. Please make changes before proceeding."))
+ if not change_req.documents_complete:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": _("Missing Documents"),
+ "message": _("Some required documents are missing. Redirecting to Documents stage."),
+ "type": "warning",
+ "sticky": False,
+ "next": change_req.action_goto_documents(),
+ },
+ }
+ return change_req.action_goto_review()
+
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/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/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/static/src/components/global_shortcuts/global_shortcuts.js b/spp_change_request_v2/static/src/components/global_shortcuts/global_shortcuts.js
index 29543878..14c550d0 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
@@ -49,7 +49,8 @@ const globalShortcutsService = {
);
if (cr.approval_state !== "pending") {
- return; // Not in pending state, ignore shortcut
+ // Not in pending state, ignore shortcut
+ return;
}
if (actionName === "approve" && !cr.can_approve) {
diff --git a/spp_change_request_v2/static/src/js/cr_review_documents.js b/spp_change_request_v2/static/src/js/cr_review_documents.js
new file mode 100644
index 00000000..84aed624
--- /dev/null
+++ b/spp_change_request_v2/static/src/js/cr_review_documents.js
@@ -0,0 +1,79 @@
+/* @odoo-module */
+
+import {Component, onMounted, onPatched, useRef} from "@odoo/owl";
+import {registry} from "@web/core/registry";
+import {useFileViewer} from "@web/core/file_viewer/file_viewer_hook";
+import {useService} from "@web/core/utils/hooks";
+
+/**
+ * Widget that renders review_documents_html and hooks up
+ * eye-icon clicks to the Odoo file viewer (same as preview_widget).
+ */
+export class CRReviewDocuments extends Component {
+ static template = "spp_change_request_v2.CRReviewDocuments";
+ static props = ["*"];
+
+ setup() {
+ this.orm = useService("orm");
+ this.fileViewer = useFileViewer();
+ this.rootRef = useRef("root");
+
+ onMounted(() => this._bindPreviewClicks());
+ onPatched(() => this._bindPreviewClicks());
+ }
+
+ get htmlContent() {
+ return this.props.record.data[this.props.name] || "";
+ }
+
+ _bindPreviewClicks() {
+ const root = this.rootRef.el;
+ if (!root) return;
+
+ for (const el of root.querySelectorAll(".o_cr_doc_preview")) {
+ el.addEventListener("click", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const docId = parseInt(el.dataset.docId, 10);
+ if (docId) this._openPreview(docId);
+ });
+ }
+ }
+
+ async _openPreview(docId) {
+ const [record] = await this.orm.read(
+ "spp.dms.file",
+ [docId],
+ ["content", "name", "mimetype"]
+ );
+ if (!record || !record.content) return;
+
+ const binaryData = atob(record.content);
+ const arrayBuffer = new Uint8Array(binaryData.length);
+ for (let i = 0; i < binaryData.length; i++) {
+ arrayBuffer[i] = binaryData.charCodeAt(i);
+ }
+
+ const blob = new Blob([arrayBuffer], {type: record.mimetype});
+ const fileUrl = URL.createObjectURL(blob);
+
+ if (record.mimetype === "application/pdf") {
+ window.open(fileUrl, "_blank");
+ } else {
+ this.fileViewer.open({
+ isImage: Boolean(record.mimetype.startsWith("image/")),
+ isVideo: Boolean(record.mimetype.startsWith("video/")),
+ isViewable: true,
+ displayName: record.name,
+ defaultSource: fileUrl,
+ });
+ }
+ }
+}
+
+const crReviewDocuments = {
+ component: CRReviewDocuments,
+ supportedTypes: ["html"],
+};
+
+registry.category("fields").add("cr_review_documents", crReviewDocuments);
diff --git a/spp_change_request_v2/static/src/js/cr_search_results_field.js b/spp_change_request_v2/static/src/js/cr_search_results_field.js
index 526c5a92..024c9657 100644
--- a/spp_change_request_v2/static/src/js/cr_search_results_field.js
+++ b/spp_change_request_v2/static/src/js/cr_search_results_field.js
@@ -33,7 +33,7 @@ export class CrSearchResultsField extends Component {
row.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
- const partnerId = parseInt(row.dataset.partnerId);
+ const partnerId = parseInt(row.dataset.partnerId, 10);
if (partnerId) {
this.props.record.update({_selected_partner_id: partnerId});
}
diff --git a/spp_change_request_v2/static/src/js/create_change_request.js b/spp_change_request_v2/static/src/js/create_change_request.js
index 1eecd24f..651a2f16 100644
--- a/spp_change_request_v2/static/src/js/create_change_request.js
+++ b/spp_change_request_v2/static/src/js/create_change_request.js
@@ -37,6 +37,46 @@ async function openCRCloseModal(env, action) {
registry.category("actions").add("open_cr_close_modal", openCRCloseModal);
+/**
+ * Client action for stage navigation that replaces the current breadcrumb
+ * instead of pushing a new one. This keeps the breadcrumb clean when
+ * navigating between Details → Documents → Review.
+ */
+async function navigateCRStage(env, action) {
+ const actionService = env.services.action;
+ const params = action.params || {};
+
+ const windowAction = {
+ type: "ir.actions.act_window",
+ name: params.name || "Change Request",
+ res_model: params.res_model,
+ res_id: params.res_id,
+ view_mode: "form",
+ views: [[false, "form"]],
+ target: "current",
+ context: params.context || {},
+ };
+
+ await actionService.doAction(windowAction, {
+ stackPosition: "replaceCurrentAction",
+ });
+}
+
+registry.category("actions").add("navigate_cr_stage", navigateCRStage);
+
+/**
+ * Client action to navigate back to the CR list view, clearing all breadcrumbs.
+ */
+async function navigateCRList(env) {
+ const actionService = env.services.action;
+
+ await actionService.doAction("spp_change_request_v2.action_change_request", {
+ clearBreadcrumbs: true,
+ });
+}
+
+registry.category("actions").add("navigate_cr_list", navigateCRList);
+
patch(ListController.prototype, {
setup() {
super.setup();
@@ -46,19 +86,103 @@ patch(ListController.prototype, {
return;
}
const is_admin = await user.hasGroup("spp_security.group_spp_admin");
- const is_cr_user = await user.hasGroup(
- "spp_change_request_v2.group_cr_user"
+ const is_cr_manager = await user.hasGroup(
+ "spp_change_request_v2.group_cr_manager"
);
- if (is_admin || is_cr_user) {
+ if (is_admin || is_cr_manager) {
this.customListCreateButton = {
label: "New Request",
title: "Create a New Change Request",
className: "o_list_button_add_cr",
};
+ } else {
+ this.activeActions = {...this.activeActions, create: false};
}
});
},
+ /**
+ * Override row-click to route CRs to stage-specific forms.
+ *
+ * For draft/revision CRs: reads stage + detail info, then opens
+ * the appropriate stage form (details/documents/review).
+ * For other states: opens the default CR form.
+ */
+ async openRecord(record) {
+ if (this.model.root.resModel === "spp.change.request") {
+ // Read the fields we need for routing
+ const [crData] = await this.model.orm.read(
+ "spp.change.request",
+ [record.resId],
+ ["stage", "approval_state", "detail_res_model", "detail_res_id"]
+ );
+
+ if (crData) {
+ const isDraftOrRevision =
+ crData.approval_state === "draft" ||
+ crData.approval_state === "revision";
+
+ if (isDraftOrRevision && crData.stage === "documents") {
+ await this.actionService.doAction({
+ type: "ir.actions.act_window",
+ name: "Documents",
+ res_model: "spp.change.request",
+ res_id: record.resId,
+ view_mode: "form",
+ views: [[false, "form"]],
+ target: "current",
+ context: {
+ form_view_ref:
+ "spp_change_request_v2.spp_change_request_documents_form",
+ form_view_initial_mode: "edit",
+ },
+ });
+ return;
+ }
+ if (crData.stage === "review") {
+ await this.actionService.doAction({
+ type: "ir.actions.act_window",
+ name: "Review",
+ res_model: "spp.change.request",
+ res_id: record.resId,
+ view_mode: "form",
+ views: [[false, "form"]],
+ target: "current",
+ context: {
+ form_view_ref:
+ "spp_change_request_v2.spp_change_request_review_form",
+ form_view_initial_mode: "edit",
+ },
+ });
+ return;
+ }
+ // Default: details stage — open the detail model form
+ if (
+ isDraftOrRevision &&
+ crData.detail_res_model &&
+ crData.detail_res_id
+ ) {
+ await this.actionService.doAction({
+ type: "ir.actions.act_window",
+ name: "Change Request Details",
+ res_model: crData.detail_res_model,
+ res_id: crData.detail_res_id,
+ view_mode: "form",
+ views: [[false, "form"]],
+ target: "current",
+ context: {
+ create: false,
+ delete: false,
+ form_view_initial_mode: "edit",
+ },
+ });
+ return;
+ }
+ }
+ }
+ return super.openRecord(record);
+ },
+
/**
* Opens the Create Change Request wizard when the custom button is clicked.
*/
diff --git a/spp_change_request_v2/static/src/xml/cr_review_documents.xml b/spp_change_request_v2/static/src/xml/cr_review_documents.xml
new file mode 100644
index 00000000..cc505abf
--- /dev/null
+++ b/spp_change_request_v2/static/src/xml/cr_review_documents.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/spp_change_request_v2/strategies/field_mapping.py b/spp_change_request_v2/strategies/field_mapping.py
index c182944f..a1c795e6 100644
--- a/spp_change_request_v2/strategies/field_mapping.py
+++ b/spp_change_request_v2/strategies/field_mapping.py
@@ -74,9 +74,8 @@ def apply(self, change_request):
def _eval_expression(self, expr, value, detail, registrant):
"""Safely evaluate transform expression."""
try:
+ # Admin-defined field mapping expressions with restricted context (no env)
return safe_eval( # nosemgrep: odoo-unsafe-safe-eval
- # Admin-defined field mapping expressions with restricted context (no env);
- # reviewed as part of CR strategy engine.
expr,
{
"value": value,
@@ -155,25 +154,27 @@ def preview(self, change_request):
changes = {}
for mapping in cr_type.apply_mapping_ids:
- source_value = getattr(detail, mapping.source_field, None)
- current_value = getattr(registrant, mapping.target_field, None)
+ source_raw = getattr(detail, mapping.source_field, None)
+ current_raw = getattr(registrant, mapping.target_field, None)
- # Skip empty values (same logic as apply)
- # COMMENTED OUT: Users may want to intentionally clear fields
- # if self._is_value_empty(source_value, registrant, mapping.target_field):
- # continue
+ # Get display-friendly values for relational fields
+ source_display = source_raw.display_name if hasattr(source_raw, "display_name") else source_raw
+ current_display = current_raw.display_name if hasattr(current_raw, "display_name") else current_raw
- # Normalize for comparison
- if hasattr(source_value, "id"):
- source_value = source_value.id
- if hasattr(current_value, "id"):
- current_value = current_value.id
+ # Normalize for comparison (use IDs for recordsets)
+ source_cmp = source_raw.id if hasattr(source_raw, "id") else source_raw
+ current_cmp = current_raw.id if hasattr(current_raw, "id") else current_raw
# Only show fields that actually changed
- if source_value != current_value:
- changes[mapping.target_field] = {
- "old": current_value,
- "new": source_value,
+ if source_cmp != current_cmp:
+ # Use field description as label if available
+ field_label = mapping.target_field
+ if mapping.target_field in registrant._fields:
+ field_label = registrant._fields[mapping.target_field].string or field_label
+
+ changes[field_label] = {
+ "old": current_display,
+ "new": source_display,
}
return changes
diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py
index b1a29300..91cbf4e3 100644
--- a/spp_change_request_v2/tests/__init__.py
+++ b/spp_change_request_v2/tests/__init__.py
@@ -19,3 +19,5 @@
from . import test_conflict_detection_extended
from . import test_approval_per_cr_type
from . import test_ux_wizards
+from . import test_stage_navigation
+from . import test_approval_hooks_and_audit
diff --git a/spp_change_request_v2/tests/test_apply_strategies.py b/spp_change_request_v2/tests/test_apply_strategies.py
index 019ce57d..1472d442 100644
--- a/spp_change_request_v2/tests/test_apply_strategies.py
+++ b/spp_change_request_v2/tests/test_apply_strategies.py
@@ -96,8 +96,9 @@ def test_field_mapping_preview(self):
strategy = self.env["spp.cr.strategy.field_mapping"]
preview = strategy.preview(cr)
- self.assertIn("given_name", preview)
- self.assertEqual(preview["given_name"]["new"], "Preview")
+ # preview() returns field labels (not raw field names)
+ self.assertIn("Given Name", preview)
+ self.assertEqual(preview["Given Name"]["new"], "Preview")
def test_manual_strategy_noop(self):
"""Test manual strategy does nothing but returns True."""
diff --git a/spp_change_request_v2/tests/test_approval_hooks_and_audit.py b/spp_change_request_v2/tests/test_approval_hooks_and_audit.py
new file mode 100644
index 00000000..46760c82
--- /dev/null
+++ b/spp_change_request_v2/tests/test_approval_hooks_and_audit.py
@@ -0,0 +1,337 @@
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests import tagged
+
+from .test_change_request import TestChangeRequestBase
+
+
+@tagged("post_install", "-at_install")
+class TestApprovalHooks(TestChangeRequestBase):
+ """Tests for approval hooks in spp.change.request."""
+
+ def _create_cr(self, registrant=None, cr_type=None):
+ """Helper to create a CR for testing."""
+ vals = {
+ "request_type_id": (cr_type or self.cr_type_add_member).id,
+ }
+ if registrant is not False:
+ vals["registrant_id"] = (registrant or self.group).id
+ return self.cr_model.create(vals)
+
+ def test_on_approve_creates_log_and_audit(self):
+ """_on_approve() creates log entry with 'approved' action."""
+ cr = self._create_cr()
+ # Disable auto_apply to avoid action_apply being triggered
+ cr.request_type_id.auto_apply_on_approve = False
+ # _on_approve is called after base mixin sets approved state
+ cr.approval_state = "approved"
+ log_count_before = len(cr.log_ids)
+ cr._on_approve()
+ cr.invalidate_recordset()
+ new_logs = cr.log_ids.filtered(lambda r: r.action == "approved")
+ self.assertTrue(new_logs)
+ self.assertGreater(len(cr.log_ids), log_count_before)
+
+ def test_on_approve_auto_apply(self):
+ """Auto-apply triggers when auto_apply_on_approve=True."""
+ cr = self._create_cr()
+ cr.request_type_id.auto_apply_on_approve = True
+ # _on_approve is called after base mixin sets approved state
+ cr.approval_state = "approved"
+ # action_apply will run the strategy — might fail if strategy
+ # raises but we just want to verify the auto_apply path is taken
+ try:
+ cr._on_approve()
+ except Exception:
+ pass # Strategy-specific errors are OK, we're testing the hook path
+ # Verify it attempted (log was created)
+ cr.invalidate_recordset()
+ new_logs = cr.log_ids.filtered(lambda r: r.action == "approved")
+ self.assertTrue(new_logs)
+
+ def test_on_reject_creates_log_with_reason(self):
+ """_on_reject() logs with reason notes."""
+ cr = self._create_cr()
+ cr.approval_state = "pending"
+ reason = "Missing documentation"
+ cr._on_reject(reason)
+ cr.invalidate_recordset()
+ reject_logs = cr.log_ids.filtered(lambda r: r.action == "rejected")
+ self.assertTrue(reject_logs)
+ self.assertEqual(reject_logs[0].notes, reason)
+
+ def test_on_submit_creates_log(self):
+ """_on_submit() creates log entry with 'submitted' action."""
+ cr = self._create_cr()
+ cr.approval_state = "draft"
+ cr._on_submit()
+ cr.invalidate_recordset()
+ submit_logs = cr.log_ids.filtered(lambda r: r.action == "submitted")
+ self.assertTrue(submit_logs)
+
+ def test_on_submit_resubmission_logs_resubmitted(self):
+ """Resubmit from revision logs 'resubmitted' action."""
+ cr = self._create_cr()
+ # Simulate revision state
+ cr.approval_state = "revision"
+ cr._on_submit()
+ cr.invalidate_recordset()
+ resubmit_logs = cr.log_ids.filtered(lambda r: r.action == "resubmitted")
+ self.assertTrue(resubmit_logs)
+
+ def test_on_request_revision_sets_stage_review(self):
+ """_on_request_revision() sets stage to 'review' and creates log."""
+ cr = self._create_cr()
+ cr.approval_state = "pending"
+ cr._on_request_revision("Please update details")
+ self.assertEqual(cr.stage, "review")
+ cr.invalidate_recordset()
+ rev_logs = cr.log_ids.filtered(lambda r: r.action == "revision_requested")
+ self.assertTrue(rev_logs)
+ self.assertEqual(rev_logs[0].notes, "Please update details")
+
+ def test_on_reset_to_draft_sets_stage_details(self):
+ """_on_reset_to_draft() sets stage to 'details' and creates log."""
+ cr = self._create_cr()
+ cr.stage = "review"
+ cr._on_reset_to_draft()
+ self.assertEqual(cr.stage, "details")
+ cr.invalidate_recordset()
+ reset_logs = cr.log_ids.filtered(lambda r: r.action == "reset_to_draft")
+ self.assertTrue(reset_logs)
+
+ def test_check_can_submit_from_draft(self):
+ """_check_can_submit() passes for draft state."""
+ cr = self._create_cr()
+ cr.approval_state = "draft"
+ # Should not raise
+ cr._check_can_submit()
+
+ def test_check_can_submit_from_revision(self):
+ """_check_can_submit() passes for revision state."""
+ cr = self._create_cr()
+ cr.approval_state = "revision"
+ # Should not raise
+ cr._check_can_submit()
+
+ def test_check_can_submit_from_pending_raises(self):
+ """_check_can_submit() raises UserError from pending state."""
+ cr = self._create_cr()
+ cr.approval_state = "pending"
+ with self.assertRaises(UserError):
+ cr._check_can_submit()
+
+ def test_check_can_submit_from_approved_raises(self):
+ """_check_can_submit() raises UserError from approved state."""
+ cr = self._create_cr()
+ cr.approval_state = "approved"
+ with self.assertRaises(UserError):
+ cr._check_can_submit()
+
+
+@tagged("post_install", "-at_install")
+class TestDocumentValidation(TestChangeRequestBase):
+ """Tests for document validation in spp.change.request."""
+
+ def _create_cr(self, registrant=None, cr_type=None):
+ """Helper to create a CR for testing."""
+ vals = {
+ "request_type_id": (cr_type or self.cr_type_add_member).id,
+ }
+ if registrant is not False:
+ vals["registrant_id"] = (registrant or self.group).id
+ return self.cr_model.create(vals)
+
+ def _get_or_create_doc_vocab(self):
+ """Get or create the document type vocabulary."""
+ vocab = self.env["spp.vocabulary"].search(
+ [("namespace_uri", "=", "urn:openspp:vocab:cr_document_type")], limit=1
+ )
+ if not vocab:
+ vocab = self.env["spp.vocabulary"].create(
+ {
+ "name": "CR Document Types",
+ "namespace_uri": "urn:openspp:vocab:cr_document_type",
+ }
+ )
+ return vocab
+
+ def test_validate_documents_mode_none(self):
+ """Returns None when mode='none'."""
+ cr = self._create_cr()
+ cr.request_type_id.document_validation_mode = "none"
+ result = cr._validate_documents()
+ self.assertIsNone(result)
+
+ def test_validate_documents_no_required(self):
+ """Returns None when no required docs configured."""
+ cr = self._create_cr()
+ cr.request_type_id.document_validation_mode = "required"
+ cr.request_type_id.required_document_ids = False
+ result = cr._validate_documents()
+ self.assertIsNone(result)
+
+ def test_validate_documents_mode_required_raises(self):
+ """Raises ValidationError with missing doc names."""
+ cr = self._create_cr()
+ vocab = self._get_or_create_doc_vocab()
+ doc_type = self.env["spp.vocabulary.code"].create(
+ {
+ "vocabulary_id": vocab.id,
+ "code": "test_cert_val",
+ "display": "Birth Certificate",
+ }
+ )
+ cr.request_type_id.document_validation_mode = "required"
+ cr.request_type_id.required_document_ids = doc_type
+ with self.assertRaises(ValidationError):
+ cr._validate_documents()
+
+ def test_validate_documents_mode_warning_returns_notification(self):
+ """Returns warning notification dict when mode='warning'."""
+ cr = self._create_cr()
+ vocab = self._get_or_create_doc_vocab()
+ doc_type = self.env["spp.vocabulary.code"].create(
+ {
+ "vocabulary_id": vocab.id,
+ "code": "test_id_val",
+ "display": "ID Document",
+ }
+ )
+ cr.request_type_id.document_validation_mode = "warning"
+ cr.request_type_id.required_document_ids = doc_type
+ result = cr._validate_documents()
+ self.assertIsNotNone(result)
+ self.assertIn("notification", result)
+ self.assertEqual(result["notification"]["type"], "warning")
+
+ def test_validate_documents_all_present(self):
+ """Returns None when all required docs uploaded."""
+ cr = self._create_cr()
+ vocab = self._get_or_create_doc_vocab()
+ doc_type = self.env["spp.vocabulary.code"].create(
+ {
+ "vocabulary_id": vocab.id,
+ "code": "test_present_val",
+ "display": "Present Doc",
+ }
+ )
+ cr.request_type_id.document_validation_mode = "required"
+ cr.request_type_id.required_document_ids = doc_type
+
+ # Upload the required document
+ dms_file = self.env["spp.dms.file"].create(
+ {
+ "name": "present.pdf",
+ "document_type_id": doc_type.id,
+ "directory_id": cr.dms_directory_id.id,
+ "content": "dGVzdA==",
+ }
+ )
+ cr.document_ids = dms_file
+ result = cr._validate_documents()
+ self.assertIsNone(result)
+
+
+@tagged("post_install", "-at_install")
+class TestAuditLogging(TestChangeRequestBase):
+ """Tests for audit logging in spp.change.request."""
+
+ def _create_cr(self, registrant=None, cr_type=None):
+ """Helper to create a CR for testing."""
+ vals = {
+ "request_type_id": (cr_type or self.cr_type_add_member).id,
+ }
+ if registrant is not False:
+ vals["registrant_id"] = (registrant or self.group).id
+ return self.cr_model.create(vals)
+
+ def test_create_log_entry(self):
+ """_create_log() creates spp.change.request.log record."""
+ cr = self._create_cr()
+ log_count_before = self.env["spp.change.request.log"].search_count([("change_request_id", "=", cr.id)])
+ cr._create_log("submitted")
+ log_count_after = self.env["spp.change.request.log"].search_count([("change_request_id", "=", cr.id)])
+ self.assertEqual(log_count_after, log_count_before + 1)
+
+ def test_create_log_with_notes(self):
+ """Log includes notes field."""
+ cr = self._create_cr()
+ cr._create_log("rejected", notes="Incomplete submission")
+ log = self.env["spp.change.request.log"].search(
+ [("change_request_id", "=", cr.id), ("action", "=", "rejected")],
+ limit=1,
+ order="id desc",
+ )
+ self.assertTrue(log)
+ self.assertEqual(log.notes, "Incomplete submission")
+
+ def test_create_log_records_user(self):
+ """Log records the current user."""
+ cr = self._create_cr()
+ cr._create_log("approved")
+ log = self.env["spp.change.request.log"].search(
+ [("change_request_id", "=", cr.id), ("action", "=", "approved")],
+ limit=1,
+ order="id desc",
+ )
+ self.assertTrue(log)
+ self.assertEqual(log.user_id.id, self.env.user.id)
+
+ def test_create_audit_event_with_registrant(self):
+ """_create_audit_event() creates spp.event.data record if model exists."""
+ cr = self._create_cr()
+ if "spp.event.data" not in self.env:
+ return # Skip if event module not installed
+ event_type = self.env.ref(
+ "spp_change_request_v2.event_type_cr_audit",
+ raise_if_not_found=False,
+ )
+ if not event_type:
+ return # Skip if event type not defined
+ event_count_before = self.env["spp.event.data"].search_count([("partner_id", "=", cr.registrant_id.id)])
+ cr._create_audit_event("test_action", "draft", "pending")
+ event_count_after = self.env["spp.event.data"].search_count([("partner_id", "=", cr.registrant_id.id)])
+ self.assertEqual(event_count_after, event_count_before + 1)
+
+ def test_create_audit_event_no_registrant_skips(self):
+ """Skips audit event creation if no registrant."""
+ cr = self._create_cr()
+ cr.registrant_id = False
+ # Should not raise, just skip
+ cr._create_audit_event("test_action", "draft", "pending")
+
+ def test_create_audit_event_no_event_model_skips(self):
+ """Skips if spp.event.data not in env (handled gracefully)."""
+ cr = self._create_cr()
+ # The method checks 'spp.event.data' not in self.env
+ # We can't easily remove a model from env, but we can verify
+ # the method doesn't crash when called
+ cr._create_audit_event("test_action", "draft", "pending")
+
+ def test_cr_create_generates_log(self):
+ """CR creation generates a 'created' log entry."""
+ cr = self._create_cr()
+ created_logs = cr.log_ids.filtered(lambda r: r.action == "created")
+ self.assertTrue(created_logs, "CR creation should generate a 'created' log entry")
+
+ def test_log_action_label_computed(self):
+ """Log action_label is computed from action field."""
+ cr = self._create_cr()
+ cr._create_log("submitted")
+ log = self.env["spp.change.request.log"].search(
+ [("change_request_id", "=", cr.id), ("action", "=", "submitted")],
+ limit=1,
+ order="id desc",
+ )
+ self.assertEqual(log.action_label, "Submitted for Approval")
+
+ def test_log_action_label_approved(self):
+ """Log action_label for 'approved' is 'Approved'."""
+ cr = self._create_cr()
+ cr._create_log("approved")
+ log = self.env["spp.change.request.log"].search(
+ [("change_request_id", "=", cr.id), ("action", "=", "approved")],
+ limit=1,
+ order="id desc",
+ )
+ self.assertEqual(log.action_label, "Approved")
diff --git a/spp_change_request_v2/tests/test_stage_navigation.py b/spp_change_request_v2/tests/test_stage_navigation.py
new file mode 100644
index 00000000..91fc64b3
--- /dev/null
+++ b/spp_change_request_v2/tests/test_stage_navigation.py
@@ -0,0 +1,418 @@
+from odoo.tests import tagged
+
+from .test_change_request import TestChangeRequestBase
+
+
+@tagged("post_install", "-at_install")
+class TestStageNavigation(TestChangeRequestBase):
+ """Tests for stage navigation methods in spp.change.request."""
+
+ def _create_cr(self, registrant=None, cr_type=None):
+ """Helper to create a CR for testing."""
+ vals = {
+ "request_type_id": (cr_type or self.cr_type_add_member).id,
+ }
+ if registrant is not False:
+ vals["registrant_id"] = (registrant or self.group).id
+ return self.cr_model.create(vals)
+
+ def test_action_goto_details_sets_stage(self):
+ """action_goto_details() sets stage to 'details'."""
+ cr = self._create_cr()
+ cr.stage = "documents"
+ cr.action_goto_details()
+ self.assertEqual(cr.stage, "details")
+
+ def test_action_goto_details_returns_client_action(self):
+ """action_goto_details() returns navigate_cr_stage client action."""
+ cr = self._create_cr()
+ result = cr.action_goto_details()
+ self.assertEqual(result["type"], "ir.actions.client")
+ self.assertEqual(result["tag"], "navigate_cr_stage")
+ self.assertEqual(result["params"]["res_model"], cr.detail_res_model)
+ detail = cr.get_detail()
+ self.assertEqual(result["params"]["res_id"], detail.id)
+
+ def test_action_goto_details_creates_detail_if_missing(self):
+ """action_goto_details() calls _ensure_detail(), creating detail if missing."""
+ cr = self._create_cr()
+ # Detail is auto-created, so clear it to test re-creation
+ old_detail_id = cr.detail_res_id
+ self.assertTrue(old_detail_id)
+ result = cr.action_goto_details()
+ self.assertTrue(cr.detail_res_id)
+ self.assertEqual(result["params"]["res_model"], cr.detail_res_model)
+
+ def test_action_goto_documents_sets_stage(self):
+ """action_goto_documents() sets stage to 'documents'."""
+ cr = self._create_cr()
+ cr.action_goto_documents()
+ self.assertEqual(cr.stage, "documents")
+
+ def test_action_goto_documents_returns_client_action(self):
+ """action_goto_documents() returns correct client action with documents form_view_ref."""
+ cr = self._create_cr()
+ result = cr.action_goto_documents()
+ self.assertEqual(result["type"], "ir.actions.client")
+ self.assertEqual(result["tag"], "navigate_cr_stage")
+ self.assertEqual(result["params"]["res_model"], "spp.change.request")
+ self.assertEqual(result["params"]["res_id"], cr.id)
+ self.assertIn("spp_change_request_documents_form", result["params"]["context"]["form_view_ref"])
+
+ def test_action_goto_review_sets_stage(self):
+ """action_goto_review() sets stage to 'review'."""
+ cr = self._create_cr()
+ cr.action_goto_review()
+ self.assertEqual(cr.stage, "review")
+
+ def test_action_goto_review_returns_client_action(self):
+ """action_goto_review() returns correct client action with review form_view_ref."""
+ cr = self._create_cr()
+ result = cr.action_goto_review()
+ self.assertEqual(result["type"], "ir.actions.client")
+ self.assertEqual(result["tag"], "navigate_cr_stage")
+ self.assertEqual(result["params"]["res_model"], "spp.change.request")
+ self.assertEqual(result["params"]["res_id"], cr.id)
+ self.assertIn("spp_change_request_review_form", result["params"]["context"]["form_view_ref"])
+
+ def test_action_save_and_go_to_list(self):
+ """action_save_and_go_to_list() returns navigate_cr_list client action."""
+ cr = self._create_cr()
+ result = cr.action_save_and_go_to_list()
+ self.assertEqual(result["type"], "ir.actions.client")
+ self.assertEqual(result["tag"], "navigate_cr_list")
+
+ def test_action_open_stage_form_draft_details(self):
+ """Draft CR with stage=details opens detail form via action_open_detail."""
+ cr = self._create_cr()
+ cr.stage = "details"
+ result = cr.action_open_stage_form()
+ # action_open_detail returns act_window for detail model
+ self.assertEqual(result["type"], "ir.actions.act_window")
+ self.assertEqual(result["res_model"], cr.detail_res_model)
+
+ def test_action_open_stage_form_draft_documents(self):
+ """Draft CR with stage=documents calls _action_open_documents_form."""
+ cr = self._create_cr()
+ cr.stage = "documents"
+ if hasattr(cr, "_action_open_documents_form"):
+ result = cr.action_open_stage_form()
+ self.assertIsInstance(result, dict)
+ else:
+ # Method not yet implemented — verify code path reaches it
+ with self.assertRaises(AttributeError):
+ cr.action_open_stage_form()
+
+ def test_action_open_stage_form_draft_review(self):
+ """Draft CR with stage=review calls _action_open_review_form."""
+ cr = self._create_cr()
+ cr.stage = "review"
+ if hasattr(cr, "_action_open_review_form"):
+ result = cr.action_open_stage_form()
+ self.assertIsInstance(result, dict)
+ else:
+ # Method not yet implemented — verify code path reaches it
+ with self.assertRaises(AttributeError):
+ cr.action_open_stage_form()
+
+ def test_action_open_stage_form_pending(self):
+ """Pending CR opens main form (not stage form)."""
+ cr = self._create_cr()
+ cr.approval_state = "pending"
+ result = cr.action_open_stage_form()
+ self.assertEqual(result["type"], "ir.actions.act_window")
+ self.assertEqual(result["res_model"], "spp.change.request")
+ self.assertEqual(result["res_id"], cr.id)
+ self.assertEqual(result["views"], [[False, "form"]])
+
+ def test_action_start_over_creates_new_cr(self):
+ """action_start_over() creates a new CR with same type and registrant."""
+ cr = self._create_cr()
+ result = cr.action_start_over()
+ # Should return a navigate_cr_stage action
+ self.assertEqual(result["type"], "ir.actions.client")
+ self.assertEqual(result["tag"], "navigate_cr_stage")
+ # The new CR's detail should be different from old one
+ new_detail_id = result["params"]["res_id"]
+ old_detail = cr.get_detail()
+ self.assertNotEqual(new_detail_id, old_detail.id)
+
+ def test_action_start_over_returns_detail_form(self):
+ """action_start_over() returns client action pointing to new detail."""
+ cr = self._create_cr()
+ result = cr.action_start_over()
+ self.assertEqual(result["type"], "ir.actions.client")
+ self.assertEqual(result["tag"], "navigate_cr_stage")
+ self.assertIn("res_model", result["params"])
+ self.assertIn("res_id", result["params"])
+ self.assertFalse(result["params"]["context"].get("create", True) is True)
+
+ def test_action_start_over_without_registrant(self):
+ """action_start_over() works when CR has no registrant."""
+ # Create CR type that doesn't require registrant
+ cr_type = self.cr_type_model.search([("is_requires_registrant", "=", False)], limit=1)
+ if not cr_type:
+ cr_type = self.cr_type_add_member
+ cr = self.cr_model.create(
+ {
+ "request_type_id": cr_type.id,
+ "registrant_id": self.group.id,
+ }
+ )
+ # Clear registrant manually to simulate
+ cr.registrant_id = False
+ result = cr.action_start_over()
+ self.assertEqual(result["type"], "ir.actions.client")
+ self.assertEqual(result["tag"], "navigate_cr_stage")
+
+ def test_stage_default_value(self):
+ """New CR defaults to stage='details'."""
+ cr = self._create_cr()
+ self.assertEqual(cr.stage, "details")
+
+
+@tagged("post_install", "-at_install")
+class TestComputedHtmlFields(TestChangeRequestBase):
+ """Tests for computed HTML fields in spp.change.request."""
+
+ def _create_cr(self, registrant=None, cr_type=None):
+ """Helper to create a CR for testing."""
+ vals = {
+ "request_type_id": (cr_type or self.cr_type_add_member).id,
+ }
+ if registrant is not False:
+ vals["registrant_id"] = (registrant or self.group).id
+ return self.cr_model.create(vals)
+
+ def test_stage_banner_with_registrant(self):
+ """Banner includes CR ref, type name, registrant name."""
+ cr = self._create_cr()
+ cr.invalidate_recordset()
+ banner = cr.stage_banner_html
+ self.assertIn(cr.name, banner)
+ self.assertIn(cr.request_type_id.name, banner)
+ self.assertIn(cr.registrant_id.name, banner)
+ self.assertIn("fa-user", banner)
+
+ def test_stage_banner_without_registrant(self):
+ """Banner without registrant omits registrant section."""
+ cr = self._create_cr()
+ cr.registrant_id = False
+ cr.invalidate_recordset()
+ banner = cr.stage_banner_html
+ self.assertIn(cr.name, banner)
+ self.assertIn(cr.request_type_id.name, banner)
+ # Should NOT have the registrant fa-user icon segment
+ self.assertNotIn("fa-user", banner)
+
+ def test_required_documents_html_no_requirements(self):
+ """Shows 'optional' message when no required docs configured."""
+ cr = self._create_cr()
+ # Ensure CR type has no required documents
+ cr.request_type_id.required_document_ids = False
+ cr.invalidate_recordset()
+ html = cr.required_documents_html
+ self.assertIn("optional", html.lower())
+
+ def test_required_documents_html_with_missing(self):
+ """Shows fa-times-circle for missing docs."""
+ cr = self._create_cr()
+ # Create a vocabulary for required document types
+ vocab = self.env["spp.vocabulary"].search(
+ [("namespace_uri", "=", "urn:openspp:vocab:cr_document_type")], limit=1
+ )
+ if not vocab:
+ vocab = self.env["spp.vocabulary"].create(
+ {
+ "name": "CR Document Types",
+ "namespace_uri": "urn:openspp:vocab:cr_document_type",
+ }
+ )
+ doc_type = self.env["spp.vocabulary.code"].create(
+ {
+ "vocabulary_id": vocab.id,
+ "code": "test_passport",
+ "display": "Passport",
+ }
+ )
+ cr.request_type_id.required_document_ids = doc_type
+ cr.invalidate_recordset()
+ html = cr.required_documents_html
+ self.assertIn("fa-times-circle", html)
+ self.assertIn("Passport", html)
+
+ def test_required_documents_html_all_uploaded(self):
+ """Shows fa-check-circle for complete docs."""
+ cr = self._create_cr()
+ vocab = self.env["spp.vocabulary"].search(
+ [("namespace_uri", "=", "urn:openspp:vocab:cr_document_type")], limit=1
+ )
+ if not vocab:
+ vocab = self.env["spp.vocabulary"].create(
+ {
+ "name": "CR Document Types",
+ "namespace_uri": "urn:openspp:vocab:cr_document_type",
+ }
+ )
+ doc_type = self.env["spp.vocabulary.code"].create(
+ {
+ "vocabulary_id": vocab.id,
+ "code": "test_id_card",
+ "display": "ID Card",
+ }
+ )
+ cr.request_type_id.required_document_ids = doc_type
+
+ # Create a DMS file with the required type and attach to CR
+ dms_file = self.env["spp.dms.file"].create(
+ {
+ "name": "test_id.pdf",
+ "document_type_id": doc_type.id,
+ "directory_id": cr.dms_directory_id.id,
+ "content": "dGVzdA==", # base64 "test"
+ }
+ )
+ cr.document_ids = dms_file
+ cr.invalidate_recordset()
+ html = cr.required_documents_html
+ self.assertIn("fa-check-circle", html)
+
+ def test_missing_required_documents_computed(self):
+ """missing_required_document_ids and documents_complete are computed correctly."""
+ cr = self._create_cr()
+ vocab = self.env["spp.vocabulary"].search(
+ [("namespace_uri", "=", "urn:openspp:vocab:cr_document_type")], limit=1
+ )
+ if not vocab:
+ vocab = self.env["spp.vocabulary"].create(
+ {
+ "name": "CR Document Types",
+ "namespace_uri": "urn:openspp:vocab:cr_document_type",
+ }
+ )
+ doc_type = self.env["spp.vocabulary.code"].create(
+ {
+ "vocabulary_id": vocab.id,
+ "code": "test_photo",
+ "display": "Photo",
+ }
+ )
+ cr.request_type_id.required_document_ids = doc_type
+ cr.invalidate_recordset()
+
+ # Should be missing
+ self.assertEqual(len(cr.missing_required_document_ids), 1)
+ self.assertFalse(cr.documents_complete)
+
+ # Upload the doc
+ dms_file = self.env["spp.dms.file"].create(
+ {
+ "name": "photo.jpg",
+ "document_type_id": doc_type.id,
+ "directory_id": cr.dms_directory_id.id,
+ "content": "dGVzdA==",
+ }
+ )
+ cr.document_ids = dms_file
+ cr.invalidate_recordset()
+
+ self.assertEqual(len(cr.missing_required_document_ids), 0)
+ self.assertTrue(cr.documents_complete)
+
+ def test_review_documents_html_no_docs(self):
+ """Shows 'No documents attached' message."""
+ cr = self._create_cr()
+ cr.document_ids = False
+ cr.invalidate_recordset()
+ html = cr.review_documents_html
+ self.assertIn("No documents attached", html)
+
+ def test_review_documents_html_with_docs(self):
+ """Renders table with file name, type, date."""
+ cr = self._create_cr()
+ dms_file = self.env["spp.dms.file"].create(
+ {
+ "name": "contract.pdf",
+ "directory_id": cr.dms_directory_id.id,
+ "content": "dGVzdA==",
+ }
+ )
+ cr.document_ids = dms_file
+ cr.invalidate_recordset()
+ html = cr.review_documents_html
+ self.assertIn("contract.pdf", html)
+ self.assertIn("