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 @@

Changelog

+

19.0.2.4.0

+ +
+

19.0.2.3.4

-
+

19.0.2.3.3

-
+

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)
-
+

19.0.2.1.3

  • fix(security): align Program Viewer / Validator / Cycle Approver roles @@ -767,7 +797,7 @@

    19.0.2.1.3

    cross-references — only the dedicated top-level menu disappears.
-
+

19.0.2.1.2

  • fix(security): add global ir.rule records on @@ -781,7 +811,7 @@

    19.0.2.1.2

    no-op for users with no center areas (global roles).
-
+

19.0.2.1.1

  • fix(views): apply spp_registry.x2many_no_padding widget to the @@ -790,7 +820,7 @@

    19.0.2.1.1

    19 inserts on inline list-in-form views (#943).
-
+

19.0.2.0.11

  • Fix TypeError: 'NoneType' object is not iterable when clicking @@ -801,7 +831,7 @@

    19.0.2.0.11

    omit the state filter instead of crashing on tuple(None)
-
+

19.0.2.0.10

  • Increase parallel-safe channel limits (cycle, eligibility_manager, @@ -814,7 +844,7 @@

    19.0.2.0.10

    submission on double-click
-
+

19.0.2.0.9

  • Add context flags (skip_registrant_statistics, @@ -827,7 +857,7 @@

    19.0.2.0.9

    _compute_has_members
-
+

19.0.2.0.8

  • Replace OFFSET pagination with NTILE-based ID-range batching in all @@ -838,7 +868,7 @@

    19.0.2.0.8

    program and cycle
-
+

19.0.2.0.7

  • Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING @@ -847,7 +877,7 @@

    19.0.2.0.7

    _add_beneficiaries with bulk SQL path
-
+

19.0.2.0.6

  • Remove unused entitlement_base_model.py (dead code, never imported)
  • @@ -856,34 +886,34 @@

    19.0.2.0.6

    payment, and fund tests (172 → 492 tests)
-
+

19.0.2.0.5

  • Batch create entitlements and payments instead of one-by-one ORM creates
-
+

19.0.2.0.4

  • Fetch fund balance once per approval batch instead of per entitlement
-
+

19.0.2.0.3

  • Replace cycle computed fields (total_amount, entitlements_count, approval flags) with SQL aggregation queries
-
+

19.0.2.0.2

  • 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 Amount 0 + 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. +
- + - +
+
+ + No schedule configured — click Add above to choose how often this program runs. +
- + - - + +
@@ -405,24 +503,23 @@ Replaces the technical manager configuration with intuitive sections. placeholder="No compliance rule configured yet — click Edit above." /> - + - +
@@ -516,24 +623,23 @@ Replaces the technical manager configuration with intuitive sections. placeholder="No payment processing configured yet — click Edit above." /> - + - +
+ +
+
+
+ +
+
Notifications
+ Send SMS or other notifications to beneficiaries +
+
+
+ + Configured + + + + + +
+
+
+ + + + + +
+ + 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. -
- - - - -