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"
/>
diff --git a/spp_scoring_programs/__manifest__.py b/spp_scoring_programs/__manifest__.py
index 18824c81..d0dee4c6 100644
--- a/spp_scoring_programs/__manifest__.py
+++ b/spp_scoring_programs/__manifest__.py
@@ -15,9 +15,7 @@
"spp_programs",
],
"data": [
- # Note: No ACL entries needed - this module only extends existing models
- # (spp.program, spp.program.membership, spp.scoring.model) which have
- # their own ACLs defined in their respective modules.
+ "security/ir.model.access.csv",
"views/scoring_model_views.xml",
"views/program_views.xml",
],
diff --git a/spp_scoring_programs/models/program.py b/spp_scoring_programs/models/program.py
index 3f29a815..af18dbaf 100644
--- a/spp_scoring_programs/models/program.py
+++ b/spp_scoring_programs/models/program.py
@@ -73,8 +73,11 @@ def check_scoring_eligibility(self, registrant):
return {"eligible": True, "score": None, "classification": None}
# Get or calculate score
+ # max_age_days=0 means "always recalculate" (no cache);
+ # max_age_days>0 means "reuse if fresher than N days";
+ # max_age_days not set means "always recalculate" (same as 0).
engine = self.env["spp.scoring.engine"]
- max_age = self.score_max_age_days if self.score_max_age_days > 0 else None
+ max_age = self.score_max_age_days or 0
score_result = engine.get_or_calculate_score(
registrant,
@@ -132,31 +135,57 @@ def _pre_enrollment_hook(self, partner):
)
def _post_enrollment_hook(self, partner):
- """Calculate score after enrollment if configured."""
+ """Calculate score after enrollment and record it on membership."""
super()._post_enrollment_hook(partner)
- if self.is_auto_score_on_enrollment and self.scoring_model_id:
+ if not self.scoring_model_id:
+ return
+
+ score_result = None
+
+ if self.is_auto_score_on_enrollment:
engine = self.env["spp.scoring.engine"]
try:
- engine.calculate_score(
+ score_result = engine.calculate_score(
partner,
self.scoring_model_id,
mode="automatic",
)
except (ValidationError, UserError) as e:
- # Expected errors from scoring validation - log and continue
_logger.warning(
"Score calculation failed for registrant_id=%s: %s",
partner.id,
str(e),
)
except Exception:
- # Unexpected errors - log type only to avoid PII exposure
_logger.exception(
"Unexpected error calculating score for registrant_id=%s",
partner.id,
)
+ # If no auto-score, use existing latest score
+ if not score_result:
+ Result = self.env["spp.scoring.result"]
+ score_result = Result.get_latest_score(partner, self.scoring_model_id)
+
+ # Record enrollment score on membership
+ if score_result:
+ membership = self.env["spp.program.membership"].search(
+ [
+ ("partner_id", "=", partner.id),
+ ("program_id", "=", self.id),
+ ("state", "=", "enrolled"),
+ ],
+ limit=1,
+ )
+ if membership:
+ membership.write(
+ {
+ "enrollment_score": score_result.score,
+ "enrollment_classification": score_result.classification_code,
+ }
+ )
+
class SppProgramMembership(models.Model):
"""Extends membership with scoring information."""
diff --git a/spp_scoring_programs/security/ir.model.access.csv b/spp_scoring_programs/security/ir.model.access.csv
new file mode 100644
index 00000000..ffd09b64
--- /dev/null
+++ b/spp_scoring_programs/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_spp_program_scoring_viewer,spp.program scoring viewer,spp_programs.model_spp_program,spp_scoring.group_scoring_viewer,1,0,0,0
+access_spp_program_membership_scoring_viewer,spp.program.membership scoring viewer,spp_programs.model_spp_program_membership,spp_scoring.group_scoring_viewer,1,0,0,0
diff --git a/spp_scoring_programs/views/program_views.xml b/spp_scoring_programs/views/program_views.xml
index cf62ecbf..9e01af13 100644
--- a/spp_scoring_programs/views/program_views.xml
+++ b/spp_scoring_programs/views/program_views.xml
@@ -66,10 +66,10 @@
-
-
-
-
+
+
+
+