Skip to content

Commit e729c6c

Browse files
committed
feat(spp_programs): show cycle compliance status on registrant form
Add visibility into cycle-level compliance status from the registrant form. Users can now see at a glance which programs have non-compliant cycles and drill down to the specific cycle details. - Add latest_cycle_state computed field on spp.program.membership showing the most recent cycle state per registrant+program - Add "View Cycles" button on program membership rows opening filtered cycle memberships for that registrant+program - Add compliance_criteria computed field on spp.cycle.membership showing the program CEL expression when non-compliant - Add cycle_membership_count and non_compliant_cycle_count on res.partner with smart button - Add Non-Compliant filter to cycle membership search view Closes #860
1 parent f738582 commit e729c6c

5 files changed

Lines changed: 198 additions & 1 deletion

File tree

spp_programs/models/cycle_membership.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ class SPPCycleMembership(models.Model):
1616
partner_id = fields.Many2one("res.partner", "Registrant", help="A beneficiary", required=True, index=True)
1717
cycle_id = fields.Many2one("spp.cycle", "Cycle", help="A cycle", required=True, index=True)
1818
enrollment_date = fields.Date(default=lambda self: fields.Datetime.now())
19+
20+
compliance_criteria = fields.Char(
21+
string="Compliance Criteria",
22+
compute="_compute_compliance_criteria",
23+
help="The compliance CEL expression from the program that this registrant failed to meet",
24+
)
25+
1926
state = fields.Selection(
2027
selection=[
2128
("draft", "Draft"),
@@ -29,6 +36,21 @@ class SPPCycleMembership(models.Model):
2936
copy=False,
3037
)
3138

39+
def _compute_compliance_criteria(self):
40+
"""Show the compliance CEL expression from the program when non-compliant."""
41+
for rec in self:
42+
if rec.state == "non_compliant" and rec.cycle_id and rec.cycle_id.program_id:
43+
program = rec.cycle_id.program_id
44+
for wrapper in program.compliance_manager_ids:
45+
concrete = wrapper.manager_ref_id
46+
if hasattr(concrete, "compliance_cel_expression") and concrete.compliance_cel_expression:
47+
rec.compliance_criteria = concrete.compliance_cel_expression
48+
break
49+
else:
50+
rec.compliance_criteria = False
51+
else:
52+
rec.compliance_criteria = False
53+
3254
def _compute_display_name(self):
3355
res = super()._compute_display_name()
3456
# 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
@@ -84,6 +84,56 @@ def _compute_duplicate_reason(self):
8484
else:
8585
rec.duplicate_reason = False
8686

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

spp_programs/models/registrant.py

Lines changed: 57 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."""
@@ -144,6 +189,18 @@ def action_view_program_memberships(self):
144189
"context": {"default_partner_id": self.id},
145190
}
146191

192+
def action_view_cycle_memberships(self):
193+
"""Open cycle memberships for this registrant."""
194+
self.ensure_one()
195+
return {
196+
"name": _("Cycle Memberships - %s") % self.name,
197+
"type": "ir.actions.act_window",
198+
"res_model": "spp.cycle.membership",
199+
"view_mode": "list,form",
200+
"domain": [("partner_id", "=", self.id)],
201+
"context": {"default_partner_id": self.id},
202+
}
203+
147204
def action_view_all_entitlements(self):
148205
"""Open all entitlements (cash + in-kind) for this registrant."""
149206
self.ensure_one()

spp_programs/views/cycle_membership_view.xml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2323
decoration-primary="state=='draft'"
2424
decoration-warning="state=='paused'"
2525
decoration-success="state=='enrolled'"
26-
decoration-danger="state=='exited'"
26+
decoration-danger="state in ('exited', 'non_compliant')"
2727
widget="badge"
2828
/>
29+
<field
30+
name="compliance_criteria"
31+
string="Compliance Criteria"
32+
optional="show"
33+
/>
2934
</list>
3035
</field>
3136
</record>
@@ -124,6 +129,11 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
124129
string="Not Eligible"
125130
domain="[('state','=','not_eligible')]"
126131
/>
132+
<filter
133+
name="non_compliant"
134+
string="Non-Compliant"
135+
domain="[('state','=','non_compliant')]"
136+
/>
127137
<separator />
128138
<filter
129139
string="Enrollment Date"

spp_programs/views/registrant_view.xml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
3939
string="Entitlements"
4040
/>
4141
</button>
42+
<button
43+
name="action_view_cycle_memberships"
44+
type="object"
45+
class="oe_stat_button"
46+
icon="fa-refresh"
47+
invisible="not is_registrant or cycle_membership_count == 0"
48+
groups="spp_programs.group_programs_viewer"
49+
>
50+
<div class="o_stat_info">
51+
<field name="cycle_membership_count" class="o_stat_value" />
52+
<span class="o_stat_text">Cycles</span>
53+
</div>
54+
<field
55+
name="non_compliant_cycle_count"
56+
invisible="non_compliant_cycle_count == 0"
57+
widget="statinfo"
58+
string="Non-Compliant"
59+
class="text-danger"
60+
/>
61+
</button>
4262
</xpath>
4363

4464
<!-- Programs Section in Individual Participation Tab -->
@@ -68,6 +88,7 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
6888
decoration-muted="state in ('exited', 'not_eligible')"
6989
decoration-warning="state == 'paused'"
7090
decoration-success="state == 'enrolled'"
91+
decoration-danger="latest_cycle_state == 'non_compliant'"
7192
>
7293
<field name="program_id" />
7394
<field name="state" widget="badge" />
@@ -94,6 +115,24 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
94115
groups="spp_programs.group_programs_officer"
95116
confirm="Are you sure you want to exit this registrant from the program?"
96117
/>
118+
<field
119+
name="latest_cycle_state"
120+
string="Latest Cycle Status"
121+
widget="badge"
122+
decoration-danger="latest_cycle_state == 'non_compliant'"
123+
decoration-success="latest_cycle_state == 'enrolled'"
124+
decoration-warning="latest_cycle_state == 'paused'"
125+
decoration-muted="latest_cycle_state in ('exited', 'not_eligible')"
126+
optional="show"
127+
/>
128+
<button
129+
name="action_view_cycle_memberships"
130+
type="object"
131+
string="View Cycles"
132+
class="btn-link"
133+
icon="fa-list"
134+
groups="spp_programs.group_programs_viewer"
135+
/>
97136
</list>
98137
</field>
99138
<div invisible="program_membership_count != 0" class="text-muted small">
@@ -188,6 +227,7 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
188227
decoration-muted="state in ('exited', 'not_eligible')"
189228
decoration-warning="state == 'paused'"
190229
decoration-success="state == 'enrolled'"
230+
decoration-danger="latest_cycle_state == 'non_compliant'"
191231
>
192232
<field name="program_id" />
193233
<field name="state" widget="badge" />
@@ -214,6 +254,24 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
214254
groups="spp_programs.group_programs_officer"
215255
confirm="Are you sure you want to exit this registrant from the program?"
216256
/>
257+
<field
258+
name="latest_cycle_state"
259+
string="Latest Cycle Status"
260+
widget="badge"
261+
decoration-danger="latest_cycle_state == 'non_compliant'"
262+
decoration-success="latest_cycle_state == 'enrolled'"
263+
decoration-warning="latest_cycle_state == 'paused'"
264+
decoration-muted="latest_cycle_state in ('exited', 'not_eligible')"
265+
optional="show"
266+
/>
267+
<button
268+
name="action_view_cycle_memberships"
269+
type="object"
270+
string="View Cycles"
271+
class="btn-link"
272+
icon="fa-list"
273+
groups="spp_programs.group_programs_viewer"
274+
/>
217275
</list>
218276
</field>
219277
<div invisible="program_membership_count != 0" class="text-muted small">

0 commit comments

Comments
 (0)