Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spp_programs/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,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": [
Expand Down
53 changes: 53 additions & 0 deletions spp_programs/models/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,56 @@
"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",
},
"cycle": {
"field": "cycle_manager_ids",
"wrapper": "spp.cycle.manager",
"label": "Cycle Schedule",
},
"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",
},
"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",
},
}
124 changes: 59 additions & 65 deletions spp_programs/models/program_manager_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -241,6 +244,12 @@ 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")

@api.depends("eligibility_manager_ids", "eligibility_manager_ids.manager_ref_id")
def _compute_eligibility_summary(self):
for rec in self:
Expand Down Expand Up @@ -403,6 +412,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",
)
def _compute_banner_layout_helpers(self):
"""Populate the `<banner>_manager_count / _display / _detail` fields
Expand All @@ -413,6 +424,7 @@ def _compute_banner_layout_helpers(self):
("cycle_manager_ids", "cycle"),
("compliance_manager_ids", "compliance"),
("payment_manager_ids", "payment"),
("notification_manager_ids", "notification"),
)
for rec in self:
for field_name, prefix in banners:
Expand Down Expand Up @@ -624,86 +636,68 @@ 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 _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."""
Expand Down
96 changes: 95 additions & 1 deletion spp_programs/models/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging

from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.exceptions import UserError, ValidationError

from . import constants

Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -276,6 +347,13 @@ def _compute_can_edit_configuration(self):
@api.model
def create(self, vals):
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"):
Expand All @@ -285,6 +363,22 @@ 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).

Only the links this write adds are checked, so a database that already
holds a cross-program link stays editable and the link can be removed.
"""
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 = {}
Expand Down
3 changes: 3 additions & 0 deletions spp_programs/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,6 @@ 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_validator,Manager Setup Wizard Validator Access,spp_programs.model_spp_manager_setup_wizard,group_programs_validator,1,1,1,0
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
1 change: 1 addition & 0 deletions spp_programs/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@
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
Loading
Loading