diff --git a/spp_programs/models/cycle_membership.py b/spp_programs/models/cycle_membership.py
index 1e10faed..ae6f9f2f 100644
--- a/spp_programs/models/cycle_membership.py
+++ b/spp_programs/models/cycle_membership.py
@@ -16,6 +16,13 @@ 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)
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"),
@@ -29,6 +36,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 c22f5a0d..0ff409e1 100644
--- a/spp_programs/models/program_membership.py
+++ b/spp_programs/models/program_membership.py
@@ -84,6 +84,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 15dd4885..8cedc58a 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."""
@@ -144,6 +189,18 @@ 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": {"default_partner_id": self.id},
+ }
+
def action_view_all_entitlements(self):
"""Open all entitlements (cash + in-kind) for this registrant."""
self.ensure_one()
diff --git a/spp_programs/views/cycle_membership_view.xml b/spp_programs/views/cycle_membership_view.xml
index a6197722..a84ae4f8 100644
--- a/spp_programs/views/cycle_membership_view.xml
+++ b/spp_programs/views/cycle_membership_view.xml
@@ -23,9 +23,14 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
decoration-primary="state=='draft'"
decoration-warning="state=='paused'"
decoration-success="state=='enrolled'"
- decoration-danger="state=='exited'"
+ decoration-danger="state in ('exited', 'non_compliant')"
widget="badge"
/>
+