diff --git a/spp_programs/models/managers/program_manager.py b/spp_programs/models/managers/program_manager.py index eccb41a2..f9854f35 100644 --- a/spp_programs/models/managers/program_manager.py +++ b/spp_programs/models/managers/program_manager.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from odoo import _, api, fields, models -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.addons.job_worker.delay import group @@ -219,12 +219,35 @@ def _enroll_eligible_registrants(self, states, offset=0, limit=None, do_count=Fa _logger.debug("members filtered: %s", members) not_enrolled = members.filtered(lambda m: m.state not in ("enrolled", "duplicated", "exited")) _logger.debug("not_enrolled: %s", not_enrolled) - not_enrolled.write( + + # Run pre-enrollment hooks (e.g., scoring eligibility checks). + # Members that fail the hook are moved to not_eligible. + hook_failed = self.env["spp.program.membership"] + for member in not_enrolled: + try: + program._pre_enrollment_hook(member.partner_id) + except (ValidationError, UserError) as e: + _logger.info( + "Pre-enrollment hook rejected registrant %s: %s", + member.partner_id.id, + str(e), + ) + hook_failed |= member + + enrollable = not_enrolled - hook_failed + if hook_failed: + hook_failed.write({"state": "not_eligible"}) + + enrollable.write( { "state": "enrolled", "enrollment_date": fields.Datetime.now(), } ) + + # Run post-enrollment hooks (e.g., auto-score on enrollment) + for member in enrollable: + program._post_enrollment_hook(member.partner_id) # dis-enroll the one not eligible anymore: enrolled_members_ids = members.ids members_to_remove = member_before.filtered( @@ -242,4 +265,4 @@ def _enroll_eligible_registrants(self, states, offset=0, limit=None, do_count=Fa program._compute_eligible_beneficiary_count() program._compute_beneficiary_count() - return len(not_enrolled) + return len(enrollable) diff --git a/spp_scoring/models/scoring_engine.py b/spp_scoring/models/scoring_engine.py index 18ae8acc..20012bfa 100644 --- a/spp_scoring/models/scoring_engine.py +++ b/spp_scoring/models/scoring_engine.py @@ -590,7 +590,9 @@ def get_or_calculate_score(self, registrant, scoring_model, max_age_days=None): """ Result = self.env["spp.scoring.result"] - if max_age_days: + # max_age_days > 0: reuse cached score if fresh enough + # max_age_days = 0 or None: always recalculate + if max_age_days and max_age_days > 0: existing = Result.get_latest_score(registrant, scoring_model, max_age_days) if existing: return existing diff --git a/spp_scoring/models/scoring_model.py b/spp_scoring/models/scoring_model.py index 0d141f14..ff701d6b 100644 --- a/spp_scoring/models/scoring_model.py +++ b/spp_scoring/models/scoring_model.py @@ -205,7 +205,10 @@ def action_activate(self): for record in self: errors = record._validate_configuration() if errors: - raise ValidationError(_("Cannot activate model. Validation errors:\n%s") % "\n".join(errors)) + raise ValidationError( + _("Cannot activate model '%(name)s'. Validation errors:\n%(errors)s") + % {"name": record.name, "errors": "\n".join(f"• {e}" for e in errors)} + ) record.is_active = True return True @@ -221,11 +224,11 @@ def _validate_configuration(self): # Check indicators exist if not self.indicator_ids: - errors.append(_("At least one indicator is required.")) + errors.append(_("No indicators defined. Add at least one indicator in the Indicators tab.")) # Check thresholds exist if not self.threshold_ids: - errors.append(_("At least one threshold is required.")) + errors.append(_("No thresholds defined. Add at least one threshold in the Thresholds tab.")) # Check weights sum correctly (if expected) if self.expected_total_weight > 0: @@ -258,21 +261,37 @@ def _validate_configuration(self): return errors def _validate_thresholds(self): - """Check that thresholds cover the expected score range without gaps.""" + """Check that thresholds cover the expected score range without gaps or overlaps.""" errors = [] if not self.threshold_ids: return errors sorted_thresholds = self.threshold_ids.sorted(key=lambda t: t.min_score) - # Check for gaps between thresholds + # Check all consecutive threshold boundaries for gaps and overlaps for i, threshold in enumerate(sorted_thresholds[:-1]): next_threshold = sorted_thresholds[i + 1] gap = next_threshold.min_score - threshold.max_score + if gap > 0.01: errors.append( - _("Gap detected between thresholds '%(current)s' and '%(next)s'.") - % {"current": threshold.name, "next": next_threshold.name} + _("Gap detected between thresholds '%(current)s' (max %(max)s) and '%(next)s' (min %(min)s).") + % { + "current": threshold.name, + "max": threshold.max_score, + "next": next_threshold.name, + "min": next_threshold.min_score, + } + ) + elif gap < -0.01: + errors.append( + _("Overlap detected between thresholds '%(current)s' (max %(max)s) and '%(next)s' (min %(min)s).") + % { + "current": threshold.name, + "max": threshold.max_score, + "next": next_threshold.name, + "min": next_threshold.min_score, + } ) return errors diff --git a/spp_scoring/views/scoring_model_views.xml b/spp_scoring/views/scoring_model_views.xml index f56e1df1..9fa04404 100644 --- a/spp_scoring/views/scoring_model_views.xml +++ b/spp_scoring/views/scoring_model_views.xml @@ -13,12 +13,14 @@ type="object" class="btn-primary" invisible="is_active" + groups="spp_scoring.group_scoring_manager" />