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_audit manual both - Audit trail events for change request state transitions + Audit trail events for change request state transitions @@ -17,8 +15,7 @@ cr_conflict manual both - 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.request CR/%(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 Requestor global - 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( + "" + '' + '' + '' + "" + ) + 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"" + f'' + f"" + f"" + f"" + ) + + html.append("
    FileDocument TypeUploaded
    ' + f'' + f'{doc_name}{doc_type}{uploaded}
    ") + 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'
    ID: {reg.spp_id}
    ') + html_parts.append( + f'
    ' f'ID: {reg.spp_id}' f"
    " + ) # Address address_parts = [] @@ -345,7 +548,7 @@ def _compute_registrant_summary_html(self): html_parts.append( f'
    ' f'' - f"{', '.join(address_parts)}" + f'{", ".join(address_parts)}' f"
    " ) @@ -353,7 +556,10 @@ def _compute_registrant_summary_html(self): if reg.is_group and hasattr(reg, "group_membership_ids"): member_count = len(reg.group_membership_ids or []) html_parts.append( - f'
    {member_count} member(s)
    ' + f'
    ' + f'' + f"{member_count} member(s)" + f"
    " ) html_parts.append("") @@ -404,6 +610,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() @@ -445,7 +652,7 @@ def _get_parent_directory(self): ) if not parent_dir: _logger.warning( - "Parent 'Change Request' directory not found. Please ensure data/dms_directories.xml is loaded." + "Parent 'Change Request' directory not found. " "Please ensure data/dms_directories.xml is loaded." ) return parent_dir @@ -524,7 +731,7 @@ def _ensure_detail(self): # Use sudo() for creation - users don't need create permission # Detail records are always created by the system automatically - detail = detail_model.sudo().create({cr_field: self.id}) # nosemgrep: odoo-sudo-without-context + detail = detail_model.sudo().create({cr_field: self.id}) self.detail_res_id = detail.id # Pre-fill detail from registrant if the detail model supports it @@ -537,6 +744,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 +791,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 +842,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 +872,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 @@ -648,11 +897,18 @@ def _generate_preview_html(self): self.ensure_one() if not self.request_type_id or not self.detail_res_id: - return '
    No changes to preview yet.
    ' + 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() + 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 ( @@ -720,27 +976,153 @@ def _generate_preview_html(self): else: display_value = str(value) - html_parts.append(f"{display_key}{display_value}") + html_parts.append(f"{display_key}" f"{display_value}") html_parts.append("") else: html_parts.append( - '

    No field changes detected.

    ' + '

    ' + '' + "No field changes detected." + "

    " ) 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( + "" + '' + '' + '' + "" + ) + 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'' + 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'' + f'' + f"" + ) + + html.append("
    CurrentProposed
    {display_key}
    {display_key}{display_value}
    ") + 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("" '' '' "") + 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'') + + html.append("
    Value
    {display_key}{display_value}
    ") + 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 system 10 @@ -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 @@ - +
    -
    +
    Loading...
    -
    +
    - +

    Select a change request to review

    @@ -31,42 +25,30 @@

    - + - +

    - +
    -
    -
    +
    -
    -
    +
    +
    - Tier of + Tier of
    @@ -77,39 +59,26 @@
    - + Current Data
    - +
    -
    - ID: +
    + ID:
    -
    - - - , +
    + + + ,
    -
    +
    - - members + + members
    @@ -119,7 +88,7 @@
    - + Proposed Changes
    @@ -132,25 +101,11 @@ - + - - - - - - - - - - - + + + @@ -163,19 +118,15 @@
    - - Documents () + + Documents ()
    -
    Notes
    -

    - -

    +
    Notes
    +

    @@ -187,19 +138,13 @@
    - + -
    @@ -208,23 +153,14 @@
    - - -
    @@ -233,69 +169,43 @@
    - - -
    -
    +
    - -
    - - -
    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..fa671c6b --- /dev/null +++ b/spp_change_request_v2/static/src/js/cr_review_documents.js @@ -0,0 +1,76 @@ +/* @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); + 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, + downloadUrl: 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/create_change_request.js b/spp_change_request_v2/static/src/js/create_change_request.js index 1eecd24f..f0b20a05 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 @@ -16,10 +16,7 @@ async function openCRCloseModal(env, action) { const actionService = env.services.action; const params = action.params || {}; - await actionService.doAction( - {type: "ir.actions.act_window_close"}, - {clearBreadcrumbs: true} - ); + await actionService.doAction({type: "ir.actions.act_window_close"}, {clearBreadcrumbs: true}); if (params.res_id) { await actionService.doAction({ @@ -37,6 +34,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, action) { + 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,32 +83,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" - ); - if (is_admin || is_cr_user) { + const is_cr_manager = await user.hasGroup("spp_change_request_v2.group_cr_manager"); + 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. */ async onCustomListCreate() { if (this.model.root.resModel === "spp.change.request") { - await this.actionService.doAction( - "spp_change_request_v2.action_cr_create_wizard", - { - onClose: async () => { - await this.model.root.load(); - }, - } - ); + await this.actionService.doAction("spp_change_request_v2.action_cr_create_wizard", { + onClose: async () => { + await this.model.root.load(); + }, + }); return; } return super.onCustomListCreate(...arguments); diff --git a/spp_change_request_v2/static/src/js/search_delay_field.js b/spp_change_request_v2/static/src/js/search_delay_field.js index dca9714e..c43a2108 100644 --- a/spp_change_request_v2/static/src/js/search_delay_field.js +++ b/spp_change_request_v2/static/src/js/search_delay_field.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import {Component, useEffect, useRef} from "@odoo/owl"; +import {Component, useEffect, useRef, onWillUnmount} from "@odoo/owl"; import {_t} from "@web/core/l10n/translation"; import {useDebounced} from "@web/core/utils/timing"; import {registry} from "@web/core/registry"; 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/static/src/xml/cr_search_results_field.xml b/spp_change_request_v2/static/src/xml/cr_search_results_field.xml index b4ea3f52..d22a127d 100644 --- a/spp_change_request_v2/static/src/xml/cr_search_results_field.xml +++ b/spp_change_request_v2/static/src/xml/cr_search_results_field.xml @@ -2,12 +2,7 @@ -
    +
    diff --git a/spp_change_request_v2/static/src/xml/search_delay_field.xml b/spp_change_request_v2/static/src/xml/search_delay_field.xml index 5e5ca56f..fd21961f 100644 --- a/spp_change_request_v2/static/src/xml/search_delay_field.xml +++ b/spp_change_request_v2/static/src/xml/search_delay_field.xml @@ -2,15 +2,13 @@ - + diff --git a/spp_change_request_v2/strategies/field_mapping.py b/spp_change_request_v2/strategies/field_mapping.py index c182944f..1ffdaca6 100644 --- a/spp_change_request_v2/strategies/field_mapping.py +++ b/spp_change_request_v2/strategies/field_mapping.py @@ -74,9 +74,9 @@ def apply(self, change_request): def _eval_expression(self, expr, value, detail, registrant): """Safely evaluate transform expression.""" try: - 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. + # nosemgrep: odoo-unsafe-safe-eval + # Admin-defined field mapping expressions with restricted context (no env) + return safe_eval( expr, { "value": value, @@ -155,25 +155,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/strategies/update_id.py b/spp_change_request_v2/strategies/update_id.py index 09927bc0..937147fa 100644 --- a/spp_change_request_v2/strategies/update_id.py +++ b/spp_change_request_v2/strategies/update_id.py @@ -51,7 +51,8 @@ def _apply_add(self, registrant, detail, change_request): ) if existing: raise UserError( - _("Registrant already has an ID of type '%s'. Use 'Update' operation instead.") % detail.id_type_id.name + _("Registrant already has an ID of type '%s'. " "Use 'Update' operation instead.") + % detail.id_type_id.name ) # Create new ID record diff --git a/spp_change_request_v2/tests/common.py b/spp_change_request_v2/tests/common.py index 1bdb6e7b..c6ec1882 100644 --- a/spp_change_request_v2/tests/common.py +++ b/spp_change_request_v2/tests/common.py @@ -106,7 +106,9 @@ def get_or_create_membership_kind(env, code): """Get a membership type vocabulary code, creating it if not found.""" kind = env["spp.vocabulary.code"].get_code(MEMBERSHIP_TYPE_NS, code) if not kind: - vocab = env["spp.vocabulary"].search([("namespace_uri", "=", MEMBERSHIP_TYPE_NS)], limit=1) + vocab = env["spp.vocabulary"].search( + [("namespace_uri", "=", MEMBERSHIP_TYPE_NS)], limit=1 + ) defs = _MEMBERSHIP_TYPE_CODES[code] kind = env["spp.vocabulary.code"].create( { 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_conflict_detection_extended.py b/spp_change_request_v2/tests/test_conflict_detection_extended.py index 0cd42572..0d286aa3 100644 --- a/spp_change_request_v2/tests/test_conflict_detection_extended.py +++ b/spp_change_request_v2/tests/test_conflict_detection_extended.py @@ -199,7 +199,7 @@ def test_multiple_rules_block_takes_precedence(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type_1.id, "registrant_id": registrant.id, @@ -439,7 +439,7 @@ def test_field_conflict_no_detail(self): ) # Create CRs without details - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -496,7 +496,7 @@ def setUpClass(cls): def test_group_scope_same_household_members(self): """Test group scope detects conflicts for household members.""" # Create household with members - self.env["res.partner"].create( + household = self.env["res.partner"].create( { "name": "Test Household", "is_registrant": True, @@ -512,7 +512,7 @@ def test_group_scope_same_household_members(self): } ) - self.env["res.partner"].create( + individual2 = self.env["res.partner"].create( { "name": "Member 2", "is_registrant": True, @@ -878,7 +878,7 @@ def test_write_triggers_recheck(self): ) # Create CR for registrant1 - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant1.id, @@ -1000,7 +1000,7 @@ def test_wizard_can_override_permission(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1046,7 +1046,7 @@ def test_wizard_override_action(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1086,7 +1086,7 @@ def test_wizard_override_requires_reason(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1145,7 +1145,7 @@ def test_wizard_cancel_conflicting(self): } ) - wizard.action_resolve() + result = wizard.action_resolve() # cr1 should be deleted (was draft) self.assertFalse(cr1.exists()) @@ -1159,7 +1159,7 @@ def test_wizard_cancel_this(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1194,7 +1194,7 @@ def test_wizard_wait_action(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1229,7 +1229,7 @@ def test_wizard_comparison_html(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1336,7 +1336,7 @@ def test_action_view_duplicates(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1363,7 +1363,7 @@ def test_action_open_conflict_wizard(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1412,7 +1412,7 @@ def test_get_conflict_summary(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1693,7 +1693,7 @@ def test_conflict_detection_disabled(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, @@ -1721,7 +1721,7 @@ def test_duplicate_detection_disabled(self): } ) - self.env["spp.change.request"].create( + cr1 = self.env["spp.change.request"].create( { "request_type_id": self.cr_type.id, "registrant_id": registrant.id, diff --git a/spp_change_request_v2/tests/test_e2e_workflows.py b/spp_change_request_v2/tests/test_e2e_workflows.py index 72309579..62553ac7 100644 --- a/spp_change_request_v2/tests/test_e2e_workflows.py +++ b/spp_change_request_v2/tests/test_e2e_workflows.py @@ -135,7 +135,7 @@ def test_scenario_new_household_registration(self): self.assertEqual(spouse.name, "DELA CRUZ, MARIA") # Step 3: Add children - for _i, name in enumerate(["Pedro", "Ana"]): + for i, name in enumerate(["Pedro", "Ana"]): cr = self.cr_model.create( { "request_type_id": self.add_member_type.id, @@ -232,7 +232,7 @@ def test_scenario_marriage_household_split(self): } ) - self.membership_model.create( + mem_parent1 = self.membership_model.create( { "group": original.id, "individual": parent1.id, diff --git a/spp_change_request_v2/tests/test_split_household_strategy.py b/spp_change_request_v2/tests/test_split_household_strategy.py index 26dafaca..af889f2f 100644 --- a/spp_change_request_v2/tests/test_split_household_strategy.py +++ b/spp_change_request_v2/tests/test_split_household_strategy.py @@ -351,7 +351,7 @@ def test_split_all_reasons(self): "is_group": False, } ) - self.membership_model.create( + mem1 = self.membership_model.create( { "group": group.id, "individual": m1.id, diff --git a/spp_change_request_v2/tests/test_transfer_member_strategy.py b/spp_change_request_v2/tests/test_transfer_member_strategy.py index 22dc078a..e12cbb29 100644 --- a/spp_change_request_v2/tests/test_transfer_member_strategy.py +++ b/spp_change_request_v2/tests/test_transfer_member_strategy.py @@ -152,7 +152,6 @@ def test_transfer_member_with_role(self): def test_transfer_to_same_group_fails(self): """Test cannot transfer to same group.""" - from odoo.exceptions import ValidationError cr = self.cr_model.create( { @@ -163,7 +162,7 @@ def test_transfer_to_same_group_fails(self): detail = cr.get_detail() - with self.assertRaises(ValidationError): + with self.assertRaises(Exception): # ValidationError detail.write( { "membership_id": self.membership.id, diff --git a/spp_change_request_v2/tests/test_ux_wizards.py b/spp_change_request_v2/tests/test_ux_wizards.py index 51e2c263..ea8cca2c 100644 --- a/spp_change_request_v2/tests/test_ux_wizards.py +++ b/spp_change_request_v2/tests/test_ux_wizards.py @@ -88,7 +88,7 @@ def test_wizard_create_draft_success(self): result = wizard.action_create_draft() - # Should return client action that closes modal then opens form + # Wizard returns a client action that closes the modal and opens the form self.assertEqual(result["type"], "ir.actions.client") self.assertEqual(result["tag"], "open_cr_close_modal") params = result["params"] @@ -102,19 +102,19 @@ def test_wizard_create_draft_success(self): detail = self.env[cr_type.detail_model].browse(params["res_id"]) self.assertTrue(detail.exists()) # Verify the CR was created and linked - cr = detail.change_request_id - self.assertTrue(cr.exists()) - self.assertEqual(cr.request_type_id, cr_type) - self.assertEqual(cr.registrant_id, self.group) - self.assertEqual(cr.approval_state, "draft") + change_request = detail.change_request_id + self.assertTrue(change_request.exists()) + self.assertEqual(change_request.request_type_id, cr_type) + self.assertEqual(change_request.registrant_id, self.group) + self.assertEqual(change_request.approval_state, "draft") else: # Opened CR form (fallback) self.assertEqual(params["res_model"], "spp.change.request") - cr = self.env["spp.change.request"].browse(params["res_id"]) - self.assertTrue(cr.exists()) - self.assertEqual(cr.request_type_id, cr_type) - self.assertEqual(cr.registrant_id, self.group) - self.assertEqual(cr.approval_state, "draft") + change_request = self.env["spp.change.request"].browse(params["res_id"]) + self.assertTrue(change_request.exists()) + self.assertEqual(change_request.request_type_id, cr_type) + self.assertEqual(change_request.registrant_id, self.group) + self.assertEqual(change_request.approval_state, "draft") def test_wizard_registrant_domain_filter_group(self): """Test registrant domain is computed correctly for group target type.""" @@ -369,7 +369,7 @@ def test_wizard_quick_actions(self): wizard.action_approve_latest_decline_others() # Check decisions were set - latest_line = wizard.line_ids.sorted(key=lambda line: line.change_request_id.create_date, reverse=True)[0] + latest_line = wizard.line_ids.sorted(key=lambda rec: rec.change_request_id.create_date, reverse=True)[0] self.assertEqual(latest_line.decision, "approve") def test_wizard_line_decision(self): diff --git a/spp_change_request_v2/views/batch_approval_wizard_views.xml b/spp_change_request_v2/views/batch_approval_wizard_views.xml index fe32f355..a133c161 100644 --- a/spp_change_request_v2/views/batch_approval_wizard_views.xml +++ b/spp_change_request_v2/views/batch_approval_wizard_views.xml @@ -76,7 +76,7 @@ - + @@ -116,6 +115,12 @@ invisible="not can_process" title="Ready to process" /> +
    - +

    - +

    - - - - - + + + + + - - + + + - - - + + + - - + + - - + + - - - - + + + + - +

    Select which fields must be filled before the change request can be submitted for approval.

    - +
    - - + + - - - + + + - + - - - - - + + + + + - - + + - + + - - + + - - + +
    - + @@ -192,23 +150,15 @@ spp.change.request.type - - - - - - - - - + + + + + + + + + @@ -228,4 +178,5 @@

    + diff --git a/spp_change_request_v2/views/change_request_views.xml b/spp_change_request_v2/views/change_request_views.xml index 161d6eab..de49ea59 100644 --- a/spp_change_request_v2/views/change_request_views.xml +++ b/spp_change_request_v2/views/change_request_views.xml @@ -7,37 +7,24 @@ spp.change.request.list spp.change.request - - - - - - - - - - + + + + + + + + + + @@ -49,52 +36,37 @@ spp.change.request.kanban spp.change.request - - - - - - - - - + + + + + + + + +
    - +
    - +
    - - + +
    -
    +
    - + - +
    @@ -110,16 +82,11 @@ spp.change.request.calendar spp.change.request - - - - + + + + @@ -132,8 +99,8 @@ spp.change.request - - + + @@ -146,7 +113,7 @@ spp.change.request - + @@ -161,86 +128,69 @@
    - - + + -
    -
    @@ -420,30 +323,19 @@
    -
    +
    - Current Data + Current Data
    -
    - +
    @@ -452,16 +344,11 @@
    - Proposed Changes + Proposed Changes
    - +
    @@ -469,19 +356,12 @@
    -
    +
    @@ -489,16 +369,11 @@
    - Registrant + Registrant
    - +
    @@ -507,20 +382,13 @@
    - - Changes Applied - Proposed Changes + + Changes Applied + Proposed Changes
    - +
    @@ -528,69 +396,43 @@
    -
    @@ -230,20 +164,12 @@
    -
    +
    - +
    -
    Please Review Before Submitting
    +
    Please Review Before Submitting

    Another change request for this beneficiary may overlap with yours. Please review before submitting. @@ -253,152 +179,95 @@

    -
    +
    - +
    Approved to Continue

    A supervisor reviewed the conflict and approved this request to proceed.

    - Approved by - on + Approved by + on
    - + - - - - - + + + + + - + - - - + + + -
    +
    - - Technical Details + + Technical Details
    - - + + - - + + - + - +
    @@ -406,12 +275,12 @@ - + - + @@ -425,36 +294,30 @@ spp.change.request.list.conflict spp.change.request - + - + -
    -
    @@ -228,4 +145,5 @@ form new + diff --git a/spp_change_request_v2/views/create_wizard_views.xml b/spp_change_request_v2/views/create_wizard_views.xml index e5796896..e56c8a2d 100644 --- a/spp_change_request_v2/views/create_wizard_views.xml +++ b/spp_change_request_v2/views/create_wizard_views.xml @@ -14,80 +14,68 @@
    - +
    - +
    - + + +
    +
    + + +
    +
    + - - - - + + + + - + -
    - +
    +
    -
    - -
    -
    @@ -100,7 +88,8 @@ New Change Request spp.cr.create.wizard form - + new + diff --git a/spp_change_request_v2/views/detail_add_member_views.xml b/spp_change_request_v2/views/detail_add_member_views.xml index 8b14ee78..90a45b9c 100644 --- a/spp_change_request_v2/views/detail_add_member_views.xml +++ b/spp_change_request_v2/views/detail_add_member_views.xml @@ -5,26 +5,30 @@ spp.cr.detail.add_member.form spp.cr.detail.add_member -
    +
    +
    @@ -34,7 +38,7 @@ - + diff --git a/spp_change_request_v2/views/detail_change_hoh_views.xml b/spp_change_request_v2/views/detail_change_hoh_views.xml index bba05b18..8832a26f 100644 --- a/spp_change_request_v2/views/detail_change_hoh_views.xml +++ b/spp_change_request_v2/views/detail_change_hoh_views.xml @@ -5,26 +5,30 @@ spp.cr.detail.change_hoh.form spp.cr.detail.change_hoh - +
    +
    diff --git a/spp_change_request_v2/views/detail_create_group_views.xml b/spp_change_request_v2/views/detail_create_group_views.xml index 54ecd0ff..d7aa8069 100644 --- a/spp_change_request_v2/views/detail_create_group_views.xml +++ b/spp_change_request_v2/views/detail_create_group_views.xml @@ -5,36 +5,37 @@ spp.cr.detail.create_group.form spp.cr.detail.create_group - +
    +
    - + @@ -78,15 +79,9 @@ - + - + diff --git a/spp_change_request_v2/views/detail_edit_group_views.xml b/spp_change_request_v2/views/detail_edit_group_views.xml index c06e4a1c..d0552bb0 100644 --- a/spp_change_request_v2/views/detail_edit_group_views.xml +++ b/spp_change_request_v2/views/detail_edit_group_views.xml @@ -5,26 +5,30 @@ spp.cr.detail.edit_group.form spp.cr.detail.edit_group - +
    +
    @@ -32,36 +36,36 @@

    Edit Group Information

    - - + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/spp_change_request_v2/views/detail_edit_individual_views.xml b/spp_change_request_v2/views/detail_edit_individual_views.xml index 3867d0f9..c5dfc807 100644 --- a/spp_change_request_v2/views/detail_edit_individual_views.xml +++ b/spp_change_request_v2/views/detail_edit_individual_views.xml @@ -5,26 +5,30 @@ spp.cr.detail.edit_individual.form spp.cr.detail.edit_individual -
    +
    +
    @@ -32,42 +36,42 @@

    Edit Individual Information

    - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spp_change_request_v2/views/detail_exit_registrant_views.xml b/spp_change_request_v2/views/detail_exit_registrant_views.xml index a7978307..8e84123c 100644 --- a/spp_change_request_v2/views/detail_exit_registrant_views.xml +++ b/spp_change_request_v2/views/detail_exit_registrant_views.xml @@ -5,26 +5,30 @@ spp.cr.detail.exit_registrant.form spp.cr.detail.exit_registrant -
    +
    +
    diff --git a/spp_change_request_v2/views/detail_merge_registrants_views.xml b/spp_change_request_v2/views/detail_merge_registrants_views.xml index 8e686916..2cfe12f1 100644 --- a/spp_change_request_v2/views/detail_merge_registrants_views.xml +++ b/spp_change_request_v2/views/detail_merge_registrants_views.xml @@ -5,26 +5,30 @@ spp.cr.detail.merge_registrants.form spp.cr.detail.merge_registrants - +
    +
    diff --git a/spp_change_request_v2/views/detail_remove_member_views.xml b/spp_change_request_v2/views/detail_remove_member_views.xml index 07d81b92..739df844 100644 --- a/spp_change_request_v2/views/detail_remove_member_views.xml +++ b/spp_change_request_v2/views/detail_remove_member_views.xml @@ -5,26 +5,30 @@ spp.cr.detail.remove_member.form spp.cr.detail.remove_member - +
    +
    diff --git a/spp_change_request_v2/views/detail_split_household_views.xml b/spp_change_request_v2/views/detail_split_household_views.xml index c789b87d..88a22833 100644 --- a/spp_change_request_v2/views/detail_split_household_views.xml +++ b/spp_change_request_v2/views/detail_split_household_views.xml @@ -5,38 +5,42 @@ spp.cr.detail.split_household.form spp.cr.detail.split_household - +
    +
    - -
    + + +
    + + No documents uploaded yet. +
    + + + + + + +