-
Notifications
You must be signed in to change notification settings - Fork 1
fix(spp_scoring,spp_scoring_programs): scoring validation, eligibility enforcement, and access control #161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 19.0
Are you sure you want to change the base?
Changes from all commits
9f903a4
2d7db71
2781ffa
4cabe1f
093b815
d8096dd
2f7ebb1
ac9138b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
269
to
274
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The sorting and gap calculation will fail with a 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| } | ||
| ) | ||
|
Comment on lines
+173
to
+187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 ( |
||
|
|
||
|
|
||
| class SppProgramMembership(models.Model): | ||
| """Extends membership with scoring information.""" | ||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.