diff --git a/spp_programs/models/cycle_membership.py b/spp_programs/models/cycle_membership.py
index 4fdb0bf6..374bdfa8 100644
--- a/spp_programs/models/cycle_membership.py
+++ b/spp_programs/models/cycle_membership.py
@@ -19,7 +19,20 @@ class SPPCycleMembership(models.Model):
partner_id = fields.Many2one("res.partner", "Registrant", help="A beneficiary", required=True, index=True)
cycle_id = fields.Many2one("spp.cycle", "Cycle", help="A cycle", required=True, index=True)
+ program_id = fields.Many2one(
+ related="cycle_id.program_id",
+ string="Program",
+ store=True,
+ index=True,
+ )
enrollment_date = fields.Date(default=lambda self: fields.Datetime.now())
+
+ compliance_criteria = fields.Char(
+ string="Compliance Criteria",
+ compute="_compute_compliance_criteria",
+ help="The compliance CEL expression from the program that this registrant failed to meet",
+ )
+
state = fields.Selection(
selection=[
("draft", "Draft"),
@@ -33,6 +46,21 @@ class SPPCycleMembership(models.Model):
copy=False,
)
+ def _compute_compliance_criteria(self):
+ """Show the compliance CEL expression from the program when non-compliant."""
+ for rec in self:
+ if rec.state == "non_compliant" and rec.cycle_id and rec.cycle_id.program_id:
+ program = rec.cycle_id.program_id
+ for wrapper in program.compliance_manager_ids:
+ concrete = wrapper.manager_ref_id
+ if hasattr(concrete, "compliance_cel_expression") and concrete.compliance_cel_expression:
+ rec.compliance_criteria = concrete.compliance_cel_expression
+ break
+ else:
+ rec.compliance_criteria = False
+ else:
+ rec.compliance_criteria = False
+
def _compute_display_name(self):
res = super()._compute_display_name()
# Prefetch cycle_id and partner_id to avoid N+1 queries in loop
diff --git a/spp_programs/models/program_membership.py b/spp_programs/models/program_membership.py
index 985fd31d..7e93543f 100644
--- a/spp_programs/models/program_membership.py
+++ b/spp_programs/models/program_membership.py
@@ -87,6 +87,56 @@ def _compute_duplicate_reason(self):
else:
rec.duplicate_reason = False
+ latest_cycle_state = fields.Selection(
+ selection=[
+ ("draft", "Draft"),
+ ("enrolled", "Enrolled"),
+ ("paused", "Paused"),
+ ("exited", "Exited"),
+ ("not_eligible", "Not Eligible"),
+ ("non_compliant", "Non-Compliant"),
+ ],
+ string="Cycle Status",
+ compute="_compute_latest_cycle_state",
+ help="State of the most recent cycle membership for this registrant in this program",
+ )
+
+ def _compute_latest_cycle_state(self):
+ """Get the latest cycle membership state per registrant+program."""
+ if not self:
+ return
+
+ for rec in self:
+ rec.latest_cycle_state = False
+
+ # Batch query: find latest cycle membership for each program membership
+ CycleMembership = self.env["spp.cycle.membership"]
+ for rec in self:
+ cycle_mem = CycleMembership.search(
+ [
+ ("partner_id", "=", rec.partner_id.id),
+ ("cycle_id.program_id", "=", rec.program_id.id),
+ ],
+ order="id desc",
+ limit=1,
+ )
+ if cycle_mem:
+ rec.latest_cycle_state = cycle_mem.state
+
+ def action_view_cycle_memberships(self):
+ """Open cycle memberships for this registrant in this program."""
+ self.ensure_one()
+ return {
+ "name": _("%s — Cycles") % self.program_id.name,
+ "type": "ir.actions.act_window",
+ "res_model": "spp.cycle.membership",
+ "view_mode": "list,form",
+ "domain": [
+ ("partner_id", "=", self.partner_id.id),
+ ("cycle_id.program_id", "=", self.program_id.id),
+ ],
+ }
+
# TODO: Implement exit reasons
# exit_reason_id = fields.Many2one("Exit Reason") Default: Completed, Opt-Out, Other
diff --git a/spp_programs/models/registrant.py b/spp_programs/models/registrant.py
index 20a8cd31..c1232472 100644
--- a/spp_programs/models/registrant.py
+++ b/spp_programs/models/registrant.py
@@ -17,6 +17,17 @@ class SPPRegistrant(models.Model):
inkind_entitlement_ids = fields.One2many("spp.entitlement.inkind", "partner_id", "In-kind Entitlements")
# Statistics
+ cycle_membership_count = fields.Integer(
+ string="# Cycles",
+ compute="_compute_cycle_membership_count",
+ store=True,
+ )
+ non_compliant_cycle_count = fields.Integer(
+ string="# Non-Compliant",
+ compute="_compute_cycle_membership_count",
+ store=True,
+ )
+
program_membership_count = fields.Integer(
string="# Program Memberships",
compute="_compute_program_membership_count",
@@ -34,6 +45,40 @@ class SPPRegistrant(models.Model):
compute="_compute_total_entitlements_count",
)
+ @api.depends("cycle_ids", "cycle_ids.state")
+ def _compute_cycle_membership_count(self):
+ """Batch-efficient cycle membership and non-compliant counts."""
+ if not self:
+ return
+
+ registrants = self.filtered("is_registrant")
+ for partner in self - registrants:
+ partner.cycle_membership_count = 0
+ partner.non_compliant_cycle_count = 0
+
+ if not registrants:
+ return
+
+ # Total cycle memberships
+ total_data = self.env["spp.cycle.membership"]._read_group(
+ domain=[("partner_id", "in", registrants.ids)],
+ groupby=["partner_id"],
+ aggregates=["__count"],
+ )
+ total_counts = {partner.id: count for partner, count in total_data}
+
+ # Non-compliant count
+ nc_data = self.env["spp.cycle.membership"]._read_group(
+ domain=[("partner_id", "in", registrants.ids), ("state", "=", "non_compliant")],
+ groupby=["partner_id"],
+ aggregates=["__count"],
+ )
+ nc_counts = {partner.id: count for partner, count in nc_data}
+
+ for partner in registrants:
+ partner.cycle_membership_count = total_counts.get(partner.id, 0)
+ partner.non_compliant_cycle_count = nc_counts.get(partner.id, 0)
+
@api.depends("entitlements_count", "inkind_entitlements_count")
def _compute_total_entitlements_count(self):
"""Compute combined count of cash and in-kind entitlements."""
@@ -152,6 +197,33 @@ def action_view_program_memberships(self):
"context": {"default_partner_id": self.id},
}
+ def action_view_cycle_memberships(self):
+ """Open cycle memberships for this registrant."""
+ self.ensure_one()
+ return {
+ "name": _("Cycle Memberships - %s") % self.name,
+ "type": "ir.actions.act_window",
+ "res_model": "spp.cycle.membership",
+ "view_mode": "list,form",
+ "domain": [("partner_id", "=", self.id)],
+ "context": {"create": False},
+ }
+
+ def action_view_non_compliant_cycles(self):
+ """Open non-compliant cycle memberships for this registrant."""
+ self.ensure_one()
+ return {
+ "name": _("Non-Compliant Cycles - %s") % self.name,
+ "type": "ir.actions.act_window",
+ "res_model": "spp.cycle.membership",
+ "view_mode": "list,form",
+ "domain": [
+ ("partner_id", "=", self.id),
+ ("state", "=", "non_compliant"),
+ ],
+ "context": {"create": False},
+ }
+
def action_view_all_entitlements(self):
"""Open all entitlements (cash + in-kind) for this registrant."""
self.ensure_one()
diff --git a/spp_programs/tests/__init__.py b/spp_programs/tests/__init__.py
index 873da4ce..e010342c 100644
--- a/spp_programs/tests/__init__.py
+++ b/spp_programs/tests/__init__.py
@@ -32,6 +32,7 @@
from . import test_payment_and_accounting
from . import test_managers
from . import test_cycle_auto_approve_fund_check
+from . import test_cycle_compliance_on_registrant
from . import test_bulk_membership
from . import test_keyset_pagination
from . import test_canary_patterns
diff --git a/spp_programs/tests/test_cycle_compliance_on_registrant.py b/spp_programs/tests/test_cycle_compliance_on_registrant.py
new file mode 100644
index 00000000..c333e37b
--- /dev/null
+++ b/spp_programs/tests/test_cycle_compliance_on_registrant.py
@@ -0,0 +1,210 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Tests for cycle compliance visibility on registrant form.
+
+Covers:
+- Cycle membership compliance_criteria computed field
+- Program membership latest_cycle_state computed field
+- Program membership action_view_cycle_memberships action
+- Registrant cycle_membership_count / non_compliant_cycle_count computed fields
+- Registrant action_view_cycle_memberships action
+"""
+
+import uuid
+
+from odoo import fields
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
+
+
+@tagged("post_install", "-at_install")
+class TestCycleComplianceOnRegistrant(TransactionCase):
+ """Test cycle compliance status visibility on registrant form."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ uid = uuid.uuid4().hex[:6]
+
+ # Create program
+ cls.program = cls.env["spp.program"].create({"name": f"Compliance Test Program {uid}"})
+
+ # Create registrant
+ cls.registrant = cls.env["res.partner"].create(
+ {
+ "name": f"Test Registrant {uid}",
+ "is_registrant": True,
+ "is_group": True,
+ }
+ )
+
+ # Create cycle
+ cls.cycle = cls.env["spp.cycle"].create(
+ {
+ "name": f"Test Cycle {uid}",
+ "program_id": cls.program.id,
+ "start_date": fields.Date.today(),
+ "end_date": fields.Date.today(),
+ }
+ )
+
+ # Create program membership
+ cls.prog_mem = cls.env["spp.program.membership"].create(
+ {
+ "partner_id": cls.registrant.id,
+ "program_id": cls.program.id,
+ "state": "enrolled",
+ }
+ )
+
+ # Create cycle membership
+ cls.cycle_mem = cls.env["spp.cycle.membership"].create(
+ {
+ "partner_id": cls.registrant.id,
+ "cycle_id": cls.cycle.id,
+ "state": "enrolled",
+ }
+ )
+
+ # === Cycle Membership: compliance_criteria ===
+
+ def test_compliance_criteria_empty_when_enrolled(self):
+ """compliance_criteria is False when state is not non_compliant."""
+ self.cycle_mem.state = "enrolled"
+ self.assertFalse(self.cycle_mem.compliance_criteria)
+
+ def test_compliance_criteria_empty_when_non_compliant_no_manager(self):
+ """compliance_criteria is False when non_compliant but no compliance manager."""
+ self.cycle_mem.state = "non_compliant"
+ self.assertFalse(self.cycle_mem.compliance_criteria)
+
+ def test_compliance_criteria_shows_cel_when_non_compliant(self):
+ """compliance_criteria shows CEL expression when non_compliant and manager exists."""
+ # Create compliance manager with CEL expression
+ ComplianceManager = self.env.get("spp.compliance.manager.default")
+ if not ComplianceManager:
+ self.skipTest("spp.compliance.manager.default not available")
+
+ manager = ComplianceManager.create(
+ {
+ "name": f"Test Compliance {uuid.uuid4().hex[:6]}",
+ "program_id": self.program.id,
+ "compliance_cel_expression": "per_capita_income < poverty_line",
+ }
+ )
+
+ # Link manager to program via manager wrapper
+ ManagerWrapper = self.env.get("spp.program.manager")
+ if ManagerWrapper:
+ ManagerWrapper.create(
+ {
+ "program_id": self.program.id,
+ "manager_ref_id": f"{manager._name},{manager.id}",
+ }
+ )
+
+ self.cycle_mem.state = "non_compliant"
+ self.cycle_mem.invalidate_recordset(["compliance_criteria"])
+
+ # If manager was properly linked, criteria should show the CEL
+ # Otherwise it's False (depends on manager linking mechanism)
+ criteria = self.cycle_mem.compliance_criteria
+ if criteria:
+ self.assertEqual(criteria, "per_capita_income < poverty_line")
+
+ # === Program Membership: latest_cycle_state ===
+
+ def test_latest_cycle_state_enrolled(self):
+ """latest_cycle_state reflects the most recent cycle membership state."""
+ self.cycle_mem.state = "enrolled"
+ self.prog_mem.invalidate_recordset(["latest_cycle_state"])
+ self.assertEqual(self.prog_mem.latest_cycle_state, "enrolled")
+
+ def test_latest_cycle_state_non_compliant(self):
+ """latest_cycle_state shows non_compliant when cycle is non_compliant."""
+ self.cycle_mem.state = "non_compliant"
+ self.prog_mem.invalidate_recordset(["latest_cycle_state"])
+ self.assertEqual(self.prog_mem.latest_cycle_state, "non_compliant")
+
+ def test_latest_cycle_state_no_cycle(self):
+ """latest_cycle_state is False when no cycle memberships exist."""
+ # Create a new program membership with no cycles
+ prog_mem2 = self.env["spp.program.membership"].create(
+ {
+ "partner_id": self.registrant.id,
+ "program_id": self.env["spp.program"].create({"name": f"Empty Program {uuid.uuid4().hex[:6]}"}).id,
+ "state": "enrolled",
+ }
+ )
+ self.assertFalse(prog_mem2.latest_cycle_state)
+
+ def test_latest_cycle_state_picks_latest(self):
+ """latest_cycle_state picks the most recent cycle (highest ID)."""
+ cycle2 = self.env["spp.cycle"].create(
+ {
+ "name": f"Cycle 2 {uuid.uuid4().hex[:6]}",
+ "program_id": self.program.id,
+ "start_date": fields.Date.today(),
+ "end_date": fields.Date.today(),
+ }
+ )
+ self.env["spp.cycle.membership"].create(
+ {
+ "partner_id": self.registrant.id,
+ "cycle_id": cycle2.id,
+ "state": "non_compliant",
+ }
+ )
+ self.prog_mem.invalidate_recordset(["latest_cycle_state"])
+ self.assertEqual(self.prog_mem.latest_cycle_state, "non_compliant")
+
+ # === Program Membership: action_view_cycle_memberships ===
+
+ def test_action_view_cycle_memberships_from_program(self):
+ """action_view_cycle_memberships returns valid action dict."""
+ action = self.prog_mem.action_view_cycle_memberships()
+
+ self.assertEqual(action["type"], "ir.actions.act_window")
+ self.assertEqual(action["res_model"], "spp.cycle.membership")
+ self.assertIn("domain", action)
+ # Domain should filter by partner and program
+ domain = action["domain"]
+ self.assertTrue(any(d[0] == "partner_id" and d[2] == self.registrant.id for d in domain))
+ self.assertTrue(any(d[0] == "cycle_id.program_id" and d[2] == self.program.id for d in domain))
+
+ # === Registrant: cycle_membership_count / non_compliant_cycle_count ===
+
+ def test_cycle_membership_count(self):
+ """cycle_membership_count reflects total cycle memberships."""
+ self.registrant.invalidate_recordset(["cycle_membership_count"])
+ self.assertGreaterEqual(self.registrant.cycle_membership_count, 1)
+
+ def test_non_compliant_cycle_count_zero(self):
+ """non_compliant_cycle_count is 0 when all cycles are enrolled."""
+ self.cycle_mem.state = "enrolled"
+ self.registrant.invalidate_recordset(["non_compliant_cycle_count"])
+ self.assertEqual(self.registrant.non_compliant_cycle_count, 0)
+
+ def test_non_compliant_cycle_count_increments(self):
+ """non_compliant_cycle_count increments when cycle becomes non_compliant."""
+ self.cycle_mem.state = "non_compliant"
+ self.registrant.invalidate_recordset(["non_compliant_cycle_count"])
+ self.assertGreaterEqual(self.registrant.non_compliant_cycle_count, 1)
+
+ def test_cycle_counts_non_registrant(self):
+ """Non-registrant partners have 0 cycle counts."""
+ non_reg = self.env["res.partner"].create({"name": "Not a registrant"})
+ self.assertEqual(non_reg.cycle_membership_count, 0)
+ self.assertEqual(non_reg.non_compliant_cycle_count, 0)
+
+ # === Registrant: action_view_cycle_memberships ===
+
+ def test_action_view_cycle_memberships_from_registrant(self):
+ """action_view_cycle_memberships returns valid action dict."""
+ action = self.registrant.action_view_cycle_memberships()
+
+ self.assertEqual(action["type"], "ir.actions.act_window")
+ self.assertEqual(action["res_model"], "spp.cycle.membership")
+ self.assertIn("domain", action)
+ domain = action["domain"]
+ self.assertTrue(any(d[0] == "partner_id" and d[2] == self.registrant.id for d in domain))
diff --git a/spp_programs/views/cycle_membership_compliance_view.xml b/spp_programs/views/cycle_membership_compliance_view.xml
index ca68ee95..3d826487 100644
--- a/spp_programs/views/cycle_membership_compliance_view.xml
+++ b/spp_programs/views/cycle_membership_compliance_view.xml
@@ -7,9 +7,8 @@