From 7cd387205ddcfceb0a001d5aff5519e91a2dfbde Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Fri, 24 Apr 2026 12:40:12 +0800 Subject: [PATCH 01/10] fix(spp_programs): rename 'Initial Amount' label to 'Amount' (#941 item 1) --- spp_programs/models/entitlement.py | 2 +- spp_programs/wizard/multi_entitlement_approval_wizard.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spp_programs/models/entitlement.py b/spp_programs/models/entitlement.py index b351cf2c..2e155fa8 100644 --- a/spp_programs/models/entitlement.py +++ b/spp_programs/models/entitlement.py @@ -84,7 +84,7 @@ def _generate_code(self): is_cash_entitlement = fields.Boolean("Cash Entitlement", default=False) currency_id = fields.Many2one("res.currency", readonly=True, related="journal_id.currency_id") - initial_amount = fields.Monetary(required=True, currency_field="currency_id") + initial_amount = fields.Monetary(string="Amount", required=True, currency_field="currency_id") transfer_fee = fields.Monetary(currency_field="currency_id", default=0.0) balance = fields.Monetary(compute="_compute_balance") # in company currency # TODO: implement transactions against this entitlement diff --git a/spp_programs/wizard/multi_entitlement_approval_wizard.py b/spp_programs/wizard/multi_entitlement_approval_wizard.py index 1ce12a9b..213f5aef 100644 --- a/spp_programs/wizard/multi_entitlement_approval_wizard.py +++ b/spp_programs/wizard/multi_entitlement_approval_wizard.py @@ -190,7 +190,7 @@ class MultiEntitlementApproval(models.TransientModel): ) code = fields.Char(related="entitlement_id.code") initial_amount = fields.Monetary( - string="Initial Amount", + string="Amount", currency_field="currency_id", related="entitlement_id.initial_amount", ) From 6496692c199c803ff4edfd39dfb5882a8b4ab4db Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Fri, 24 Apr 2026 13:28:22 +0800 Subject: [PATCH 02/10] feat(spp_programs): add per-program email notification toggle (#941 item 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Send email notifications toggle on spp.program (default OFF), exposed under the Notifications section of the Configuration tab. When the toggle is off, the approval workflow does not emit outgoing email for that program's records: - _notify_thread_by_email on spp.entitlement / spp.entitlement.inkind / spp.cycle short-circuits the mail-framework email dispatch, which catches tracking emails on approval_state transitions and the approval-result message_notify call. - _create_approval_activity on the same three models skips scheduling the approver mail.activity at submit time (the main email source — a large cycle would flood every approver's inbox). Chatter entries and in-app notifications still fire, preserving the audit trail. The toggle is disabled (readonly) whenever no ir.mail_server record is configured; an info line underneath points admins to the outgoing-mail server settings. Gating resolves through _should_send_email_notifications() which checks both the flag and mail-server presence. --- spp_programs/models/cycle.py | 21 ++++++++++ spp_programs/models/entitlement.py | 41 +++++++++++++++++++ spp_programs/models/programs.py | 36 ++++++++++++++++ .../views/program_config_cards_view.xml | 17 ++++++++ 4 files changed, 115 insertions(+) diff --git a/spp_programs/models/cycle.py b/spp_programs/models/cycle.py index 07c4a85b..dd30170f 100644 --- a/spp_programs/models/cycle.py +++ b/spp_programs/models/cycle.py @@ -609,6 +609,27 @@ def _get_approval_definition(self): self.ensure_one() return self.cycle_approval_definition_id + def _notify_thread_by_email(self, message, recipients_data, **kwargs): + """Suppress outgoing email when the parent program has email + notifications disabled. Chatter logging and in-app notifications are + unaffected — only the email dispatch is short-circuited.""" + self.ensure_one() + if self.program_id and not self.program_id._should_send_email_notifications(): + return + return super()._notify_thread_by_email(message, recipients_data, **kwargs) + + def _create_approval_activity(self, definition, review): + """Gate the approver-email path on the parent program's toggle. + + spp.approval.mixin schedules a mail.activity for each approver on + submit; the activity dispatch sends email through the assignee's + notification preferences. Skip the scheduling entirely when the + program has email notifications turned off.""" + self.ensure_one() + if self.program_id and not self.program_id._should_send_email_notifications(): + return + return super()._create_approval_activity(definition, review) + @api.onchange("start_date") def on_start_date_change(self): self.program_id.get_manager(constants.MANAGER_CYCLE).on_start_date_change(self) diff --git a/spp_programs/models/entitlement.py b/spp_programs/models/entitlement.py index 2e155fa8..3fadf458 100644 --- a/spp_programs/models/entitlement.py +++ b/spp_programs/models/entitlement.py @@ -390,6 +390,29 @@ def _get_approval_definition(self): self.ensure_one() return self.entitlement_approval_definition_id + def _notify_thread_by_email(self, message, recipients_data, **kwargs): + """Suppress outgoing email when the parent program has email + notifications disabled. Chatter logging and in-app notifications are + unaffected — only the email dispatch is short-circuited.""" + self.ensure_one() + program = self.cycle_id.program_id + if program and not program._should_send_email_notifications(): + return + return super()._notify_thread_by_email(message, recipients_data, **kwargs) + + def _create_approval_activity(self, definition, review): + """Gate the approver-email path on the parent program's toggle. + + spp.approval.mixin schedules a mail.activity for each approver on + submit; the activity dispatch sends email through the assignee's + notification preferences. Skip the scheduling entirely when the + program has email notifications turned off.""" + self.ensure_one() + program = self.cycle_id.program_id + if program and not program._should_send_email_notifications(): + return + return super()._create_approval_activity(definition, review) + def action_submit_for_approval(self): """Submit entitlement for approval using standardized workflow.""" for record in self: @@ -885,6 +908,24 @@ def _resolve_approval_definition(self): self.ensure_one() return self._get_approval_definition() + def _notify_thread_by_email(self, message, recipients_data, **kwargs): + """Suppress outgoing email when the parent program has email + notifications disabled. Chatter logging and in-app notifications are + unaffected — only the email dispatch is short-circuited.""" + self.ensure_one() + program = self.cycle_id.program_id + if program and not program._should_send_email_notifications(): + return + return super()._notify_thread_by_email(message, recipients_data, **kwargs) + + def _create_approval_activity(self, definition, review): + """Gate the approver-email path on the parent program's toggle.""" + self.ensure_one() + program = self.cycle_id.program_id + if program and not program._should_send_email_notifications(): + return + return super()._create_approval_activity(definition, review) + def action_submit_for_approval(self): """Submit in-kind entitlement for approval using standardized workflow.""" for record in self: diff --git a/spp_programs/models/programs.py b/spp_programs/models/programs.py index 1888ef05..c22cd4df 100644 --- a/spp_programs/models/programs.py +++ b/spp_programs/models/programs.py @@ -88,6 +88,23 @@ def _default_journal_id(self): reconciliation_managers = fields.Selection([]) + # Email notifications + send_email_notifications = fields.Boolean( + string="Send email notifications", + default=False, + tracking=True, + help=( + "When enabled, approval workflows on this program (entitlement and cycle " + "submit-for-approval, approval result, revision requests) will send email " + "notifications through Odoo's mail framework. Requires a configured outgoing " + "mail server." + ), + ) + has_outgoing_mail_server = fields.Boolean( + compute="_compute_has_outgoing_mail_server", + help="True when at least one ir.mail_server record is configured.", + ) + program_membership_ids = fields.One2many("spp.program.membership", "program_id", "Program Memberships") has_members = fields.Boolean( string="Have Beneficiaries", @@ -211,6 +228,25 @@ def _compute_has_compliance_criteria(self): for rec in self: rec.has_compliance_criteria = bool(rec.compliance_manager_ids) + def _compute_has_outgoing_mail_server(self): + # sudo is required: non-admin users can't read ir.mail_server but need to + # know whether email notifications are sendable. We only return a bool, + # no server details leak. + has_server = bool( + self.env["ir.mail_server"].sudo().search_count([], limit=1) # nosemgrep: odoo-sudo-without-context + ) + for rec in self: + rec.has_outgoing_mail_server = has_server + + def _should_send_email_notifications(self): + """Gate for approval-workflow email notifications. + + Returns True only when this program has the toggle enabled AND the + environment has at least one configured outgoing mail server. + """ + self.ensure_one() + return bool(self.send_email_notifications and self.has_outgoing_mail_server) + def _get_compliance_managers(self): """Get compliance managers with their implementations. diff --git a/spp_programs/views/program_config_cards_view.xml b/spp_programs/views/program_config_cards_view.xml index 08063dcb..c2a8f721 100644 --- a/spp_programs/views/program_config_cards_view.xml +++ b/spp_programs/views/program_config_cards_view.xml @@ -469,6 +469,23 @@ Replaces the technical manager configuration with intuitive sections.
Send SMS or other notifications to beneficiaries.
+ + +
+ + No outgoing mail server is configured. Ask your administrator to set one up under + Settings → Technical → Email → Outgoing Mail Servers before enabling email + notifications. +
Date: Fri, 24 Apr 2026 14:48:02 +0800 Subject: [PATCH 03/10] feat(spp_programs): rework program config banners (#941 item 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the 5 manager banners on the Program Configuration tab so the layout is clearer whether one or many managers are configured, and the details actually describe the configuration. Smart display names on concrete managers Each concrete manager falls back to a method-specific label when its name is still the "Default" auto-name: "CEL Eligibility Criteria", "Cash Entitlement", "In-kind Entitlement", "Basic Cash", "Default Cycle Schedule", "CEL Compliance Criteria", "Default Payment", "Default Program Manager". User renames are preserved. Dialog title manager_mixin.open_manager_form() accepts a title override and the per-banner action_configure_* methods pass "Who Qualifies?", "What Do They Receive?", "Program Schedule", "Compliance Criteria", "Payment Processing", etc. so the Edit modal shows the banner label instead of the default "Odoo" breadcrumb. Layout helpers on spp.program Per banner, three computed fields (_manager_count, _display, _detail). When a banner has exactly one manager, the header row shows Method + the manager's display name and a full- width detail block below renders method-specific content instead of the truncated summary: - CEL managers → the full CEL expression, or an explicit "no CEL defined, every will match" warning when empty. - Entitlement with items → one line per item with the CEL expression, multiplier, or flat amount (plus condition). - Basic Cash entitlement → amount per cycle, amount per person (with cap), transfer-fee breakdown. When count > 1 the list widget is shown as before. When count == 1 the list is hidden. Compliance zero-state Compliance is optional, so count==0 now shows an informational block body + an "Add" button in the banner header that creates the default compliance manager and opens its form in a popup. The generic list widget only shows when count >= 2. Recurrence label Cycle summary previously produced "Every 1 monthly" which confused QA. A new _format_recurrence helper turns it into "Monthly", "Every 2 months", "Weekly", etc. View labels "Details" renamed to banner-specific strings: "Eligibility Method" (eligibility) and "Entitlement Type" (entitlement). Schedule, Compliance, and Payment already had per-banner labels. --- spp_programs/models/cel/eligibility_cel.py | 12 + .../models/managers/compliance_manager.py | 11 + .../models/managers/cycle_manager_base.py | 9 + .../managers/entitlement_manager_base.py | 9 + .../managers/entitlement_manager_cash.py | 9 + .../managers/entitlement_manager_inkind.py | 9 + spp_programs/models/managers/manager_mixin.py | 7 +- .../models/managers/payment_manager.py | 9 + .../models/managers/program_manager.py | 9 + spp_programs/models/program_manager_ui.py | 233 +++++++++++++++++- .../views/program_config_cards_view.xml | 129 +++++++++- 11 files changed, 425 insertions(+), 21 deletions(-) diff --git a/spp_programs/models/cel/eligibility_cel.py b/spp_programs/models/cel/eligibility_cel.py index 58bdf49d..4fb2694d 100644 --- a/spp_programs/models/cel/eligibility_cel.py +++ b/spp_programs/models/cel/eligibility_cel.py @@ -56,6 +56,18 @@ class DefaultEligibilityManagerCEL(models.Model): help="True if the local expression differs from the source template", ) + @api.depends("name", "eligibility_mode") + def _compute_display_name(self): + """Fall back to a method-specific label when the manager still has + the 'Default' auto-name. Honours user renames.""" + for rec in self: + if rec.name and rec.name != "Default": + rec.display_name = rec.name + elif rec.eligibility_mode == "cel": + rec.display_name = _("CEL Eligibility Criteria") + else: + rec.display_name = rec.name or _("Eligibility Criteria") + # ------------------------------------------------------------------------- # CEL Profile (computed for widget context) # ------------------------------------------------------------------------- diff --git a/spp_programs/models/managers/compliance_manager.py b/spp_programs/models/managers/compliance_manager.py index 8ccde3f0..35992989 100644 --- a/spp_programs/models/managers/compliance_manager.py +++ b/spp_programs/models/managers/compliance_manager.py @@ -97,6 +97,17 @@ class DefaultComplianceManager(models.Model): _inherit = ["spp.compliance.manager.base", "spp.manager.source.mixin"] _description = "Default Compliance Manager" + @api.depends("name", "compliance_cel_mode") + def _compute_display_name(self): + """Smart default label when the manager still has the 'Default' auto-name.""" + for rec in self: + if rec.name and rec.name != "Default": + rec.display_name = rec.name + elif rec.compliance_cel_mode == "cel": + rec.display_name = _("CEL Compliance Criteria") + else: + rec.display_name = rec.name or _("Compliance Criteria") + # Mode selection compliance_cel_mode = fields.Selection( selection=[ diff --git a/spp_programs/models/managers/cycle_manager_base.py b/spp_programs/models/managers/cycle_manager_base.py index f9fb56e8..1c5da02f 100644 --- a/spp_programs/models/managers/cycle_manager_base.py +++ b/spp_programs/models/managers/cycle_manager_base.py @@ -373,6 +373,15 @@ class DefaultCycleManager(models.Model): ] _description = "Default Cycle Manager" + @api.depends("name") + def _compute_display_name(self): + """Smart default label when the manager still has the 'Default' auto-name.""" + for rec in self: + if rec.name and rec.name != "Default": + rec.display_name = rec.name + else: + rec.display_name = _("Default Cycle Schedule") + cycle_duration = fields.Integer(default=1, required=True, string="Recurrence") approver_group_id = fields.Many2one( comodel_name="res.groups", diff --git a/spp_programs/models/managers/entitlement_manager_base.py b/spp_programs/models/managers/entitlement_manager_base.py index 5ddb61b6..81ffe609 100644 --- a/spp_programs/models/managers/entitlement_manager_base.py +++ b/spp_programs/models/managers/entitlement_manager_base.py @@ -301,6 +301,15 @@ class DefaultCashEntitlementManager(models.Model): # Set to True so that the UI will display the payment management components IS_CASH_ENTITLEMENT = True + @api.depends("name") + def _compute_display_name(self): + """Smart default label when the manager still has the 'Default' auto-name.""" + for rec in self: + if rec.name and rec.name != "Default": + rec.display_name = rec.name + else: + rec.display_name = _("Basic Cash") + amount_per_cycle = fields.Monetary( currency_field="currency_id", aggregator="sum", diff --git a/spp_programs/models/managers/entitlement_manager_cash.py b/spp_programs/models/managers/entitlement_manager_cash.py index 7449263d..00ff70e9 100644 --- a/spp_programs/models/managers/entitlement_manager_cash.py +++ b/spp_programs/models/managers/entitlement_manager_cash.py @@ -39,6 +39,15 @@ class SppCashEntitlementManager(models.Model): # Set to True so that the UI will display the payment management components IS_CASH_ENTITLEMENT = True + @api.depends("name") + def _compute_display_name(self): + """Smart default label when the manager still has the 'Default' auto-name.""" + for rec in self: + if rec.name and rec.name != "Default": + rec.display_name = rec.name + else: + rec.display_name = _("Cash Entitlement") + # Cash Entitlement Manager is_evaluate_one_item = fields.Boolean(default=False) entitlement_item_ids = fields.One2many( diff --git a/spp_programs/models/managers/entitlement_manager_inkind.py b/spp_programs/models/managers/entitlement_manager_inkind.py index f3630ee2..9d09d640 100644 --- a/spp_programs/models/managers/entitlement_manager_inkind.py +++ b/spp_programs/models/managers/entitlement_manager_inkind.py @@ -32,6 +32,15 @@ class SPPInKindEntitlementManager(models.Model): # Set to False so that the UI will not display the payment management components IS_CASH_ENTITLEMENT = False + @api.depends("name") + def _compute_display_name(self): + """Smart default label when the manager still has the 'Default' auto-name.""" + for rec in self: + if rec.name and rec.name != "Default": + rec.display_name = rec.name + else: + rec.display_name = _("In-kind Entitlement") + @api.model def _default_warehouse_id(self): return self.env["stock.warehouse"].search([("company_id", "=", self.env.company.id)], limit=1) diff --git a/spp_programs/models/managers/manager_mixin.py b/spp_programs/models/managers/manager_mixin.py index 6b4257d7..4d6b872f 100644 --- a/spp_programs/models/managers/manager_mixin.py +++ b/spp_programs/models/managers/manager_mixin.py @@ -25,7 +25,7 @@ def _compute_display_name(self): def _selection_manager_ref_id(self): return [] - def open_manager_form(self, readonly=False): + def open_manager_form(self, readonly=False, title=None): self.ensure_one() if self.manager_ref_id: # Get the res_model and res_id from the manager_ref_id (reference field) @@ -52,6 +52,11 @@ def open_manager_form(self, readonly=False): "flags": {"mode": "readonly" if readonly else "edit"}, } ) + # Override the dialog title so the banner label (e.g. "Who + # Qualifies?") shows in the modal header instead of the model's + # _description ("Odoo" when unset). + if title: + action["name"] = title return action return { diff --git a/spp_programs/models/managers/payment_manager.py b/spp_programs/models/managers/payment_manager.py index 37ce2d17..a3a863a9 100644 --- a/spp_programs/models/managers/payment_manager.py +++ b/spp_programs/models/managers/payment_manager.py @@ -84,6 +84,15 @@ class DefaultFilePaymentManager(models.Model): MAX_PAYMENTS_FOR_SYNC_PREPARE = 200 MAX_BATCHES_FOR_SYNC_SEND = 50 + @api.depends("name") + def _compute_display_name(self): + """Smart default label when the manager still has the 'Default' auto-name.""" + for rec in self: + if rec.name and rec.name != "Default": + rec.display_name = rec.name + else: + rec.display_name = _("Default Payment") + currency_id = fields.Many2one("res.currency", related="program_id.journal_id.currency_id", readonly=True) create_batch = fields.Boolean("Automatically Create Batch", default=True) diff --git a/spp_programs/models/managers/program_manager.py b/spp_programs/models/managers/program_manager.py index 7a799664..28bb2420 100644 --- a/spp_programs/models/managers/program_manager.py +++ b/spp_programs/models/managers/program_manager.py @@ -87,6 +87,15 @@ class DefaultProgramManager(models.Model): _inherit = ["spp.base.program.manager", "spp.manager.source.mixin"] _description = "Default Program Manager" + @api.depends("name") + def _compute_display_name(self): + """Smart default label when the manager still has the 'Default' auto-name.""" + for rec in self: + if rec.name and rec.name != "Default": + rec.display_name = rec.name + else: + rec.display_name = _("Default Program Manager") + number_of_cycles = fields.Integer(default=1) copy_last_cycle_on_new_cycle = fields.Boolean(string="Copy previous cycle", default=True) diff --git a/spp_programs/models/program_manager_ui.py b/spp_programs/models/program_manager_ui.py index 9d629f23..2df76e07 100644 --- a/spp_programs/models/program_manager_ui.py +++ b/spp_programs/models/program_manager_ui.py @@ -8,6 +8,33 @@ from odoo import _, api, fields, models + +def _format_recurrence(duration, rrule_type): + """Human-readable recurrence label. + + Turns rrule_type values ('daily', 'weekly', 'monthly', 'yearly') + an + interval into natural English ("Monthly" vs "Every 2 months" etc.), + replacing the literal "Every 1 monthly" that confused QA. + """ + if not rrule_type or not duration: + return "" + singular = { + "daily": _("Daily"), + "weekly": _("Weekly"), + "monthly": _("Monthly"), + "yearly": _("Yearly"), + } + plural = { + "daily": _("days"), + "weekly": _("weeks"), + "monthly": _("months"), + "yearly": _("years"), + } + if duration == 1: + return singular.get(rrule_type, rrule_type.capitalize()) + return _("Every %(n)s %(unit)s") % {"n": duration, "unit": plural.get(rrule_type, rrule_type)} + + # Manager type metadata for user-friendly display # Note: Using plain strings here instead of _() because this is evaluated at module import time. # Translation happens at runtime when these strings are displayed in the UI. @@ -189,6 +216,31 @@ class ProgramManagerUI(models.Model): compute="_compute_compliance_summary", ) + # --- Single-vs-multi manager layout helpers (one per banner). + # When a banner has exactly one manager configured, the UI collapses the + # Method / Details header row into a single-manager view and renders the + # full configuration detail in a full-width block below, instead of a + # list widget. These computes drive that layout. + eligibility_manager_count = fields.Integer(compute="_compute_banner_layout_helpers") + eligibility_manager_display = fields.Char(compute="_compute_banner_layout_helpers") + eligibility_manager_detail = fields.Text(compute="_compute_banner_layout_helpers") + + entitlement_manager_count = fields.Integer(compute="_compute_banner_layout_helpers") + entitlement_manager_display = fields.Char(compute="_compute_banner_layout_helpers") + entitlement_manager_detail = fields.Text(compute="_compute_banner_layout_helpers") + + cycle_manager_count = fields.Integer(compute="_compute_banner_layout_helpers") + cycle_manager_display = fields.Char(compute="_compute_banner_layout_helpers") + cycle_manager_detail = fields.Text(compute="_compute_banner_layout_helpers") + + compliance_manager_count = fields.Integer(compute="_compute_banner_layout_helpers") + compliance_manager_display = fields.Char(compute="_compute_banner_layout_helpers") + compliance_manager_detail = fields.Text(compute="_compute_banner_layout_helpers") + + payment_manager_count = fields.Integer(compute="_compute_banner_layout_helpers") + payment_manager_display = fields.Char(compute="_compute_banner_layout_helpers") + payment_manager_detail = fields.Text(compute="_compute_banner_layout_helpers") + @api.depends("eligibility_manager_ids", "eligibility_manager_ids.manager_ref_id") def _compute_eligibility_summary(self): for rec in self: @@ -262,7 +314,7 @@ def _compute_cycle_summary(self): duration = manager.cycle_duration rrule = manager.rrule_type if rrule and duration: - summary_parts.append(f"Every {duration} {rrule}") + summary_parts.append(_format_recurrence(duration, rrule)) if hasattr(manager, "auto_approve_entitlements") and manager.auto_approve_entitlements: summary_parts.append("Auto-approve enabled") rec.cycle_manager_summary = " • ".join(summary_parts) if summary_parts else "Configured" @@ -340,6 +392,146 @@ def _compute_compliance_summary(self): rec.compliance_manager_summary = False rec.compliance_configured = False + @api.depends( + "eligibility_manager_ids", + "eligibility_manager_ids.manager_ref_id", + "entitlement_manager_ids", + "entitlement_manager_ids.manager_ref_id", + "cycle_manager_ids", + "cycle_manager_ids.manager_ref_id", + "compliance_manager_ids", + "compliance_manager_ids.manager_ref_id", + "payment_manager_ids", + "payment_manager_ids.manager_ref_id", + ) + def _compute_banner_layout_helpers(self): + """Populate the `_manager_count / _display / _detail` fields + that drive the single-vs-multi layout on each manager banner.""" + banners = ( + ("eligibility_manager_ids", "eligibility"), + ("entitlement_manager_ids", "entitlement"), + ("cycle_manager_ids", "cycle"), + ("compliance_manager_ids", "compliance"), + ("payment_manager_ids", "payment"), + ) + for rec in self: + for field_name, prefix in banners: + wrappers = rec[field_name].filtered(lambda w: w.manager_ref_id) + count = len(wrappers) + rec[f"{prefix}_manager_count"] = count + display = "" + detail = "" + if count == 1: + concrete = wrappers[0].manager_ref_id + display = concrete.display_name or concrete.name or "" + detail = rec._manager_detail_for(prefix, concrete) + elif count > 1: + display = _("%d methods configured") % count + rec[f"{prefix}_manager_display"] = display + rec[f"{prefix}_manager_detail"] = detail + + def _manager_detail_for(self, prefix, concrete): + """Method-specific detail rendering for a single manager. + + Dispatches by field signature: CEL expression → show it, entitlement + items → expand rules, flat-amount entitlement → spell out amounts, + otherwise fall back to the existing short summary. + """ + self.ensure_one() + # CEL managers (eligibility / compliance) + cel_detail = self._manager_detail_cel(concrete) + if cel_detail is not None: + return cel_detail + # Entitlement manager with items + if hasattr(concrete, "entitlement_item_ids") and concrete.entitlement_item_ids: + return self._manager_detail_entitlement_items(concrete) + # "Basic Cash" entitlement — flat amount + per-person multiplier + if hasattr(concrete, "amount_per_cycle") or hasattr(concrete, "amount_per_individual_in_group"): + return self._manager_detail_basic_cash(concrete) + # Fallback to existing summary + summary = self[f"{prefix}_manager_summary"] if f"{prefix}_manager_summary" in self._fields else "" + if summary and summary != "Configured": + return summary + return "" + + def _manager_detail_cel(self, concrete): + """Return CEL-based detail or None if not a CEL manager.""" + if hasattr(concrete, "cel_expression"): + cel = getattr(concrete, "cel_expression", "") or "" + elif hasattr(concrete, "compliance_cel_expression"): + cel = getattr(concrete, "compliance_cel_expression", "") or "" + else: + return None + if cel: + return cel + # Empty CEL — warn that every target-type registrant will match. + target = (self.target_type or "").strip().lower() + target_label = { + "group": _("groups / households"), + "individual": _("individuals"), + }.get(target, _("registrants")) + return ( + _( + "No CEL expression defined. With an empty expression, every %s registrant of this " + "program will match — click Edit above to narrow the criteria." + ) + % target_label + ) + + def _manager_detail_entitlement_items(self, concrete): + """Render each entitlement_item line readably.""" + lines = [] + for item in concrete.entitlement_item_ids: + amount_expr = getattr(item, "amount_cel_expression", "") or "" + amount = getattr(item, "amount", 0) or 0 + multiplier_field = getattr(item, "multiplier_field", False) + condition = getattr(item, "condition", "") or "" + if amount_expr: + line = _("Amount per beneficiary: %s") % amount_expr + elif multiplier_field: + mult_label = multiplier_field.field_description or multiplier_field.name + line = _("Amount per beneficiary: %(amount)s × %(mult)s") % { + "amount": amount, + "mult": mult_label, + } + else: + line = _("Amount per beneficiary: %s per cycle") % amount + if condition and condition.strip() not in ("[]", ""): + line += _(" — only if %s") % condition + lines.append(line) + if len(concrete.entitlement_item_ids) > 1: + lines.insert(0, _("%d entitlement rule(s):") % len(concrete.entitlement_item_ids)) + return "\n".join(lines) + + def _manager_detail_basic_cash(self, concrete): + """Render the Basic Cash entitlement fields readably.""" + per_cycle = getattr(concrete, "amount_per_cycle", 0) or 0 + per_person = getattr(concrete, "amount_per_individual_in_group", 0) or 0 + max_people = getattr(concrete, "max_individual_in_group", 0) or 0 + fee_pct = getattr(concrete, "transfer_fee_pct", 0) or 0 + fee_amount = getattr(concrete, "transfer_fee_amount", 0) or 0 + currency_sym = "" + if getattr(concrete, "currency_id", False): + currency_sym = concrete.currency_id.symbol or concrete.currency_id.name or "" + lines = [] + if per_cycle: + lines.append(_("Amount per cycle: %(sym)s %(amt)s") % {"sym": currency_sym, "amt": per_cycle}) + if per_person: + line = _("Amount per person in group: %(sym)s %(amt)s") % { + "sym": currency_sym, + "amt": per_person, + } + if max_people: + line += _(" (up to %s people)") % max_people + lines.append(line) + if fee_pct: + lines.append(_("Transfer fee: %s%% of amount") % fee_pct) + elif fee_amount: + lines.append(_("Transfer fee: %(sym)s %(amt)s flat") % {"sym": currency_sym, "amt": fee_amount}) + if not lines: + return _("No amount configured yet — click Edit above to set how much each beneficiary receives per cycle.") + return "\n".join(lines) + # ==================== Action Methods ==================== def action_configure_eligibility(self): @@ -347,7 +539,7 @@ def action_configure_eligibility(self): self.ensure_one() readonly = not self.can_edit_configuration if self.eligibility_manager_ids and self.eligibility_manager_ids[0].manager_ref_id: - return self.eligibility_manager_ids[0].open_manager_form(readonly=readonly) + return self.eligibility_manager_ids[0].open_manager_form(readonly=readonly, title=_("Who Qualifies?")) # No manager yet - open wizard to create one (only if can edit) if not readonly: return self._open_manager_setup_wizard("eligibility") @@ -358,7 +550,9 @@ def action_configure_entitlement(self): self.ensure_one() readonly = not self.can_edit_configuration if self.entitlement_manager_ids and self.entitlement_manager_ids[0].manager_ref_id: - return self.entitlement_manager_ids[0].open_manager_form(readonly=readonly) + return self.entitlement_manager_ids[0].open_manager_form( + readonly=readonly, title=_("What Do They Receive?") + ) if not readonly: return self._open_manager_setup_wizard("entitlement") return False @@ -368,7 +562,7 @@ def action_configure_cycle(self): self.ensure_one() readonly = not self.can_edit_configuration if self.cycle_manager_ids and self.cycle_manager_ids[0].manager_ref_id: - return self.cycle_manager_ids[0].open_manager_form(readonly=readonly) + return self.cycle_manager_ids[0].open_manager_form(readonly=readonly, title=_("Program Schedule")) if not readonly: return self._open_manager_setup_wizard("cycle") return False @@ -378,7 +572,7 @@ def action_configure_payment(self): self.ensure_one() readonly = not self.can_edit_configuration if self.payment_manager_ids and self.payment_manager_ids[0].manager_ref_id: - return self.payment_manager_ids[0].open_manager_form(readonly=readonly) + return self.payment_manager_ids[0].open_manager_form(readonly=readonly, title=_("Payment Processing")) if not readonly: return self._open_manager_setup_wizard("payment") return False @@ -388,7 +582,7 @@ def action_configure_deduplication(self): self.ensure_one() readonly = not self.can_edit_configuration if self.deduplication_manager_ids and self.deduplication_manager_ids[0].manager_ref_id: - return self.deduplication_manager_ids[0].open_manager_form(readonly=readonly) + return self.deduplication_manager_ids[0].open_manager_form(readonly=readonly, title=_("Deduplication")) if not readonly: return self._open_manager_setup_wizard("deduplication") return False @@ -398,7 +592,7 @@ def action_configure_notification(self): self.ensure_one() readonly = not self.can_edit_configuration if self.notification_manager_ids and self.notification_manager_ids[0].manager_ref_id: - return self.notification_manager_ids[0].open_manager_form(readonly=readonly) + return self.notification_manager_ids[0].open_manager_form(readonly=readonly, title=_("Notifications")) if not readonly: return self._open_manager_setup_wizard("notification") return False @@ -408,11 +602,34 @@ def action_configure_compliance(self): self.ensure_one() readonly = not self.can_edit_configuration if self.compliance_manager_ids and self.compliance_manager_ids[0].manager_ref_id: - return self.compliance_manager_ids[0].open_manager_form(readonly=readonly) + return self.compliance_manager_ids[0].open_manager_form(readonly=readonly, title=_("Compliance Criteria")) if not readonly: return self._open_manager_setup_wizard("compliance") return False + def action_add_compliance_manager(self): + """Create a compliance manager and open it in a popup. + + Compliance is optional, so the zero-state on the program form shows + an "Add" button instead of the generic manager list. This action + creates the wrapper + concrete default record and opens its form so + the user can immediately edit the CEL expression. + """ + self.ensure_one() + if not self.can_edit_configuration: + return False + if self.compliance_manager_ids: + # Already exists — just open the first one. + return self.action_configure_compliance() + concrete = self.env["spp.compliance.manager.default"].create({"name": "Default", "program_id": self.id}) + wrapper = self.env["spp.compliance.manager"].create( + { + "program_id": self.id, + "manager_ref_id": f"spp.compliance.manager.default,{concrete.id}", + } + ) + return wrapper.open_manager_form(title=_("Compliance Criteria")) + def _open_manager_setup_wizard(self, manager_type): """Open wizard to set up a new manager of the specified type.""" return { diff --git a/spp_programs/views/program_config_cards_view.xml b/spp_programs/views/program_config_cards_view.xml index c2a8f721..b1a65aa2 100644 --- a/spp_programs/views/program_config_cards_view.xml +++ b/spp_programs/views/program_config_cards_view.xml @@ -64,6 +64,7 @@ Replaces the technical manager configuration with intuitive sections.
+ - + + + + + +
+ - + + + + + +
+ - + + + + + + Configured + +
+ + +
+ + No compliance criteria configured. Compliance is optional — click + Add above to define ongoing conditions beneficiaries must meet. +
- + + + + + +
+ - + + + + + + Date: Fri, 24 Apr 2026 15:20:18 +0800 Subject: [PATCH 04/10] feat(spp_programs): capture exit reason on program exit (#941 item 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an exit_reason Char field on spp.program.membership and a small wizard that captures the reason at the moment the Exit button is clicked. action_exit now opens the wizard instead of writing the exited state immediately; the wizard owns the atomic write of state, exit_date, and exit_reason so every exit event persists a reason for audit. Surface the new field on: - Program Membership list (Programs > Program Memberships) - Program Membership form (main group next to exit_date) - Other Programs nested list on the membership form - Participation > Program Enrollments list on the registrant form (both individual and group variants) — exit_date also added here as an optional column ACL granted to officer (create/read/write), manager (full), and admin. Existing three lifecycle tests updated to drive the wizard end-to-end. Cycle-level exit fields don't exist on spp.cycle.membership so no cycle view changes; flagging for a follow-up if that capability is needed. --- spp_programs/__manifest__.py | 1 + spp_programs/models/program_membership.py | 27 ++++++----- spp_programs/security/ir.model.access.csv | 3 ++ spp_programs/tests/test_program_membership.py | 24 +++++++--- .../views/program_membership_view.xml | 3 ++ spp_programs/views/registrant_view.xml | 4 ++ spp_programs/wizard/__init__.py | 1 + spp_programs/wizard/exit_membership_wizard.py | 45 +++++++++++++++++++ .../wizard/exit_membership_wizard.xml | 32 +++++++++++++ 9 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 spp_programs/wizard/exit_membership_wizard.py create mode 100644 spp_programs/wizard/exit_membership_wizard.xml diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index 0f61ce1c..865b5911 100644 --- a/spp_programs/__manifest__.py +++ b/spp_programs/__manifest__.py @@ -109,6 +109,7 @@ "wizard/create_program_wizard_compliance_views.xml", "wizard/create_program_wizard_cel_views.xml", "wizard/enrollment_wizard_views.xml", + "wizard/exit_membership_wizard.xml", "wizard/prepare_entitlement_confirm_wizard.xml", ], "assets": { diff --git a/spp_programs/models/program_membership.py b/spp_programs/models/program_membership.py index 7e93543f..637fdefc 100644 --- a/spp_programs/models/program_membership.py +++ b/spp_programs/models/program_membership.py @@ -67,6 +67,10 @@ def init(self): last_deduplication = fields.Date("Last Deduplication Date") exit_date = fields.Date() + exit_reason = fields.Char( + string="Exit Reason", + help="Free-text reason recorded when a beneficiary exits a program (e.g. 'Graduated', 'Opted out', 'Moved').", + ) registrant_id = fields.Integer(string="Registrant ID", related="partner_id.id") @@ -137,9 +141,6 @@ def action_view_cycle_memberships(self): ], } - # TODO: Implement exit reasons - # exit_reason_id = fields.Many2one("Exit Reason") Default: Completed, Opt-Out, Other - # TODO: Implement not eligible reasons # Default: "Missing data", "Does not match the criterias", "Duplicate", "Other" # not_eligible_reason_id = fields.Many2one("Not Eligible Reason") @@ -387,16 +388,22 @@ def action_resume(self): self.write({"state": "enrolled"}) def action_exit(self): - """Exit the registrant from the program.""" + """Open a wizard prompting for an exit reason. + + The actual state / exit_date / exit_reason write happens inside + the wizard so every exit event captures a reason for audit. + """ self.ensure_one() if self.state not in ("enrolled", "paused"): raise UserError(_("Only enrolled or paused memberships can be exited.")) - self.write( - { - "state": "exited", - "exit_date": fields.Date.today(), - } - ) + return { + "type": "ir.actions.act_window", + "name": _("Exit Program"), + "res_model": "spp.program.membership.exit.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_membership_id": self.id}, + } @api.model def bulk_create_memberships(self, vals_list, chunk_size=1000, skip_duplicates=False): diff --git a/spp_programs/security/ir.model.access.csv b/spp_programs/security/ir.model.access.csv index c4f38ed0..c67e2beb 100644 --- a/spp_programs/security/ir.model.access.csv +++ b/spp_programs/security/ir.model.access.csv @@ -398,3 +398,6 @@ access_spp_program_enrollment_wizard_officer,Program Enrollment Wizard Officer,m access_spp_program_enrollment_wizard_manager,Program Enrollment Wizard Manager,model_spp_program_enrollment_wizard,group_programs_manager,1,1,1,0 access_spp_prepare_entitlement_confirm_wizard_officer,Prepare Entitlement Confirm Wizard Officer Access,spp_programs.model_spp_prepare_entitlement_confirm_wizard,spp_programs.group_programs_officer,1,1,1,1 access_spp_prepare_entitlement_confirm_wizard_validator,Prepare Entitlement Confirm Wizard Validator Access,spp_programs.model_spp_prepare_entitlement_confirm_wizard,spp_programs.group_programs_validator,1,1,1,1 +access_spp_program_membership_exit_wizard_officer,Program Membership Exit Wizard Officer Access,spp_programs.model_spp_program_membership_exit_wizard,spp_programs.group_programs_officer,1,1,1,0 +access_spp_program_membership_exit_wizard_manager,Program Membership Exit Wizard Manager Access,spp_programs.model_spp_program_membership_exit_wizard,spp_programs.group_programs_manager,1,1,1,1 +access_spp_program_membership_exit_wizard_admin,Program Membership Exit Wizard Admin Access,spp_programs.model_spp_program_membership_exit_wizard,spp_security.group_spp_admin,1,1,1,1 diff --git a/spp_programs/tests/test_program_membership.py b/spp_programs/tests/test_program_membership.py index 5d65a19d..421bf6f4 100644 --- a/spp_programs/tests/test_program_membership.py +++ b/spp_programs/tests/test_program_membership.py @@ -162,10 +162,20 @@ def test_10_action_resume_from_non_paused_raises(self): with self.assertRaisesRegex(UserError, "Only paused memberships can be resumed"): membership.action_resume() + def _confirm_exit(self, membership, reason="Tested exit"): + """Drive the exit-membership wizard end-to-end — action_exit now + opens a wizard that owns the state / exit_date / exit_reason write.""" + action = membership.action_exit() + self.assertEqual(action.get("res_model"), "spp.program.membership.exit.wizard") + wizard = ( + self.env[action["res_model"]].with_context(**(action.get("context") or {})).create({"exit_reason": reason}) + ) + wizard.action_confirm_exit() + def test_11_action_exit_from_enrolled(self): - """action_exit() transitions an enrolled membership to 'exited' and sets exit_date.""" + """The exit wizard transitions an enrolled membership to 'exited' and sets exit_date / exit_reason.""" membership = self._create_membership(state="enrolled") - membership.action_exit() + self._confirm_exit(membership, reason="Graduated") self.assertEqual(membership.state, "exited") self.assertEqual( @@ -173,11 +183,12 @@ def test_11_action_exit_from_enrolled(self): fields.Date.today(), "exit_date should be set to today when exiting.", ) + self.assertEqual(membership.exit_reason, "Graduated") def test_12_action_exit_from_paused(self): - """action_exit() transitions a paused membership to 'exited'.""" + """The exit wizard transitions a paused membership to 'exited'.""" membership = self._create_membership(state="paused") - membership.action_exit() + self._confirm_exit(membership) self.assertEqual(membership.state, "exited") self.assertIsNotNone(membership.exit_date) @@ -230,10 +241,11 @@ def test_15_full_lifecycle_draft_enrolled_paused_exited(self): membership.action_resume() self.assertEqual(membership.state, "enrolled") - # enrolled → exited - membership.action_exit() + # enrolled → exited (via wizard, which captures an exit_reason) + self._confirm_exit(membership, reason="Lifecycle test") self.assertEqual(membership.state, "exited") self.assertIsNotNone(membership.exit_date) + self.assertEqual(membership.exit_reason, "Lifecycle test") # ------------------------------------------------------------------ # registrant_id field diff --git a/spp_programs/views/program_membership_view.xml b/spp_programs/views/program_membership_view.xml index ced3e3ad..0641b27a 100644 --- a/spp_programs/views/program_membership_view.xml +++ b/spp_programs/views/program_membership_view.xml @@ -20,6 +20,7 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details. + + + + +