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" /> + @@ -124,6 +129,11 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details. string="Not Eligible" domain="[('state','=','not_eligible')]" /> + + @@ -68,6 +88,7 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details. decoration-muted="state in ('exited', 'not_eligible')" decoration-warning="state == 'paused'" decoration-success="state == 'enrolled'" + decoration-danger="latest_cycle_state == 'non_compliant'" > @@ -94,6 +115,24 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details. groups="spp_programs.group_programs_officer" confirm="Are you sure you want to exit this registrant from the program?" /> + +