diff --git a/spp_programs/README.rst b/spp_programs/README.rst
index 9436f7903..514eb2da4 100644
--- a/spp_programs/README.rst
+++ b/spp_programs/README.rst
@@ -254,6 +254,35 @@ Dependencies
Changelog
=========
+19.0.2.4.0
+~~~~~~~~~~
+
+- feat(spp_programs): **the Program Configuration tab is one consistent
+ set of cards.** Every manager category is now configured the same way
+ -- a card showing what is set up, ``+ Add`` opening a dialog that asks
+ which method and what to call it, and a cog on each row to open it.
+ Previously some categories were cards and others were bare editable
+ lists whose only column was a Reference field, so adding one meant
+ picking a model and then finding or creating a record of it.
+ Notifications was the last such list and is now a card like the rest
+ (#1172)
+- feat(spp_programs): a shared manager setup dialog backs all of those
+ cards, replacing the per-category wiring. Selecting a method a program
+ already has is refused with a message naming it rather than a
+ duplicate-record error (#1172)
+- fix(spp_programs): the entitlement amount item no longer requires a
+ formula. A fixed sum is a normal entitlement and should not oblige
+ anyone to write it as an expression; leaving the formula empty pays
+ the Base Amount unchanged. The **Base Amount** field is also no longer
+ hidden -- formulas are documented to build on it as ``base_amount``,
+ so hiding it left nothing for them to multiply (#1172)
+- fix(spp_programs): the entitlement formula box no longer offers
+ symbols the evaluator never receives. It advertised the entitlements
+ profile, whose record is the entitlement itself, while the field is
+ evaluated with ``me`` for the beneficiary and ``base_amount`` for the
+ fixed amount -- so a formula built from the browser failed to compile.
+ The placeholder and help now name the real vocabulary (#1172)
+
19.0.2.3.4
~~~~~~~~~~
diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py
index cdb136049..56482a824 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.3.4",
+ "version": "19.0.2.4.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
@@ -115,6 +115,7 @@
"wizard/enrollment_wizard_views.xml",
"wizard/exit_membership_wizard.xml",
"wizard/prepare_entitlement_confirm_wizard.xml",
+ "wizard/manager_setup_wizard.xml",
],
"assets": {
"web.assets_backend": [
diff --git a/spp_programs/models/constants.py b/spp_programs/models/constants.py
index 10c442b6f..1d40303ec 100644
--- a/spp_programs/models/constants.py
+++ b/spp_programs/models/constants.py
@@ -52,3 +52,86 @@
"spp.compliance.manager": "spp.compliance.manager.default",
},
}
+
+# The cards on a program's Configuration tab (OP#1172). Each names the field on
+# spp.program, the wrapper model behind it, and the wording the Add dialog uses.
+# The keys match MANAGER_TYPE_INFO's "category" so the two can be read together:
+# this map says where a category lives, MANAGER_TYPE_INFO describes the methods
+# inside it.
+#
+# The concrete methods themselves are deliberately absent. They come from the
+# wrapper's `_selection_manager_ref_id()`, which is what other modules extend
+# when they add one — spp_program_geofence adds an eligibility method that way,
+# and a hard-coded list here would never see it.
+MANAGER_CATEGORIES = {
+ "eligibility": {
+ "field": "eligibility_manager_ids",
+ "wrapper": "spp.eligibility.manager",
+ "label": "Eligibility Method",
+ },
+ "entitlement": {
+ "field": "entitlement_manager_ids",
+ "wrapper": "spp.program.entitlement.manager",
+ "label": "Entitlement Type",
+ # One per program, and not by choice of this dialog: spp.program's
+ # check_managers_limit refuses a second entitlement manager, and the
+ # cycle machinery reaches for exactly one — get_manager() calls
+ # ensure_one(), and get_managers() raises NotImplementedError for this
+ # kind. QA asked for several of the same kind (OP#1172 round 1); that
+ # needs the entitlement engine to iterate managers first, so the dialog
+ # says what the program can actually do rather than accepting a second
+ # method the cycle would then choke on.
+ "single_manager": True,
+ },
+ "cycle": {
+ "field": "cycle_manager_ids",
+ "wrapper": "spp.cycle.manager",
+ "label": "Cycle Schedule",
+ # Capped at one by spp.program's check_managers_limit, same as
+ # entitlement. Unreachable through the dialog while this category has a
+ # single concrete method in-repo -- the already-configured check fires
+ # first -- but a module registering a second one (as spp_program_geofence
+ # does for eligibility) would otherwise get the constraint's after-the-fact
+ # wording, which is the experience this dialog exists to remove.
+ "single_manager": True,
+ },
+ "compliance": {
+ "field": "compliance_manager_ids",
+ "wrapper": "spp.compliance.manager",
+ "label": "Compliance Criteria",
+ },
+ "payment": {
+ "field": "payment_manager_ids",
+ "wrapper": "spp.program.payment.manager",
+ "label": "Payment Method",
+ # Capped at one by spp.program's check_managers_limit, same as
+ # entitlement. Unreachable through the dialog while this category has a
+ # single concrete method in-repo -- the already-configured check fires
+ # first -- but a module registering a second one (as spp_program_geofence
+ # does for eligibility) would otherwise get the constraint's after-the-fact
+ # wording, which is the experience this dialog exists to remove.
+ "single_manager": True,
+ },
+ "deduplication": {
+ "field": "deduplication_manager_ids",
+ "wrapper": "spp.deduplication.manager",
+ "label": "Deduplication Method",
+ },
+ "notification": {
+ "field": "notification_manager_ids",
+ "wrapper": "spp.program.notification.manager",
+ "label": "Notification Channel",
+ },
+ "program": {
+ "field": "program_manager_ids",
+ "wrapper": "spp.program.manager",
+ "label": "Program Manager",
+ # Capped at one by spp.program's check_managers_limit, same as
+ # entitlement. Unreachable through the dialog while this category has a
+ # single concrete method in-repo -- the already-configured check fires
+ # first -- but a module registering a second one (as spp_program_geofence
+ # does for eligibility) would otherwise get the constraint's after-the-fact
+ # wording, which is the experience this dialog exists to remove.
+ "single_manager": True,
+ },
+}
diff --git a/spp_programs/models/program_manager_ui.py b/spp_programs/models/program_manager_ui.py
index 406e16c7d..535ea4235 100644
--- a/spp_programs/models/program_manager_ui.py
+++ b/spp_programs/models/program_manager_ui.py
@@ -7,6 +7,9 @@
"""
from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+from .constants import MANAGER_CATEGORIES
def _format_recurrence(duration, rrule_type):
@@ -241,6 +244,11 @@ class ProgramManagerUI(models.Model):
payment_manager_display = fields.Char(compute="_compute_banner_layout_helpers")
payment_manager_detail = fields.Text(compute="_compute_banner_layout_helpers")
+ # OP#1172: Notifications became a card like the rest, so it needs the same
+ # single-vs-multi helpers the other cards use.
+ notification_manager_count = fields.Integer(compute="_compute_banner_layout_helpers")
+ notification_manager_display = fields.Char(compute="_compute_banner_layout_helpers")
+ notification_manager_detail = fields.Text(compute="_compute_banner_layout_helpers")
deduplication_manager_count = fields.Integer(compute="_compute_banner_layout_helpers")
deduplication_manager_display = fields.Char(compute="_compute_banner_layout_helpers")
deduplication_manager_detail = fields.Text(compute="_compute_banner_layout_helpers")
@@ -407,6 +415,8 @@ def _compute_compliance_summary(self):
"compliance_manager_ids.manager_ref_id",
"payment_manager_ids",
"payment_manager_ids.manager_ref_id",
+ "notification_manager_ids",
+ "notification_manager_ids.manager_ref_id",
"deduplication_manager_ids",
"deduplication_manager_ids.manager_ref_id",
)
@@ -419,6 +429,7 @@ def _compute_banner_layout_helpers(self):
("cycle_manager_ids", "cycle"),
("compliance_manager_ids", "compliance"),
("payment_manager_ids", "payment"),
+ ("notification_manager_ids", "notification"),
("deduplication_manager_ids", "deduplication"),
)
for rec in self:
@@ -653,73 +664,58 @@ def action_configure_compliance(self):
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.
+ def action_add_manager(self):
+ """Open the Add dialog for one Configuration card (OP#1172).
+
+ One action serves every card: the button passes its category in the
+ context, so adding an eligibility method and adding a payment method
+ are the same gesture instead of one bespoke action per section.
+
+ The methods on offer come from the wrapper, so a category whose module
+ is not installed says so rather than opening a dialog with an empty
+ list — notifications have no channel at all until a bridge module such
+ as SMS is installed.
"""
self.ensure_one()
- if not self.can_edit_configuration:
+ if not self.can_edit_configuration or self.state == "ended":
return False
- if self.compliance_manager_ids:
- return self.action_configure_compliance()
- Concrete = self.env["spp.compliance.manager.default"]
+ category = self.env.context.get("manager_category")
+ info = MANAGER_CATEGORIES.get(category)
+ if not info:
+ raise UserError(_("Unknown configuration category %s.") % category)
+ wizard = self.env["spp.manager.setup.wizard"]
+ methods = wizard._methods_for_category(category)
+ if not methods:
+ raise UserError(
+ _("No %s is available. Install a module that provides one, then add it here.") % info["label"].lower()
+ )
return {
"type": "ir.actions.act_window",
- "name": _("Compliance Criteria"),
- "res_model": Concrete._name,
+ "name": _("Add a %s") % info["label"],
+ "res_model": wizard._name,
"view_mode": "form",
- "views": [(Concrete.get_manager_view_id(), "form")],
+ "views": [(False, "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",
+ "default_category": category,
+ "default_method": methods[0][0],
+ "default_name": methods[0][1],
},
}
- 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.
+ def action_add_compliance_manager(self):
+ """Compliance's Add button, kept for callers that predate OP#1172.
+
+ Compliance opened its concrete form directly (#952) and payment did the
+ same (#953), while the other cards had no Add at all. Every card now
+ goes through one dialog, so all this does is name the category.
"""
- 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",
- },
- }
+ return self.with_context(manager_category="compliance").action_add_manager()
+
+ def action_add_payment_manager(self):
+ """Payment's Add button, kept for callers that predate OP#1172."""
+ return self.with_context(manager_category="payment").action_add_manager()
def action_add_deduplication_manager(self):
"""Open the two-step dialog for adding a deduplication method (OP#1171).
@@ -746,17 +742,14 @@ def action_add_deduplication_manager(self):
}
def _open_manager_setup_wizard(self, manager_type):
- """Open wizard to set up a new manager of the specified type."""
- return {
- "type": "ir.actions.client",
- "tag": "display_notification",
- "params": {
- "title": _("Setup Required"),
- "message": _("Please add a %s manager first using the list below.") % manager_type,
- "sticky": False,
- "type": "warning",
- },
- }
+ """Point a caller at the Add dialog for this category (OP#1172).
+
+ This used to pop a warning telling the user to "add a manager using the
+ list below" — the inline list with the Reference field, which is the
+ control this ticket removes. The categories it is called with are the
+ MANAGER_CATEGORIES keys, so it can now open the real thing.
+ """
+ return self.with_context(manager_category=manager_type).action_add_manager()
def get_manager_type_options(self, category):
"""Get available manager type options for a category."""
diff --git a/spp_programs/models/programs.py b/spp_programs/models/programs.py
index a81ebbc3e..b4337a1e2 100644
--- a/spp_programs/models/programs.py
+++ b/spp_programs/models/programs.py
@@ -2,7 +2,7 @@
import logging
from odoo import _, api, fields, models
-from odoo.exceptions import AccessError, UserError
+from odoo.exceptions import AccessError, UserError, ValidationError
from . import constants
@@ -203,6 +203,77 @@ def _check_unique_program_name(self):
if existing:
raise UserError(_("A program with this name already exists. Program names must be unique."))
+ # ------------------------------------------------------------------
+ # configuration isolation (OP#1172)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _configuration_fields():
+ """The Configuration tab's fields, in one place for the rules below."""
+ return [info["field"] for info in constants.MANAGER_CATEGORIES.values()]
+
+ def _check_configuration_is_own(self, field, wrappers):
+ """Refuse configuration that belongs to another program.
+
+ Every manager names the program it was created for and runs against
+ that program, so linking one into a second program does not configure
+ the second — it only makes the form lie about what will happen. The
+ Configuration tab no longer offers a picker that can do this; this
+ covers the API, data imports and duplicated programs.
+
+ Only what is being linked now is checked. A database that already holds
+ a cross-program link from the old picker stays loadable, and the row's
+ ✕ can still take it off.
+ """
+ self.ensure_one()
+ for wrapper in wrappers:
+ concrete = wrapper.manager_ref_id
+ owner = wrapper.program_id or (
+ concrete.program_id if concrete and "program_id" in concrete._fields else False
+ )
+ if owner and owner != self:
+ raise ValidationError(
+ _(
+ "%(method)s belongs to the program %(owner)s, so it cannot be used by "
+ "%(program)s as well. Each program's configuration is its own — add a "
+ "method to this program instead."
+ )
+ % {
+ "method": wrapper.display_name or self.env[wrapper._name]._description,
+ "owner": owner.display_name,
+ "program": self.display_name,
+ }
+ )
+
+ def copy(self, default=None):
+ """Duplicate the program with its own copy of the configuration.
+
+ These fields are mostly Many2many, so a plain copy would link the
+ source's managers into the duplicate — the sharing this ticket removes.
+ Each method is copied instead: the duplicate starts configured the same
+ way and owns what it runs.
+ """
+ default = dict(default or {})
+ fields_to_copy = [field for field in self._configuration_fields() if field in self._fields]
+ for field in fields_to_copy:
+ default.setdefault(field, False)
+ new_programs = super().copy(default)
+ for source, new_program in zip(self, new_programs, strict=False):
+ for field in fields_to_copy:
+ context = {
+ "_spp_wrapper_model": source._fields[field].comodel_name,
+ "default_program_id": new_program.id,
+ }
+ if source._fields[field].type == "many2many":
+ # A Many2many does not resolve from the wrapper's program_id,
+ # so the copy has to be linked explicitly — see the source mixin.
+ context["_spp_program_m2m_field"] = field
+ for wrapper in source[field]:
+ concrete = wrapper.manager_ref_id
+ if concrete and concrete.exists():
+ concrete.with_context(**context).copy({"program_id": new_program.id})
+ return new_programs
+
@api.depends("program_membership_ids")
def _compute_has_members(self):
if self.env.context.get("skip_program_statistics"):
@@ -281,6 +352,13 @@ def create(self, vals):
for one in vals if isinstance(vals, list) else [vals]:
self._assert_operation_lock_writable(one)
res = super().create(vals)
+ # Everything linked at creation is being linked now, so all of it is
+ # checked. Reading `vals` instead would miss it: base create() is
+ # model_create_multi, so what arrives here is a list of dicts.
+ for record in res:
+ for field in record._configuration_fields():
+ if record[field]:
+ record._check_configuration_is_own(field, record[field])
if self.env.context.get("skip_default_managers"):
return res
if self.env.context.get("create_default_managers"):
@@ -290,6 +368,31 @@ def create(self, vals):
res.update({man: [(4, man_ids[man])]})
return res
+ def write(self, vals):
+ """Refuse configuration linked in from another program (OP#1172), and
+ keep the operation lock admin-writable only.
+
+ Both guards live here on purpose. They arrived from different branches
+ as two separate `write` methods on this one class, where the later
+ definition silently replaced the earlier -- no error, no conflict when
+ the branches merged, just one guard quietly gone. Anything else that
+ needs to hook writes belongs in this method too.
+
+ For the configuration check, only the links this write adds are
+ examined, so a database that already holds a cross-program link stays
+ editable and the link can be removed.
+ """
+ self._assert_operation_lock_writable(vals)
+ touched = [field for field in self._configuration_fields() if field in vals]
+ before = {(rec.id, field): set(rec[field].ids) for rec in self for field in touched}
+ result = super().write(vals)
+ for rec in self:
+ for field in touched:
+ added = set(rec[field].ids) - before[(rec.id, field)]
+ if added:
+ rec._check_configuration_is_own(field, rec[field].browse(sorted(added)))
+ return result
+
@api.model
def create_default_managers(self, program_id):
ret_vals = {}
@@ -825,10 +928,6 @@ def _assert_operation_lock_writable(self, vals):
)
)
- def write(self, vals):
- self._assert_operation_lock_writable(vals)
- return super().write(vals)
-
# NOTE(#337): these helpers sudo the lock write, so any PUBLIC method that
# calls them (e.g. the async mark_*_as_done / mark_*_as_failed completion
# callbacks) is an RPC-reachable lock-clearing path that the write() guard
diff --git a/spp_programs/readme/HISTORY.md b/spp_programs/readme/HISTORY.md
index 02a0af95c..af8366bf9 100644
--- a/spp_programs/readme/HISTORY.md
+++ b/spp_programs/readme/HISTORY.md
@@ -1,3 +1,10 @@
+### 19.0.2.4.0
+
+- feat(spp_programs): **the Program Configuration tab is one consistent set of cards.** Every manager category is now configured the same way -- a card showing what is set up, `+ Add` opening a dialog that asks which method and what to call it, and a cog on each row to open it. Previously some categories were cards and others were bare editable lists whose only column was a Reference field, so adding one meant picking a model and then finding or creating a record of it. Notifications was the last such list and is now a card like the rest (#1172)
+- feat(spp_programs): a shared manager setup dialog backs all of those cards, replacing the per-category wiring. Selecting a method a program already has is refused with a message naming it rather than a duplicate-record error (#1172)
+- fix(spp_programs): the entitlement amount item no longer requires a formula. A fixed sum is a normal entitlement and should not oblige anyone to write it as an expression; leaving the formula empty pays the Base Amount unchanged. The **Base Amount** field is also no longer hidden -- formulas are documented to build on it as `base_amount`, so hiding it left nothing for them to multiply (#1172)
+- fix(spp_programs): the entitlement formula box no longer offers symbols the evaluator never receives. It advertised the entitlements profile, whose record is the entitlement itself, while the field is evaluated with `me` for the beneficiary and `base_amount` for the fixed amount -- so a formula built from the browser failed to compile. The placeholder and help now name the real vocabulary (#1172)
+
### 19.0.2.3.4
- fix(security): the Tier-3 `group_registry_read` group can read `spp.cycle` and `spp.cycle.membership`. Both were granted only to the Tier-2 `group_registry_viewer` group, yet the registrant form depends on both: the entitlement lists render `cycle_id`, and the membership list renders `latest_cycle_state`, which is computed by searching `spp.cycle.membership` as the acting user.
diff --git a/spp_programs/security/ir.model.access.csv b/spp_programs/security/ir.model.access.csv
index cb71a2eb8..88e56a8ea 100644
--- a/spp_programs/security/ir.model.access.csv
+++ b/spp_programs/security/ir.model.access.csv
@@ -404,6 +404,8 @@ access_spp_prepare_entitlement_confirm_wizard_validator,Prepare Entitlement Conf
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
+access_spp_manager_setup_wizard_manager,Manager Setup Wizard Manager Access,spp_programs.model_spp_manager_setup_wizard,group_programs_manager,1,1,1,1
+access_spp_manager_setup_wizard_admin,Manager Setup Wizard Admin Access,spp_programs.model_spp_manager_setup_wizard,spp_security.group_spp_admin,1,1,1,1
access_spp_deduplication_setup_wizard_manager,Deduplication Setup Wizard Manager Access,spp_programs.model_spp_deduplication_setup_wizard,group_programs_manager,1,1,1,1
access_spp_deduplication_setup_wizard_admin,Deduplication Setup Wizard Admin Access,spp_programs.model_spp_deduplication_setup_wizard,spp_security.group_spp_admin,1,1,1,1
access_spp_cycle_registry_read,Cycle Registry Read,spp_programs.model_spp_cycle,spp_registry.group_registry_read,1,0,0,0
diff --git a/spp_programs/static/description/index.html b/spp_programs/static/description/index.html
index ac614eea7..4e3417068 100644
--- a/spp_programs/static/description/index.html
+++ b/spp_programs/static/description/index.html
@@ -658,6 +658,36 @@
feat(spp_programs): the Program Configuration tab is one consistent
+set of cards. Every manager category is now configured the same way
+– a card showing what is set up, + Add opening a dialog that asks
+which method and what to call it, and a cog on each row to open it.
+Previously some categories were cards and others were bare editable
+lists whose only column was a Reference field, so adding one meant
+picking a model and then finding or creating a record of it.
+Notifications was the last such list and is now a card like the rest
+(#1172)
+
feat(spp_programs): a shared manager setup dialog backs all of those
+cards, replacing the per-category wiring. Selecting a method a program
+already has is refused with a message naming it rather than a
+duplicate-record error (#1172)
+
fix(spp_programs): the entitlement amount item no longer requires a
+formula. A fixed sum is a normal entitlement and should not oblige
+anyone to write it as an expression; leaving the formula empty pays
+the Base Amount unchanged. The Base Amount field is also no longer
+hidden – formulas are documented to build on it as base_amount,
+so hiding it left nothing for them to multiply (#1172)
+
fix(spp_programs): the entitlement formula box no longer offers
+symbols the evaluator never receives. It advertised the entitlements
+profile, whose record is the entitlement itself, while the field is
+evaluated with me for the beneficiary and base_amount for the
+fixed amount – so a formula built from the browser failed to compile.
+The placeholder and help now name the real vocabulary (#1172)
+
+
+
19.0.2.3.4
fix(security): the Tier-3 group_registry_read group can read
@@ -668,7 +698,7 @@
19.0.2.3.4
searching spp.cycle.membership as the acting user.
-
+
19.0.2.3.3
fix(security): the operation lock is now guarded on create as well as
@@ -682,7 +712,7 @@
19.0.2.3.3
the lock as before.
-
+
19.0.2.3.2
fix(security): the Program Viewer role no longer carries the Tier-2
@@ -696,7 +726,7 @@
19.0.2.3.2
already-assigned users on upgrade.
-
+
19.0.2.3.1
fix(security): make the async operation lock a server-side boundary.
@@ -713,7 +743,7 @@
19.0.2.3.1
acquire/release from the initiating user keeps working.
-
+
19.0.2.3.0
feat(spp_programs): Duplicate Detection is a card with an Add
@@ -738,7 +768,7 @@
19.0.2.3.0
still blocked its own re-adding (#1171)
-
+
19.0.2.2.1
fix(spp_programs): stop Enroll Eligible undoing a deliberate pause. A
@@ -748,7 +778,7 @@
19.0.2.2.1
Pausing is a decision that only Resume reverses (#1117)
Add composite indexes for frequent query patterns on entitlements and
program memberships
-
+
19.0.2.0.1
Replace Python-level uniqueness checks with SQL UNIQUE constraints for
@@ -892,7 +922,7 @@
19.0.2.0.1
constraint creation
-
+
19.0.2.0.0
Initial migration to OpenSPP2
diff --git a/spp_programs/tests/__init__.py b/spp_programs/tests/__init__.py
index 9c0b90101..b2375978a 100644
--- a/spp_programs/tests/__init__.py
+++ b/spp_programs/tests/__init__.py
@@ -45,5 +45,6 @@
from . import test_cycle_null_entitlement_approval
from . import test_approve_entitlements_program_isolation
from . import test_payment_batch_payment_ids
+from . import test_manager_setup_wizard
from . import test_program_viewer_registry_scope
from . import test_registry_read_access
diff --git a/spp_programs/tests/test_deduplication_setup_wizard.py b/spp_programs/tests/test_deduplication_setup_wizard.py
index 97ef53bf7..1823e797b 100644
--- a/spp_programs/tests/test_deduplication_setup_wizard.py
+++ b/spp_programs/tests/test_deduplication_setup_wizard.py
@@ -333,13 +333,28 @@ def test_the_sweep_spares_a_method_another_program_uses(self):
shared = self.program.deduplication_manager_ids
other = self.env["spp.program"].create({"name": "Dedup Sweep Bystander [TEST]"})
self.program.write({"deduplication_manager_ids": [(3, shared.id)]})
- other.write({"deduplication_manager_ids": [(4, shared.id)]})
+ self._link_directly(other, shared)
self._wizard("spp.deduplication.manager.phone_number", "By phone").action_create_manager()
self.assertTrue(shared.exists(), "another program still uses this method")
self.assertIn(shared, other.deduplication_manager_ids)
+ def _link_directly(self, program, wrapper):
+ """Put a wrapper on a program without going through write().
+
+ OP#1172 refuses linking one program's manager into another, so this
+ shape can no longer be created through the ORM. It can still exist in
+ databases built before that rule -- which is exactly the case the sweep
+ has to spare -- so the relation row is written directly.
+ """
+ field = type(program)._fields["deduplication_manager_ids"]
+ self.env.cr.execute(
+ f"INSERT INTO {field.relation} ({field.column1}, {field.column2}) VALUES (%s, %s)", # noqa: S608
+ (program.id, wrapper.id),
+ )
+ program.invalidate_recordset(["deduplication_manager_ids"])
+
def test_the_sweep_spares_a_method_reached_through_another_wrapper(self):
"""Two wrappers can point at one concrete; the cascade is not scoped.
diff --git a/spp_programs/tests/test_entitlement_amount_cel.py b/spp_programs/tests/test_entitlement_amount_cel.py
index b665f8546..d796403f6 100644
--- a/spp_programs/tests/test_entitlement_amount_cel.py
+++ b/spp_programs/tests/test_entitlement_amount_cel.py
@@ -1,6 +1,8 @@
import logging
from datetime import timedelta
+from lxml import etree
+
from odoo import fields
from odoo.exceptions import UserError, ValidationError
from odoo.tests import TransactionCase, tagged
@@ -162,7 +164,15 @@ def test_06_cel_validation_syntax_error(self):
)
def test_07_cel_calculation_empty_formula(self):
- """Test that calculating with empty formula raises error."""
+ """Calculating from an empty formula raises.
+
+ Nothing reaches this with an empty expression: the manager that
+ actually runs (CashEntitlementManagerCEL.prepare_entitlements in
+ entitlement_condition_cel.py) tests amount_cel_expression first, and
+ the preview compute returns early. This pins the guard, not a path
+ users can take -- an empty formula is handled long before here, see
+ test_07b.
+ """
item = self.env["spp.program.entitlement.manager.cash.item"].create(
{
"entitlement_id": self.entitlement_manager.id,
@@ -172,6 +182,35 @@ def test_07_cel_calculation_empty_formula(self):
with self.assertRaises(UserError):
item._calculate_cel_amount(self.beneficiary1)
+ def test_07b_prepare_entitlements_without_a_formula(self):
+ """A typed Base Amount with no formula must still pay out.
+
+ QA round 2: "CEL expression field should be optional FOR NOW. just to
+ let user define the amount." The form stopped requiring a formula; this
+ pins the behaviour behind that, so the field cannot quietly become
+ required again without a failure here.
+ """
+ self.env["spp.program.entitlement.manager.cash.item"].create(
+ {
+ "entitlement_id": self.entitlement_manager.id,
+ "amount": 425.0,
+ "amount_cel_expression": False,
+ }
+ )
+
+ self.entitlement_manager.prepare_entitlements(self.cycle, self.membership1 | self.membership2)
+
+ entitlements = self.env["spp.entitlement"].search(
+ [
+ ("cycle_id", "=", self.cycle.id),
+ ("partner_id", "in", [self.beneficiary1.id, self.beneficiary2.id]),
+ ]
+ )
+
+ self.assertEqual(len(entitlements), 2, "a formula-less item must still pay every beneficiary")
+ for ent in entitlements:
+ self.assertEqual(ent.initial_amount, 425.0)
+
def test_08_cel_non_numeric_result(self):
"""Test that non-numeric results raise an error."""
item = self.env["spp.program.entitlement.manager.cash.item"].create(
@@ -369,3 +408,76 @@ def test_18_security_safe_field_access(self):
result = item._calculate_cel_amount(self.beneficiary1)
self.assertEqual(result, 100.0)
+
+
+@tagged("post_install", "-at_install")
+class TestEntitlementAmountCELForm(TransactionCase):
+ """OP#1172 round 1: what the amount item form must offer.
+
+ QA could not add a cash entitlement line: the form had no field for the base
+ amount, and a formula built from the widget's symbol browser failed to
+ compile. Both came from this view rather than from the entitlement logic.
+ """
+
+ def _item_form(self):
+ arch = etree.fromstring(
+ self.env["spp.program.entitlement.manager.cash"].get_view(
+ self.env.ref("spp_programs.view_entitlement_manager_cash_form").id, "form"
+ )["arch"]
+ )
+ forms = arch.xpath("//field[@name='entitlement_item_ids']/form")
+ self.assertTrue(forms, "the items list should still open a form")
+ return forms[0]
+
+ def test_the_base_amount_stays_on_the_form(self):
+ """Formulas are documented to build on it, so it must be settable.
+
+ The CEL view used to hide it outright, which left base_amount
+ permanently unset and every documented "base_amount * ..." formula
+ unusable.
+ """
+ amounts = [f for f in self._item_form().iter("field") if f.get("name") == "amount"]
+
+ self.assertTrue(amounts, "the item form should offer the base amount")
+ self.assertNotEqual(amounts[0].get("invisible"), "1", "hiding it is what QA reported")
+
+ def test_the_formula_is_not_required(self):
+ """A fixed sum should not oblige anyone to write "500" as a formula.
+
+ QA round 2: "CEL expression field should be optional FOR NOW. just to
+ let user define the amount."
+ """
+ formulas = [f for f in self._item_form().iter("field") if f.get("name") == "amount_cel_expression"]
+
+ self.assertTrue(formulas, "the item form should still offer the formula")
+ self.assertNotEqual(
+ formulas[0].get("required"),
+ "1",
+ "requiring a formula is what QA reported in round 2",
+ )
+
+ def test_the_formula_field_does_not_advertise_the_wrong_symbols(self):
+ """The evaluator receives `me` and `base_amount`, not the entitlements profile.
+
+ _validate_cel_expression and _calculate_cel_amount build their own
+ context, so a symbol browser listing spp.entitlement's fields offers
+ names that never arrive — `r.birthdate` compiles against
+ spp.entitlement and fails.
+ """
+ expressions = [f for f in self._item_form().iter("field") if f.get("name") == "amount_cel_expression"]
+
+ self.assertTrue(expressions, "the formula field should be on the form")
+ self.assertEqual(expressions[0].get("show_symbol_browser"), "false")
+ self.assertIsNone(expressions[0].get("cel_profile"), "no profile matches this evaluator yet")
+
+ def test_the_documented_vocabulary_actually_validates(self):
+ """A plain number and a base_amount formula both pass the model's own check."""
+ manager = self.env["spp.program.entitlement.manager.cash"].create(
+ {"name": "CEL Form [TEST]", "program_id": self.env["spp.program"].create({"name": "CEL Form P [TEST]"}).id}
+ )
+ for expression in ("500", "base_amount * 1.1", "me.household_size * 100"):
+ with self.subTest(expression=expression):
+ item = self.env["spp.program.entitlement.manager.cash.item"].create(
+ {"entitlement_id": manager.id, "amount": 100.0, "amount_cel_expression": expression}
+ )
+ item._validate_cel_expression()
diff --git a/spp_programs/tests/test_manager_setup_wizard.py b/spp_programs/tests/test_manager_setup_wizard.py
new file mode 100644
index 000000000..52a84d095
--- /dev/null
+++ b/spp_programs/tests/test_manager_setup_wizard.py
@@ -0,0 +1,334 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""OP#1172: one way to configure a program, and it stays that program's own.
+
+Every card on the Configuration tab used to be filled through an inline list
+with a `manager_ref_id` Reference field. Both halves of that control offered
+other programs' managers, so one program could be configured with another's
+while the manager went on running against the program it was created for.
+
+These tests cover the replacement — one Add dialog for every card — and the
+isolation rules that hold whether the configuration arrives from the form, the
+API, or a duplicated program.
+"""
+
+from lxml import etree
+
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests import TransactionCase, tagged
+
+from ..models.constants import MANAGER_CATEGORIES
+
+# The cards this branch converted. Deduplication is deliberately absent: its
+# card is being converted under OP#1171 and lands separately.
+CONVERTED = ["eligibility", "entitlement", "cycle", "compliance", "payment", "notification"]
+
+
+@tagged("post_install", "-at_install")
+class TestManagerSetupWizard(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.program = cls.env["spp.program"].create({"name": "Manager Setup Wizard [TEST]"})
+ cls.wizard_model = cls.env["spp.manager.setup.wizard"]
+
+ def _add(self, category, method=None, name="A method", program=None):
+ """Add a method the way the dialog does."""
+ methods = self.wizard_model._methods_for_category(category)
+ wizard = self.wizard_model.with_context(default_category=category).create(
+ {
+ "program_id": (program or self.program).id,
+ "category": category,
+ "method": method or methods[0][0],
+ "name": name,
+ }
+ )
+ wizard.action_create_manager()
+ return wizard
+
+ def _configured(self, category, program=None):
+ return (program or self.program)[MANAGER_CATEGORIES[category]["field"]]
+
+ # ------------------------------------------------------------------
+ # the dialog
+ # ------------------------------------------------------------------
+
+ def test_every_card_can_be_configured_through_the_dialog(self):
+ """One dialog, every category — including the One2many one.
+
+ Compliance resolves from the wrapper's program_id while the rest are
+ Many2many and need an explicit link, which is the step that used to be
+ forgotten: the manager gets created and the program never picks it up.
+ """
+ for category in CONVERTED:
+ methods = self.wizard_model._methods_for_category(category)
+ if not methods:
+ continue
+ with self.subTest(category=category):
+ program = self.env["spp.program"].create({"name": f"Dialog {category} [TEST]"})
+ self._add(category, name=f"My {category}", program=program)
+
+ configured = self._configured(category, program)
+ self.assertEqual(len(configured), 1, f"{category} should be wired to the program")
+ self.assertEqual(configured.manager_ref_id.name, f"My {category}")
+ self.assertEqual(configured.manager_ref_id.program_id, program)
+
+ def test_the_methods_come_from_the_wrapper_not_a_list_here(self):
+ """So a module that adds a method is offered without editing the wizard."""
+ wrapper = self.env[MANAGER_CATEGORIES["eligibility"]["wrapper"]]
+ offered = dict(self.wizard_model._methods_for_category("eligibility"))
+
+ self.assertTrue(offered, "eligibility should offer at least one method")
+ for model, _label in wrapper._selection_manager_ref_id():
+ if model in self.env:
+ self.assertIn(model, offered, f"{model} is registered on the wrapper but not offered")
+
+ def test_an_unknown_category_offers_nothing(self):
+ self.assertEqual(self.wizard_model._methods_for_category("not-a-category"), [])
+
+ def test_the_name_is_suggested_from_the_method(self):
+ methods = self.wizard_model._methods_for_category("eligibility")
+ if len(methods) < 2:
+ self.skipTest("needs a category with more than one method")
+ wizard = self.wizard_model.with_context(default_category="eligibility").new(
+ {"program_id": self.program.id, "category": "eligibility", "method": methods[0][0]}
+ )
+ wizard._onchange_method_suggests_a_name()
+ self.assertEqual(wizard.name, methods[0][1])
+
+ # A name the user typed is left alone.
+ wizard.name = "Our own wording"
+ wizard.method = methods[1][0]
+ wizard._onchange_method_suggests_a_name()
+ self.assertEqual(wizard.name, "Our own wording")
+
+ def test_the_same_method_cannot_be_added_twice(self):
+ program = self.env["spp.program"].create({"name": "Twice [TEST]"})
+ self._add("eligibility", name="First", program=program)
+
+ with self.assertRaises(UserError):
+ self._add("eligibility", name="Second", program=program)
+
+ def test_a_method_can_be_added_again_after_it_is_removed(self):
+ """The ✕ on a Many2many row removes the relation, not the record.
+
+ The leftover kept its program_id, so the duplicate check used to refuse
+ a method the card no longer showed (OP#1171).
+ """
+ program = self.env["spp.program"].create({"name": "Re-add [TEST]"})
+ self._add("eligibility", name="First", program=program)
+ removed = program.eligibility_manager_ids
+ program.write({"eligibility_manager_ids": [(3, removed.id)]})
+
+ self._add("eligibility", name="Second", program=program)
+
+ self.assertEqual(len(program.eligibility_manager_ids), 1, "the method should be back")
+ self.assertFalse(removed.exists(), "the removed method should not linger")
+
+ def test_a_category_with_no_method_says_so(self):
+ """Notifications have no channel until a bridge module is installed."""
+ empty = [c for c in CONVERTED if not self.wizard_model._methods_for_category(c)]
+ if not empty:
+ self.skipTest("every category has a method installed")
+ with self.assertRaises(UserError):
+ self.program.with_context(manager_category=empty[0]).action_add_manager()
+
+ def test_add_is_refused_on_an_ended_program(self):
+ program = self.env["spp.program"].create({"name": "Ended [TEST]", "state": "ended"})
+ self.assertFalse(program.with_context(manager_category="eligibility").action_add_manager())
+
+ def test_the_dead_end_helper_now_opens_the_dialog(self):
+ """It used to pop "add a manager using the list below" — that list is gone."""
+ action = self.program._open_manager_setup_wizard("eligibility")
+
+ self.assertEqual(action.get("type"), "ir.actions.act_window")
+ self.assertEqual(action.get("res_model"), "spp.manager.setup.wizard")
+
+ # ------------------------------------------------------------------
+ # entitlements: one per program, for now (OP#1172 round 1)
+ # ------------------------------------------------------------------
+
+ def test_a_second_entitlement_method_is_refused_with_the_real_reason(self):
+ """QA asked for several cash entitlements; the engine allows one.
+
+ spp.program.check_managers_limit refuses a second entitlement manager,
+ and the cycle machinery reaches for exactly one — get_manager() calls
+ ensure_one(), and get_managers() raises NotImplementedError for this
+ kind. Accepting a second here would create a program every cycle
+ operation then failed on, so the dialog refuses and says why.
+
+ This test pins today's limit rather than blessing it: when the engine
+ learns to iterate entitlement managers, this is the test that changes.
+ """
+ program = self.env["spp.program"].create({"name": "One Entitlement [TEST]"})
+ self._add("entitlement", method="spp.program.entitlement.manager.cash", name="First cash", program=program)
+
+ with self.assertRaises(UserError) as cm:
+ self._add("entitlement", method="spp.program.entitlement.manager.cash", name="Second cash", program=program)
+ self.assertIn("supports one", str(cm.exception))
+
+ with self.assertRaises(UserError):
+ self._add("entitlement", method="spp.program.entitlement.manager.inkind", name="Goods", program=program)
+
+ self.assertEqual(len(program.entitlement_manager_ids), 1, "the program keeps the method it had")
+
+ def test_the_engine_still_reaches_for_exactly_one_entitlement_manager(self):
+ """Guards the reason above: if this stops being true, revisit the limit."""
+ program = self.env["spp.program"].create({"name": "Engine Assumption [TEST]"})
+ self._add("entitlement", method="spp.program.entitlement.manager.cash", name="Cash", program=program)
+
+ self.assertTrue(program.get_manager(program.MANAGER_ENTITLEMENT))
+ with self.assertRaises(NotImplementedError):
+ program.get_managers(program.MANAGER_ENTITLEMENT)
+
+ # ------------------------------------------------------------------
+ # isolation
+ # ------------------------------------------------------------------
+
+ def test_another_programs_method_cannot_be_linked_in(self):
+ owner = self.env["spp.program"].create({"name": "Owner [TEST]"})
+ self._add("eligibility", name="Owner's rule", program=owner)
+ borrower = self.env["spp.program"].create({"name": "Borrower [TEST]"})
+
+ with self.assertRaises(ValidationError):
+ borrower.write({"eligibility_manager_ids": [(4, owner.eligibility_manager_ids.id)]})
+
+ def test_another_programs_method_cannot_be_linked_at_creation(self):
+ owner = self.env["spp.program"].create({"name": "Owner At Create [TEST]"})
+ self._add("eligibility", name="Owner's rule", program=owner)
+
+ with self.assertRaises(ValidationError):
+ self.env["spp.program"].create(
+ {
+ "name": "Borrower At Create [TEST]",
+ "eligibility_manager_ids": [(4, owner.eligibility_manager_ids.id)],
+ }
+ )
+
+ def test_a_database_that_already_shares_one_stays_editable(self):
+ """Only what a write adds is checked.
+
+ Rejecting everything already linked would trap a database polluted by
+ the old picker: the ✕ is itself a write, and with two foreign methods
+ linked, removing one would be refused because of the other.
+ """
+ owner = self.env["spp.program"].create({"name": "Legacy Owner [TEST]"})
+ self._add("eligibility", name="First", program=owner)
+ self._add("cycle", name="Second", program=owner)
+ polluted = self.env["spp.program"].create({"name": "Legacy Borrower [TEST]"})
+ for field_name, wrapper in (
+ ("eligibility_manager_ids", owner.eligibility_manager_ids),
+ ("cycle_manager_ids", owner.cycle_manager_ids),
+ ):
+ field = self.env["spp.program"]._fields[field_name]
+ self.env.cr.execute(
+ f"INSERT INTO {field.relation} ({field.column1}, {field.column2}) VALUES (%s, %s)",
+ (polluted.id, wrapper.id),
+ )
+ polluted.invalidate_recordset()
+
+ # Taking one off is a write on a field that still holds the other.
+ polluted.write({"eligibility_manager_ids": [(3, owner.eligibility_manager_ids.id)]})
+
+ self.assertFalse(polluted.eligibility_manager_ids)
+ self.assertEqual(polluted.cycle_manager_ids, owner.cycle_manager_ids)
+
+ def test_duplicating_a_program_copies_its_configuration(self):
+ """A plain copy would link the source's methods into the duplicate."""
+ source = self.env["spp.program"].create({"name": "Source [TEST]"})
+ self._add("eligibility", name="Source rule", program=source)
+
+ duplicate = source.copy({"name": "Duplicate [TEST]"})
+
+ self.assertTrue(duplicate.eligibility_manager_ids, "the duplicate should be configured too")
+ self.assertNotEqual(
+ duplicate.eligibility_manager_ids,
+ source.eligibility_manager_ids,
+ "the duplicate must not share the source's method",
+ )
+ self.assertEqual(duplicate.eligibility_manager_ids.manager_ref_id.program_id, duplicate)
+ self.assertEqual(source.eligibility_manager_ids.manager_ref_id.name, "Source rule")
+
+ # ------------------------------------------------------------------
+ # the cards
+ # ------------------------------------------------------------------
+
+ def _arch(self):
+ return etree.fromstring(self.env.ref("spp_programs.view_program_form_config_cards").arch)
+
+ def test_every_card_offers_add(self):
+ arch = self._arch()
+ for category in CONVERTED:
+ with self.subTest(category=category):
+ buttons = arch.xpath(f"//button[@name='action_add_manager'][contains(@context, \"'{category}'\")]")
+ self.assertTrue(buttons, f"the {category} card needs an Add button")
+
+ def test_no_card_edits_the_reference_field_inline(self):
+ """The Reference field is what offered other programs' managers."""
+ arch = self._arch()
+ for category in CONVERTED:
+ field = MANAGER_CATEGORIES[category]["field"]
+ with self.subTest(category=category):
+ self.assertFalse(
+ arch.xpath(f"//field[@name='{field}']//field[@name='manager_ref_id']"),
+ f"{category} should list methods, not edit their Reference",
+ )
+
+ def test_no_card_offers_add_a_line(self):
+ """Denied through 'link' as well as 'create'.
+
+ These fields are Many2many, and for those the list renderer reads
+ `"link" in activeActions ? link : create`, so create="0" on the list
+ was never consulted and the row it left opened a picker listing every
+ program's managers.
+ """
+ arch = self._arch()
+ for category in CONVERTED:
+ field_name = MANAGER_CATEGORIES[category]["field"]
+ with self.subTest(category=category):
+ field = arch.xpath(f"//field[@name='{field_name}']")[0]
+ options = field.get("options") or ""
+ self.assertIn("'link'", options, "the link row is what a Many2many shows")
+ self.assertIn("'create'", options, "create must be denied too")
+ self.assertNotIn("'unlink'", options, "removing a method must stay possible")
+ self.assertEqual(field.xpath("./list")[0].get("create"), "0")
+
+ def test_edit_is_only_offered_when_there_is_one_method(self):
+ """One button cannot sensibly open two, and it used to open the first."""
+ arch = self._arch()
+ for category in CONVERTED:
+ count = f"{category}_manager_count"
+ with self.subTest(category=category):
+ edit = arch.xpath(f"//button[@name='action_configure_{category}'][contains(@class,'btn-primary')]")[0]
+ self.assertIn(count, edit.get("invisible") or "")
+
+ def test_notifications_is_a_card_like_the_rest(self):
+ """It was the last section still rendered as a bare group."""
+ headings = [h.strip() for h in self._arch().xpath("//div[contains(@class, 'card-header')]//h5/text()")]
+
+ self.assertIn("Notifications", headings, f"found {headings}")
+
+ def test_every_capped_category_is_declared_capped(self):
+ """MANAGER_CATEGORIES must agree with what the engine enforces.
+
+ check_managers_limit refuses a second entitlement, cycle, payment or
+ program manager. A category the engine caps but the dialog does not
+ know about accepts the second method and lets the constraint refuse it
+ afterwards, with wording this dialog exists to replace. Unreachable
+ while each of those categories has one concrete method in-repo, so this
+ pins the agreement rather than a behaviour anyone can trigger today.
+ """
+ import inspect
+
+ from odoo.addons.spp_programs.models import constants
+
+ source = inspect.getsource(type(self.env["spp.program"]).check_managers_limit)
+ capped_by_engine = {
+ key for key, info in constants.MANAGER_CATEGORIES.items() if f"len(record.{info['field']}) > 1" in source
+ }
+ declared = {key for key, info in constants.MANAGER_CATEGORIES.items() if info.get("single_manager")}
+
+ self.assertEqual(
+ declared,
+ capped_by_engine,
+ "single_manager and check_managers_limit disagree about which categories take only one manager",
+ )
diff --git a/spp_programs/views/cel/entitlement_amount_cel_views.xml b/spp_programs/views/cel/entitlement_amount_cel_views.xml
index cada7d545..dd3e693f9 100644
--- a/spp_programs/views/cel/entitlement_amount_cel_views.xml
+++ b/spp_programs/views/cel/entitlement_amount_cel_views.xml
@@ -34,13 +34,26 @@
-
+
- 1
+ Base Amount0
+ Optional. Formulas can build on this value as base_amount.
@@ -49,15 +62,41 @@
position="after"
>
+
+
@@ -47,24 +66,49 @@ Replaces the technical manager configuration with intuitive sections.
>
Configured
+
+
+
+
+ No eligibility method configured — click Add above to choose who qualifies for this program.
+
-
+
-
+
+
+
+ No entitlement type configured — click Add above to choose what beneficiaries receive.
+
-
+
-
+
Configured
+
+ AddEditView
+
+
+ No schedule configured — click Add above to choose how often this program runs.
+
+ Send SMS or other notifications to beneficiaries
+
+
+
+
+ Configured
+
+
+ Add
+ Edit
+ View
+
+
+
+
+
+
+
+
+
+
+ No outgoing mail server is configured. Ask your administrator to set one up under
+ Settings → Technical → Email → Outgoing Mail Servers before enabling email
+ notifications.
+
+
+
+ No notification channel configured. It is optional — click Add above to message beneficiaries.
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- 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.
-
-
-
-
-
-
-
-
-
-
-
diff --git a/spp_programs/wizard/__init__.py b/spp_programs/wizard/__init__.py
index 252be2a3f..c97cf73f8 100644
--- a/spp_programs/wizard/__init__.py
+++ b/spp_programs/wizard/__init__.py
@@ -16,3 +16,4 @@
from . import enrollment_wizard
from . import exit_membership_wizard
from . import prepare_entitlement_confirm_wizard
+from . import manager_setup_wizard
diff --git a/spp_programs/wizard/manager_setup_wizard.py b/spp_programs/wizard/manager_setup_wizard.py
new file mode 100644
index 000000000..9bb209dbc
--- /dev/null
+++ b/spp_programs/wizard/manager_setup_wizard.py
@@ -0,0 +1,210 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""One dialog for adding any program configuration method (OP#1172).
+
+Every card on a program's Configuration tab used to be filled in the same way:
+an inline list with a `manager_ref_id` Reference field and an "Add a line" row.
+That control asks the user to pick a *model* and then find or create a record
+of it, and both halves of it leak — the Reference picker and, on the Many2many
+cards, the link dialog behind "Add a line" both list managers belonging to
+other programs, which silently wires another program's configuration into this
+one.
+
+This wizard asks the two questions that actually matter — which method, and
+what to call it — and creates a record that belongs to this program only. The
+methods on offer come from the wrapper's ``_selection_manager_ref_id()``, so a
+module that adds a method (spp_program_geofence adds an eligibility one) shows
+up here without touching this file.
+"""
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+from ..models.constants import MANAGER_CATEGORIES
+from ..models.program_manager_ui import MANAGER_TYPE_INFO
+
+
+class ManagerSetupWizard(models.TransientModel):
+ _name = "spp.manager.setup.wizard"
+ _description = "Add a Configuration Method"
+
+ program_id = fields.Many2one(
+ "spp.program",
+ required=True,
+ readonly=True,
+ )
+ category = fields.Selection(
+ selection=[(key, info["label"]) for key, info in MANAGER_CATEGORIES.items()],
+ required=True,
+ readonly=True,
+ help="Which card on the Configuration tab this method belongs to.",
+ )
+ method = fields.Selection(
+ selection="_selection_method",
+ string="Method",
+ required=True,
+ help="How this part of the program is handled. Each method can be added once per program.",
+ )
+ method_description = fields.Char(compute="_compute_method_description")
+ # Drives whether the Method question is asked at all: a category with one
+ # method has nothing to choose, and a radio list of one is just noise.
+ method_count = fields.Integer(compute="_compute_method_count")
+ name = fields.Char(
+ string="Name",
+ required=True,
+ help="Shown on the program's configuration page.",
+ )
+
+ # ------------------------------------------------------------------
+ # the methods on offer
+ # ------------------------------------------------------------------
+
+ @api.model
+ def _methods_for_category(self, category):
+ """The concrete manager models a category can offer, as selection pairs.
+
+ Read from the wrapper rather than from a list here so that methods
+ added by other modules are included, and so that a method whose module
+ has been uninstalled drops out instead of raising when it is picked.
+ MANAGER_TYPE_INFO only supplies nicer wording where it has some.
+ """
+ info = MANAGER_CATEGORIES.get(category)
+ if not info or info["wrapper"] not in self.env:
+ return []
+ methods = []
+ for model, label in self.env[info["wrapper"]]._selection_manager_ref_id():
+ if model in self.env:
+ methods.append((model, MANAGER_TYPE_INFO.get(model, {}).get("name") or label))
+ return methods
+
+ @api.model
+ def _selection_method(self):
+ """Selection values for the Method field.
+
+ A Selection cannot depend on another field's value, so the category
+ comes from the context the Add button opens this dialog with.
+ """
+ return self._methods_for_category(self.env.context.get("default_category"))
+
+ @api.depends("method")
+ def _compute_method_description(self):
+ for wizard in self:
+ wizard.method_description = MANAGER_TYPE_INFO.get(wizard.method, {}).get("description", "")
+
+ @api.depends("category")
+ def _compute_method_count(self):
+ for wizard in self:
+ wizard.method_count = len(self._methods_for_category(wizard.category))
+
+ @api.onchange("method")
+ def _onchange_method_suggests_a_name(self):
+ """Pre-fill the name from the method, so naming is one keystroke.
+
+ Only while the user has not typed their own, and only replacing a
+ suggestion we made ourselves.
+ """
+ labels = dict(self._methods_for_category(self.category))
+ if not self.name or self.name in set(labels.values()):
+ self.name = labels.get(self.method, "")
+
+ # ------------------------------------------------------------------
+ # creating the method
+ # ------------------------------------------------------------------
+
+ def _sweep_removed_methods(self):
+ """Delete the methods the card no longer shows.
+
+ Most of these program fields are Many2many, so the ✕ on a row removes
+ the *relation* and leaves the manager behind with its ``program_id``
+ still pointing here. Those leftovers never run — a program is
+ configured through its own field, not through the managers'
+ ``program_id`` — but they used to make the duplicate check below refuse
+ a method the card no longer showed (OP#1171).
+
+ Only managers that no program links are swept: on a Many2many, one this
+ program created but another program links is that program's method now,
+ not garbage.
+ """
+ self.ensure_one()
+ field = MANAGER_CATEGORIES[self.category]["field"]
+ wrapper = MANAGER_CATEGORIES[self.category]["wrapper"]
+ removed = self.env[wrapper].search([("program_id", "=", self.program_id.id)]) - self.program_id[field]
+ if not removed:
+ return
+ linked = self.env["spp.program"].search([(field, "in", removed.ids)])
+ for leftover in removed - linked[field]:
+ # The concrete record owns the wrapper: spp.manager.source.mixin's
+ # unlink() takes the wrapper with it. manager_ref_id is a Reference,
+ # so it carries no foreign key and can outlive what it points at —
+ # unlinking that blind would raise MissingError on the Add button.
+ concrete = leftover.manager_ref_id
+ ((concrete and concrete.exists()) or leftover).unlink()
+
+ def action_create_manager(self):
+ """Create the concrete manager; the wrapper follows automatically.
+
+ ``spp.manager.source.mixin.create`` builds the wrapper when it sees
+ ``_spp_wrapper_model`` in the context, so this creates one record and
+ gets both — and dismissing the dialog leaves nothing behind (#953).
+
+ ``_spp_program_m2m_field`` matters as much as the wrapper model on the
+ Many2many cards: unlike a One2many they do not resolve from the
+ wrapper's ``program_id``, so without it the manager is created and the
+ program never picks it up — the card keeps saying nothing is configured
+ and the method never runs.
+ """
+ self.ensure_one()
+ self._sweep_removed_methods()
+
+ info = MANAGER_CATEGORIES[self.category]
+ field = info["field"]
+ labels = dict(self._methods_for_category(self.category))
+ configured = self.program_id[field].filtered(lambda wrapper: wrapper.manager_ref_id)
+
+ if info.get("single_manager") and configured:
+ # Say what the limit actually is. This is not a duplicate rule: a
+ # program supports one entitlement method whatever its kind, because
+ # the cycle machinery reaches for exactly one (OP#1172 round 1).
+ raise UserError(
+ _(
+ "%(program)s already has a %(category)s: %(existing)s. A program supports one "
+ "for now — change that one, or remove it before adding another."
+ )
+ % {
+ "program": self.program_id.display_name,
+ "category": info["label"].lower(),
+ "existing": ", ".join(configured.mapped("display_name")),
+ }
+ )
+
+ if configured.filtered(lambda wrapper: wrapper.manager_ref_id._name == self.method):
+ raise UserError(
+ _("This program already has a %(method)s %(category)s.")
+ % {"method": labels.get(self.method, self.method), "category": info["label"].lower()}
+ )
+
+ context = {
+ "default_program_id": self.program_id.id,
+ "_spp_wrapper_model": MANAGER_CATEGORIES[self.category]["wrapper"],
+ }
+ if self.env["spp.program"]._fields[field].type == "many2many":
+ context["_spp_program_m2m_field"] = field
+ concrete = (
+ self.env[self.method]
+ .with_context(**context)
+ .create(
+ {
+ "name": self.name,
+ "program_id": self.program_id.id,
+ }
+ )
+ )
+ wrapper = self.env[MANAGER_CATEGORIES[self.category]["wrapper"]].search(
+ [("manager_ref_id", "=", f"{concrete._name},{concrete.id}")],
+ limit=1,
+ )
+ if wrapper:
+ # Land on the method's own form rather than back on the card with
+ # something unconfigured and a cog to discover. Eligibility filters,
+ # entitlement amounts and compliance criteria all live there.
+ return wrapper.open_manager_form(title=MANAGER_CATEGORIES[self.category]["label"])
+ return {"type": "ir.actions.act_window_close"}
diff --git a/spp_programs/wizard/manager_setup_wizard.xml b/spp_programs/wizard/manager_setup_wizard.xml
new file mode 100644
index 000000000..8acf617a7
--- /dev/null
+++ b/spp_programs/wizard/manager_setup_wizard.xml
@@ -0,0 +1,59 @@
+
+
+
+
+ spp.manager.setup.wizard.form
+ spp.manager.setup.wizard
+
+
+
+
+