Skip to content
Open
29 changes: 26 additions & 3 deletions spp_programs/models/managers/program_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment on lines +226 to +235
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling the pre-enrollment hook inside a loop for each member can lead to significant performance bottlenecks during bulk enrollment, as each hook execution might involve complex logic or database queries (like scoring). Consider refactoring the hook API to support batch processing of recordsets for better efficiency.


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(
Expand All @@ -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)
4 changes: 3 additions & 1 deletion spp_scoring/models/scoring_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 26 additions & 7 deletions spp_scoring/models/scoring_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
Comment on lines 269 to 274
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The sorting and gap calculation will fail with a TypeError if min_score or max_score are None. Since these fields might not be strictly required at the database level, it's safer to handle potential None values or ensure they default to 0.0 during validation.

        sorted_thresholds = self.threshold_ids.sorted(key=lambda t: t.min_score or 0.0)

        # Check all consecutive threshold boundaries for gaps and overlaps
        for i, threshold in enumerate(sorted_thresholds[:-1]):
            next_threshold = sorted_thresholds[i + 1]
            if threshold.max_score is None or next_threshold.min_score is None:
                continue
            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
Expand Down
2 changes: 2 additions & 0 deletions spp_scoring/views/scoring_model_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
type="object"
class="btn-primary"
invisible="is_active"
groups="spp_scoring.group_scoring_manager"
/>
<button
name="action_deactivate"
string="Deactivate"
type="object"
invisible="not is_active"
groups="spp_scoring.group_scoring_manager"
/>
<field name="is_active" widget="boolean_toggle" readonly="1" />
</header>
Expand Down
4 changes: 1 addition & 3 deletions spp_scoring_programs/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
41 changes: 35 additions & 6 deletions spp_scoring_programs/models/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
)
Comment on lines +173 to +187
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This search and subsequent write are performed inside a loop for each partner being enrolled. This creates an N+1 performance issue, especially during bulk enrollment. Since the caller (ProgramManager._enroll_eligible_registrants) already has the membership record, the hook API should ideally be refactored to accept the membership record directly to avoid redundant database lookups and multiple write calls.



class SppProgramMembership(models.Model):
"""Extends membership with scoring information."""
Expand Down
3 changes: 3 additions & 0 deletions spp_scoring_programs/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions spp_scoring_programs/views/program_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@
<field name="inherit_id" ref="spp_programs.view_program_membership_tree" />
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="enrollment_score" optional="hide" />
<field name="enrollment_classification" optional="hide" />
<field name="latest_score" optional="hide" />
<field name="latest_classification" optional="hide" />
<field name="enrollment_score" optional="show" />
<field name="enrollment_classification" optional="show" />
<field name="latest_score" optional="show" />
<field name="latest_classification" optional="show" />
</xpath>
</field>
</record>
Expand Down
Loading