Skip to content

Commit 5f420e8

Browse files
authored
Merge pull request #152 from OpenSPP/feat/cycle-compliance-on-registrant
feat(spp_programs): show cycle compliance status on registrant form
2 parents b061135 + 82c6329 commit 5f420e8

8 files changed

Lines changed: 444 additions & 5 deletions

spp_programs/models/cycle_membership.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,20 @@ class SPPCycleMembership(models.Model):
1919

2020
partner_id = fields.Many2one("res.partner", "Registrant", help="A beneficiary", required=True, index=True)
2121
cycle_id = fields.Many2one("spp.cycle", "Cycle", help="A cycle", required=True, index=True)
22+
program_id = fields.Many2one(
23+
related="cycle_id.program_id",
24+
string="Program",
25+
store=True,
26+
index=True,
27+
)
2228
enrollment_date = fields.Date(default=lambda self: fields.Datetime.now())
29+
30+
compliance_criteria = fields.Char(
31+
string="Compliance Criteria",
32+
compute="_compute_compliance_criteria",
33+
help="The compliance CEL expression from the program that this registrant failed to meet",
34+
)
35+
2336
state = fields.Selection(
2437
selection=[
2538
("draft", "Draft"),
@@ -33,6 +46,21 @@ class SPPCycleMembership(models.Model):
3346
copy=False,
3447
)
3548

49+
def _compute_compliance_criteria(self):
50+
"""Show the compliance CEL expression from the program when non-compliant."""
51+
for rec in self:
52+
if rec.state == "non_compliant" and rec.cycle_id and rec.cycle_id.program_id:
53+
program = rec.cycle_id.program_id
54+
for wrapper in program.compliance_manager_ids:
55+
concrete = wrapper.manager_ref_id
56+
if hasattr(concrete, "compliance_cel_expression") and concrete.compliance_cel_expression:
57+
rec.compliance_criteria = concrete.compliance_cel_expression
58+
break
59+
else:
60+
rec.compliance_criteria = False
61+
else:
62+
rec.compliance_criteria = False
63+
3664
def _compute_display_name(self):
3765
res = super()._compute_display_name()
3866
# Prefetch cycle_id and partner_id to avoid N+1 queries in loop

spp_programs/models/program_membership.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,56 @@ def _compute_duplicate_reason(self):
8787
else:
8888
rec.duplicate_reason = False
8989

90+
latest_cycle_state = fields.Selection(
91+
selection=[
92+
("draft", "Draft"),
93+
("enrolled", "Enrolled"),
94+
("paused", "Paused"),
95+
("exited", "Exited"),
96+
("not_eligible", "Not Eligible"),
97+
("non_compliant", "Non-Compliant"),
98+
],
99+
string="Cycle Status",
100+
compute="_compute_latest_cycle_state",
101+
help="State of the most recent cycle membership for this registrant in this program",
102+
)
103+
104+
def _compute_latest_cycle_state(self):
105+
"""Get the latest cycle membership state per registrant+program."""
106+
if not self:
107+
return
108+
109+
for rec in self:
110+
rec.latest_cycle_state = False
111+
112+
# Batch query: find latest cycle membership for each program membership
113+
CycleMembership = self.env["spp.cycle.membership"]
114+
for rec in self:
115+
cycle_mem = CycleMembership.search(
116+
[
117+
("partner_id", "=", rec.partner_id.id),
118+
("cycle_id.program_id", "=", rec.program_id.id),
119+
],
120+
order="id desc",
121+
limit=1,
122+
)
123+
if cycle_mem:
124+
rec.latest_cycle_state = cycle_mem.state
125+
126+
def action_view_cycle_memberships(self):
127+
"""Open cycle memberships for this registrant in this program."""
128+
self.ensure_one()
129+
return {
130+
"name": _("%s — Cycles") % self.program_id.name,
131+
"type": "ir.actions.act_window",
132+
"res_model": "spp.cycle.membership",
133+
"view_mode": "list,form",
134+
"domain": [
135+
("partner_id", "=", self.partner_id.id),
136+
("cycle_id.program_id", "=", self.program_id.id),
137+
],
138+
}
139+
90140
# TODO: Implement exit reasons
91141
# exit_reason_id = fields.Many2one("Exit Reason") Default: Completed, Opt-Out, Other
92142

spp_programs/models/registrant.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ class SPPRegistrant(models.Model):
1717
inkind_entitlement_ids = fields.One2many("spp.entitlement.inkind", "partner_id", "In-kind Entitlements")
1818

1919
# Statistics
20+
cycle_membership_count = fields.Integer(
21+
string="# Cycles",
22+
compute="_compute_cycle_membership_count",
23+
store=True,
24+
)
25+
non_compliant_cycle_count = fields.Integer(
26+
string="# Non-Compliant",
27+
compute="_compute_cycle_membership_count",
28+
store=True,
29+
)
30+
2031
program_membership_count = fields.Integer(
2132
string="# Program Memberships",
2233
compute="_compute_program_membership_count",
@@ -34,6 +45,40 @@ class SPPRegistrant(models.Model):
3445
compute="_compute_total_entitlements_count",
3546
)
3647

48+
@api.depends("cycle_ids", "cycle_ids.state")
49+
def _compute_cycle_membership_count(self):
50+
"""Batch-efficient cycle membership and non-compliant counts."""
51+
if not self:
52+
return
53+
54+
registrants = self.filtered("is_registrant")
55+
for partner in self - registrants:
56+
partner.cycle_membership_count = 0
57+
partner.non_compliant_cycle_count = 0
58+
59+
if not registrants:
60+
return
61+
62+
# Total cycle memberships
63+
total_data = self.env["spp.cycle.membership"]._read_group(
64+
domain=[("partner_id", "in", registrants.ids)],
65+
groupby=["partner_id"],
66+
aggregates=["__count"],
67+
)
68+
total_counts = {partner.id: count for partner, count in total_data}
69+
70+
# Non-compliant count
71+
nc_data = self.env["spp.cycle.membership"]._read_group(
72+
domain=[("partner_id", "in", registrants.ids), ("state", "=", "non_compliant")],
73+
groupby=["partner_id"],
74+
aggregates=["__count"],
75+
)
76+
nc_counts = {partner.id: count for partner, count in nc_data}
77+
78+
for partner in registrants:
79+
partner.cycle_membership_count = total_counts.get(partner.id, 0)
80+
partner.non_compliant_cycle_count = nc_counts.get(partner.id, 0)
81+
3782
@api.depends("entitlements_count", "inkind_entitlements_count")
3883
def _compute_total_entitlements_count(self):
3984
"""Compute combined count of cash and in-kind entitlements."""
@@ -152,6 +197,33 @@ def action_view_program_memberships(self):
152197
"context": {"default_partner_id": self.id},
153198
}
154199

200+
def action_view_cycle_memberships(self):
201+
"""Open cycle memberships for this registrant."""
202+
self.ensure_one()
203+
return {
204+
"name": _("Cycle Memberships - %s") % self.name,
205+
"type": "ir.actions.act_window",
206+
"res_model": "spp.cycle.membership",
207+
"view_mode": "list,form",
208+
"domain": [("partner_id", "=", self.id)],
209+
"context": {"create": False},
210+
}
211+
212+
def action_view_non_compliant_cycles(self):
213+
"""Open non-compliant cycle memberships for this registrant."""
214+
self.ensure_one()
215+
return {
216+
"name": _("Non-Compliant Cycles - %s") % self.name,
217+
"type": "ir.actions.act_window",
218+
"res_model": "spp.cycle.membership",
219+
"view_mode": "list,form",
220+
"domain": [
221+
("partner_id", "=", self.id),
222+
("state", "=", "non_compliant"),
223+
],
224+
"context": {"create": False},
225+
}
226+
155227
def action_view_all_entitlements(self):
156228
"""Open all entitlements (cash + in-kind) for this registrant."""
157229
self.ensure_one()

spp_programs/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from . import test_payment_and_accounting
3333
from . import test_managers
3434
from . import test_cycle_auto_approve_fund_check
35+
from . import test_cycle_compliance_on_registrant
3536
from . import test_bulk_membership
3637
from . import test_keyset_pagination
3738
from . import test_canary_patterns

0 commit comments

Comments
 (0)