diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py index 5ae10807..c9a3ca89 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -1411,10 +1411,12 @@ def _ensure_program_managers(self, program): if program[field]: continue for mgr_obj, def_mgr_obj in mapping.items(): - # Create the concrete manager implementation and link via wrapper + # Create the concrete manager implementation and link via + # wrapper. Each concrete model's default_get() supplies a + # method-specific name; we don't pass "Default" anymore — + # see #941 round 2 / item 3. def_mgr = self.env[def_mgr_obj].create( { - "name": "Default", "program_id": program.id, } ) diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index 0f61ce1c..beda2f28 100644 --- a/spp_programs/__manifest__.py +++ b/spp_programs/__manifest__.py @@ -4,7 +4,7 @@ "name": "OpenSPP Programs", "summary": "Manage programs, cycles, beneficiary enrollment, entitlements (cash and in-kind), payments, and fund tracking for social protection.", "category": "OpenSPP/Core", - "version": "19.0.2.0.10", + "version": "19.0.2.0.11", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", @@ -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/migrations/19.0.2.0.11/post-migration.py b/spp_programs/migrations/19.0.2.0.11/post-migration.py new file mode 100644 index 00000000..9b493c42 --- /dev/null +++ b/spp_programs/migrations/19.0.2.0.11/post-migration.py @@ -0,0 +1,58 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Replace placeholder `name = "Default"` on default-manager records with +their method-specific labels — see #941 round 2 / item 3. + +Existing programs upgraded from a prior version still carry rows whose +`name` literally reads "Default" (the value used by +`SPPProgram.create_default_managers` before the cleanup). New rows are +fine because each concrete model now seeds its own meaningful name via +`default_get`. This migration backfills the historical rows so the Edit +form shows e.g. "CEL Eligibility Criteria" instead of "Default". +""" + +import logging + +from psycopg2 import sql + +_logger = logging.getLogger(__name__) + + +# (table_name, label) pairs. Tables are derived from the concrete-manager +# `_name` (dots replaced by underscores). +_DEFAULT_NAME_RENAMES = ( + ("spp_program_membership_manager_default", "CEL Eligibility Criteria"), + ("spp_program_entitlement_manager_default", "Basic Cash"), + ("spp_program_entitlement_manager_cash", "Cash Entitlement"), + ("spp_program_entitlement_manager_inkind", "In-kind Entitlement"), + ("spp_cycle_manager_default", "Default Cycle Schedule"), + ("spp_compliance_manager_default", "CEL Compliance Criteria"), + ("spp_program_payment_manager_default", "Default Payment"), + ("spp_program_manager_default", "Default Program Manager"), + ("spp_deduplication_manager_default", "Default Deduplication"), +) + + +def migrate(cr, version): + if not version: + return + for table, label in _DEFAULT_NAME_RENAMES: + # Skip tables that don't exist (modules not installed in this DB). + cr.execute( + "SELECT 1 FROM information_schema.tables WHERE table_name = %s", + (table,), + ) + if not cr.fetchone(): + continue + cr.execute( + sql.SQL("UPDATE {tbl} SET name = %s WHERE name = 'Default'").format( + tbl=sql.Identifier(table), + ), + (label,), + ) + if cr.rowcount: + _logger.info( + "Renamed %d %s rows from 'Default' to %r", + cr.rowcount, + table, + label, + ) diff --git a/spp_programs/models/cel/eligibility_cel.py b/spp_programs/models/cel/eligibility_cel.py index 58bdf49d..a9f0d618 100644 --- a/spp_programs/models/cel/eligibility_cel.py +++ b/spp_programs/models/cel/eligibility_cel.py @@ -56,6 +56,16 @@ class DefaultEligibilityManagerCEL(models.Model): help="True if the local expression differs from the source template", ) + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label so the + record's actual `name` (visible on the Edit form) reads "CEL + Eligibility Criteria" instead of the placeholder "Default".""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("CEL Eligibility Criteria")) + return res + # ------------------------------------------------------------------------- # CEL Profile (computed for widget context) # ------------------------------------------------------------------------- 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 b351cf2c..3fadf458 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 @@ -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/managers/compliance_manager.py b/spp_programs/models/managers/compliance_manager.py index 8ccde3f0..f3a9b4f3 100644 --- a/spp_programs/models/managers/compliance_manager.py +++ b/spp_programs/models/managers/compliance_manager.py @@ -97,6 +97,14 @@ class DefaultComplianceManager(models.Model): _inherit = ["spp.compliance.manager.base", "spp.manager.source.mixin"] _description = "Default Compliance Manager" + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label.""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("CEL Compliance Criteria")) + return res + # 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..c229aef2 100644 --- a/spp_programs/models/managers/cycle_manager_base.py +++ b/spp_programs/models/managers/cycle_manager_base.py @@ -373,6 +373,14 @@ class DefaultCycleManager(models.Model): ] _description = "Default Cycle Manager" + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label.""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("Default Cycle Schedule")) + return res + 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/deduplication_manager.py b/spp_programs/models/managers/deduplication_manager.py index 1581ab06..cd5b22ed 100644 --- a/spp_programs/models/managers/deduplication_manager.py +++ b/spp_programs/models/managers/deduplication_manager.py @@ -4,7 +4,7 @@ import logging from datetime import date -from odoo import Command, api, fields, models +from odoo import Command, _, api, fields, models _logger = logging.getLogger(__name__) @@ -53,6 +53,14 @@ class DefaultDeduplication(models.Model): _capability_individual = True _capability_group = True + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label.""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("Default Deduplication")) + return res + def deduplicate_beneficiaries(self, states): for rec in self: duplicate_beneficiaries = [] diff --git a/spp_programs/models/managers/entitlement_manager_base.py b/spp_programs/models/managers/entitlement_manager_base.py index 5ddb61b6..aedbfa37 100644 --- a/spp_programs/models/managers/entitlement_manager_base.py +++ b/spp_programs/models/managers/entitlement_manager_base.py @@ -301,6 +301,14 @@ class DefaultCashEntitlementManager(models.Model): # Set to True so that the UI will display the payment management components IS_CASH_ENTITLEMENT = True + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label.""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("Basic Cash")) + return res + 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..58d437c5 100644 --- a/spp_programs/models/managers/entitlement_manager_cash.py +++ b/spp_programs/models/managers/entitlement_manager_cash.py @@ -39,6 +39,14 @@ class SppCashEntitlementManager(models.Model): # Set to True so that the UI will display the payment management components IS_CASH_ENTITLEMENT = True + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label.""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("Cash Entitlement")) + return res + # 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..a9990615 100644 --- a/spp_programs/models/managers/entitlement_manager_inkind.py +++ b/spp_programs/models/managers/entitlement_manager_inkind.py @@ -32,6 +32,14 @@ class SPPInKindEntitlementManager(models.Model): # Set to False so that the UI will not display the payment management components IS_CASH_ENTITLEMENT = False + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label.""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("In-kind Entitlement")) + return res + @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..d0053175 100644 --- a/spp_programs/models/managers/payment_manager.py +++ b/spp_programs/models/managers/payment_manager.py @@ -84,6 +84,57 @@ class DefaultFilePaymentManager(models.Model): MAX_PAYMENTS_FOR_SYNC_PREPARE = 200 MAX_BATCHES_FOR_SYNC_SEND = 50 + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label.""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("Default Payment")) + return res + + @api.model_create_multi + def create(self, vals_list): + """Auto-create the default batch tag when needed. + + The `batch_tag_ids` constraint requires at least one tag when + `create_batch=True` (the default). The legacy flow relied on + the user toggling `create_batch` in the form to fire the + onchange that creates the tag — that doesn't fire on the form's + initial open, so the program form's `+ Add` button used to + pre-create the tag itself (#952). Pre-creation leaves an + orphan batch tag in the DB if the user dismisses the dialog + with `X` (#953). Doing it here means the tag is only created + atomically with the manager record on Save. + """ + for vals in vals_list: + if not vals.get("create_batch", True) or vals.get("batch_tag_ids"): + continue + program_id = vals.get("program_id") or self.env.context.get("default_program_id") + if not program_id: + continue + program = self.env["spp.program"].browse(program_id) + tag_name = f"Default {program.name}" + BatchTag = self.env["spp.payment.batch.tag"].sudo() # nosemgrep: odoo-sudo-without-context + tag = BatchTag.search( + [ + ("name", "=", tag_name), + ("order", "=", 1), + ("max_batch_size", "=", 500), + ], + limit=1, + ) + if not tag: + tag = BatchTag.create( + { + "name": tag_name, + "order": 1, + "domain": [], + "max_batch_size": 500, + } + ) + vals["batch_tag_ids"] = [(4, tag.id)] + return super().create(vals_list) + 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..ffa32dcf 100644 --- a/spp_programs/models/managers/program_manager.py +++ b/spp_programs/models/managers/program_manager.py @@ -87,6 +87,14 @@ class DefaultProgramManager(models.Model): _inherit = ["spp.base.program.manager", "spp.manager.source.mixin"] _description = "Default Program Manager" + @api.model + def default_get(self, fields_list): + """Default the manager name to its method-specific label.""" + res = super().default_get(fields_list) + if "name" in fields_list: + res.setdefault("name", _("Default Program Manager")) + return res + 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/managers/source_mixin.py b/spp_programs/models/managers/source_mixin.py index 3c01f727..f6265290 100644 --- a/spp_programs/models/managers/source_mixin.py +++ b/spp_programs/models/managers/source_mixin.py @@ -8,18 +8,38 @@ class ManagerSourceMixin(models.AbstractModel): _name = "spp.manager.source.mixin" _description = "Manager Data Source Mixin" - @api.model - def create(self, vals): - """Override to update reference to source on the manager.""" - res = super().create(vals) - # TODO: Seems not required but this causes error when called from the create program wizard. - # Disable for now - # if self.env.context.get("active_model"): - # # update reference on manager - # self.env[self.env.context["active_model"]].browse( - # self.env.context["active_id"] - # ).manager_id = res.id - return res + @api.model_create_multi + def create(self, vals_list): + """Override to update reference to source on the manager. + + Also auto-creates the matching wrapper record when the calling + action passed `_spp_wrapper_model` in context — used by the + program form's `+ Add` zero-state buttons (#953). Opening the + concrete model in create mode means dismissing the dialog with + `X` leaves nothing in the DB; the wrapper is only created here, + atomically with the concrete record, when Save is actually + clicked. + """ + records = super().create(vals_list) + wrapper_model = self.env.context.get("_spp_wrapper_model") + program_id = self.env.context.get("default_program_id") + if wrapper_model and program_id and wrapper_model in self.env: + for record in records: + wrapper = self.env[wrapper_model].create( + { + "program_id": program_id, + "manager_ref_id": f"{record._name},{record.id}", + } + ) + # Compliance is One2many on `spp.program.compliance_manager_ids` + # and auto-resolves via the wrapper's `program_id` inverse. + # Payment is Many2many — its program-side field needs an + # explicit write, otherwise the program never picks up the + # new wrapper. + m2m_field = self.env.context.get("_spp_program_m2m_field") + if m2m_field and "spp.program" in self.env: + self.env["spp.program"].browse(program_id).write({m2m_field: [(4, wrapper.id)]}) + return records def unlink(self): for rec in self: diff --git a/spp_programs/models/program_manager_ui.py b/spp_programs/models/program_manager_ui.py index 9d629f23..b4462155 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. @@ -58,28 +85,28 @@ }, # Cycle Managers "spp.cycle.manager.default": { - "name": "Default", + "name": "Default Cycle Schedule", "icon": "fa-calendar", "description": "Standard cycle management with recurrence options", "category": "cycle", }, # Program Managers "spp.program.manager.default": { - "name": "Default", + "name": "Default Program Manager", "icon": "fa-cogs", "description": "Standard program management", "category": "program", }, # Payment Managers "spp.program.payment.manager.default": { - "name": "Default", + "name": "Default Payment", "icon": "fa-credit-card", "description": "Standard payment processing", "category": "payment", }, # Deduplication Managers "spp.deduplication.manager.default": { - "name": "Default", + "name": "Default Deduplication", "icon": "fa-copy", "description": "Basic deduplication checks", "category": "deduplication", @@ -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,163 @@ 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 + ) + + @staticmethod + def _format_money(amount, currency): + """Render a Float amount with thousands grouping + 2-decimal precision, + prefixed by the currency symbol on the left. We don't use + odoo.tools.misc.format_amount here because it honours the currency's + `position` field — which puts the symbol after the amount for some + currency records — and the program overview should always show the + symbol on the left for consistency. + """ + precision = currency.decimal_places if currency else 2 + formatted = f"{amount:,.{precision}f}" + if not currency: + return formatted + symbol = currency.symbol or currency.name or "" + return f"{symbol} {formatted}".strip() + + 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 "" + currency = getattr(item, "currency_id", False) + amount_with_sym = self._format_money(amount, currency) + 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_with_sym, + "mult": mult_label, + } + else: + line = _("Amount per beneficiary: %(amount)s per cycle") % {"amount": amount_with_sym} + 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 = getattr(concrete, "currency_id", False) + + def _fmt(amt): + return self._format_money(amt, currency) + + lines = [] + if per_cycle: + lines.append(_("Amount per cycle: %s") % _fmt(per_cycle)) + if per_person: + line = _("Amount per person in group: %s") % _fmt(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: %s flat") % _fmt(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 +556,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 +567,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 +579,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 +589,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 +599,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 +609,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 +619,79 @@ 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): + """Open the default compliance manager form in create mode. + + The program form's compliance banner shows a `+ Add` zero-state + button when no compliance manager is configured. We open the + concrete model (`spp.compliance.manager.default`) in create mode + with `default_program_id` and `_spp_wrapper_model` in context. + Saving the dialog runs the source-mixin's `create()` override, + which auto-creates the wrapper (see source_mixin.py). Dismissing + the dialog with `X` leaves nothing in the DB — that's the whole + point of #953. + """ + self.ensure_one() + if not self.can_edit_configuration: + return False + if self.compliance_manager_ids: + return self.action_configure_compliance() + Concrete = self.env["spp.compliance.manager.default"] + return { + "type": "ir.actions.act_window", + "name": _("Compliance Criteria"), + "res_model": Concrete._name, + "view_mode": "form", + "views": [(Concrete.get_manager_view_id(), "form")], + "target": "new", + "context": { + "default_program_id": self.id, + # The mixin's create() will create the wrapper and rely + # on its `program_id` inverse to populate the program's + # One2many `compliance_manager_ids` automatically — no + # m2m write needed. + "_spp_wrapper_model": "spp.compliance.manager", + }, + } + + def action_add_payment_manager(self): + """Open the default payment manager form in create mode. + + Mirrors `action_add_compliance_manager`. The concrete model's + `create()` override auto-creates the default batch tag if the + form was saved with `create_batch=True` and no tag selected — + so we don't have to pre-create it here (which would orphan the + tag if the user dismisses the dialog). The source-mixin's + `create()` override creates the wrapper, then writes it into + the program's `payment_manager_ids` Many2many because that + field doesn't auto-resolve via the wrapper's `program_id` + inverse. See #953. + """ + self.ensure_one() + if not self.can_edit_configuration: + return False + if self.payment_manager_ids: + return self.action_configure_payment() + Concrete = self.env["spp.program.payment.manager.default"] + return { + "type": "ir.actions.act_window", + "name": _("Payment Processing"), + "res_model": Concrete._name, + "view_mode": "form", + "views": [(Concrete.get_manager_view_id(), "form")], + "target": "new", + "context": { + "default_program_id": self.id, + "_spp_wrapper_model": "spp.program.payment.manager", + "_spp_program_m2m_field": "payment_manager_ids", + }, + } + 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/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/models/programs.py b/spp_programs/models/programs.py index 1888ef05..92b93bf0 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. @@ -253,12 +289,14 @@ def create_default_managers(self, program_id): ret_vals = {} for mgr_fld in self.MANAGER_MODELS: for mgr_obj in self.MANAGER_MODELS[mgr_fld]: - # Add a new record to default manager models + # Add a new record to default manager models. The concrete + # model's default_get() supplies a method-specific name (e.g. + # "CEL Eligibility Criteria") so we don't pass the placeholder + # "Default" anymore — see #941 round 2. def_mgr_obj = self.MANAGER_MODELS[mgr_fld][mgr_obj] _logger.debug("DEBUG: %s", def_mgr_obj) def_mgr = self.env[def_mgr_obj].create( { - "name": "Default", "program_id": program_id, } ) 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/__init__.py b/spp_programs/tests/__init__.py index e010342c..b583a270 100644 --- a/spp_programs/tests/__init__.py +++ b/spp_programs/tests/__init__.py @@ -37,3 +37,4 @@ from . import test_keyset_pagination from . import test_canary_patterns from . import test_concurrency +from . import test_manager_summary_formatting diff --git a/spp_programs/tests/test_manager_summary_formatting.py b/spp_programs/tests/test_manager_summary_formatting.py new file mode 100644 index 00000000..4abf0611 --- /dev/null +++ b/spp_programs/tests/test_manager_summary_formatting.py @@ -0,0 +1,143 @@ +"""Currency + decimal formatting on the "What Do They Receive?" overview text. + +OP#941 round-2 feedback: the entitlement summary on the program overview +should include the currency symbol next to the amount and render with the +currency's native decimal precision (e.g. 1.00 instead of 1.0 for USD). +Covers both code paths in `program_manager_ui.py`: + +- `_manager_detail_entitlement_items` (when the entitlement manager has + `entitlement_item_ids` — this is what default cash transfer programs hit). +- `_manager_detail_basic_cash` (when the manager is a "Basic Cash" with + `amount_per_cycle` / `amount_per_individual_in_group`). +""" + +from odoo import fields +from odoo.tests import TransactionCase + + +class TestManagerSummaryFormatting(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.program = cls.env["spp.program"].create({"name": "Summary Fmt Program [TEST]"}) + cls.program.create_journal() # Sets program.journal_id with a currency + + entitlement_model = cls.env["ir.model"].search([("model", "=", "spp.entitlement")], limit=1) + cls.approval_def = cls.env["spp.approval.definition"].create( + { + "name": "Summary Fmt Approval [TEST]", + "model_id": entitlement_model.id, + "approval_type": "group", + "approval_group_id": cls.env.ref("base.group_user").id, + } + ) + + def _wrap_manager(self, concrete): + """Attach concrete manager to program via the wrapper + m2m field.""" + wrapper = self.env["spp.program.entitlement.manager"].create( + { + "program_id": self.program.id, + "manager_ref_id": f"{concrete._name},{concrete.id}", + } + ) + self.program.write({"entitlement_manager_ids": [fields.Command.link(wrapper.id)]}) + return wrapper + + def test_items_summary_includes_currency_and_two_decimals(self): + """Items path: 'Amount per beneficiary: 1.00 per cycle'.""" + cash = self.env["spp.program.entitlement.manager.cash"].create( + { + "name": "Cash With Items [TEST]", + "program_id": self.program.id, + "approval_definition_id": self.approval_def.id, + } + ) + self.env["spp.program.entitlement.manager.cash.item"].create( + { + "entitlement_id": cash.id, + "amount": 1.0, + } + ) + self._wrap_manager(cash) + + summary = self.program.entitlement_manager_detail or "" + # Amount must render with 2-decimal precision (1.00, not 1.0) + self.assertIn("1.00", summary) + self.assertNotRegex(summary, r"\b1\.0\b(?!\d)") + # Currency symbol must be present + currency = self.program.journal_id.currency_id + sym = currency.symbol or currency.name + self.assertIn(sym, summary, f"summary missing currency symbol {sym!r}: {summary!r}") + # Sanity: the per-cycle suffix is still there + self.assertIn("per cycle", summary) + + def test_basic_cash_summary_two_decimals(self): + """Basic Cash path: amount_per_cycle renders as e.g. '12.50', not '12.5'.""" + default_cash = self.env["spp.program.entitlement.manager.default"].create( + { + "name": "Default Cash [TEST]", + "program_id": self.program.id, + "amount_per_cycle": 12.5, + } + ) + self._wrap_manager(default_cash) + + summary = self.program.entitlement_manager_detail or "" + self.assertIn("12.50", summary) + self.assertNotRegex(summary, r"\b12\.5\b(?!\d)") + currency = self.program.journal_id.currency_id + sym = currency.symbol or currency.name + self.assertIn(sym, summary) + + def test_items_summary_includes_thousands_separator(self): + """Round-3 QA: large amounts must group thousands (1,000,000.00 not 1000000.00).""" + cash = self.env["spp.program.entitlement.manager.cash"].create( + { + "name": "Cash Large Amount [TEST]", + "program_id": self.program.id, + "approval_definition_id": self.approval_def.id, + } + ) + self.env["spp.program.entitlement.manager.cash.item"].create( + { + "entitlement_id": cash.id, + "amount": 1_000_000.0, + } + ) + self._wrap_manager(cash) + + summary = self.program.entitlement_manager_detail or "" + # Should include a comma in the grouped amount for en_US locale. + self.assertIn("1,000,000", summary, f"summary missing thousands separator: {summary!r}") + # And of course still no bare "1000000" without a separator. + self.assertNotRegex(summary, r"\b1000000\b") + + def test_items_summary_symbol_appears_left_of_amount(self): + """QA round-4: currency symbol must render on the LEFT of the amount.""" + cash = self.env["spp.program.entitlement.manager.cash"].create( + { + "name": "Cash Symbol Position [TEST]", + "program_id": self.program.id, + "approval_definition_id": self.approval_def.id, + } + ) + self.env["spp.program.entitlement.manager.cash.item"].create( + { + "entitlement_id": cash.id, + "amount": 500.0, + } + ) + # Force currency.position to 'after' to ensure our render still puts + # the symbol on the left regardless of the currency record setting. + self.program.journal_id.currency_id.position = "after" + self._wrap_manager(cash) + + summary = self.program.entitlement_manager_detail or "" + currency = self.program.journal_id.currency_id + sym = currency.symbol or currency.name + # The symbol must appear before the amount substring in the summary. + idx_sym = summary.find(sym) + idx_amt = summary.find("500.00") + self.assertNotEqual(idx_sym, -1, f"symbol missing from summary: {summary!r}") + self.assertNotEqual(idx_amt, -1, f"amount missing from summary: {summary!r}") + self.assertLess(idx_sym, idx_amt, f"symbol should be LEFT of amount, got: {summary!r}") 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_config_cards_view.xml b/spp_programs/views/program_config_cards_view.xml index 08063dcb..c11f3bfd 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. +
- + + + + + + Configured + +
+ + +
+ + No payment processing configured. Click Add above to define how payments are made. +
- + + + + + + 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. +
+ + + + +