-
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
12f50d4
2720864
1a8bfdb
ab87b79
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 |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # Part of OpenSPP. See LICENSE file for full copyright and licensing details. | ||
| """Extends res.partner with scoring result count and actions.""" | ||
|
|
||
| from odoo import api, fields, models | ||
|
|
||
|
|
||
| class ResPartner(models.Model): | ||
| """Add scoring smart button to registrant form.""" | ||
|
|
||
| _inherit = "res.partner" | ||
|
|
||
| scoring_result_ids = fields.One2many( | ||
| "spp.scoring.result", | ||
| "registrant_id", | ||
| string="Scoring Results", | ||
| ) | ||
| scoring_result_count = fields.Integer( | ||
| string="# Scores", | ||
| compute="_compute_scoring_result_count", | ||
| ) | ||
|
|
||
| @api.depends("scoring_result_ids") | ||
| def _compute_scoring_result_count(self): | ||
| for partner in self: | ||
| partner.scoring_result_count = len(partner.scoring_result_ids) | ||
|
|
||
| def action_view_scoring_results(self): | ||
| """Open scoring results for this registrant.""" | ||
| self.ensure_one() | ||
| return { | ||
| "name": self.name, | ||
| "type": "ir.actions.act_window", | ||
| "res_model": "spp.scoring.result", | ||
| "view_mode": "list,form", | ||
| "domain": [("registrant_id", "=", self.id)], | ||
| "context": {"default_registrant_id": self.id}, | ||
| } | ||
|
|
||
| def action_score_registrant(self): | ||
| """Open the batch scoring wizard pre-filled for this registrant.""" | ||
| self.ensure_one() | ||
| return { | ||
| "name": "Score Registrant", | ||
| "type": "ir.actions.act_window", | ||
| "res_model": "spp.batch.scoring.wizard", | ||
| "view_mode": "form", | ||
| "target": "new", | ||
| "context": { | ||
| "default_registrant_ids": self.ids, | ||
| "default_domain": f"[('id', '=', {self.id})]", | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| <?xml version="1.0" encoding="utf-8" ?> | ||
| <odoo> | ||
| <!-- Add Scores smart button to the OpenSPP registrant form. | ||
| spp_registry.view_individuals_form replaces //sheet on res.partner, | ||
| so inheriting base.view_partner_form does not propagate to the | ||
| registry form used by Individuals and Groups. --> | ||
| <record id="view_registrant_form_scoring" model="ir.ui.view"> | ||
| <field name="name">spp_registry.view_registrant_form.scoring</field> | ||
| <field name="model">res.partner</field> | ||
| <field name="inherit_id" ref="spp_registry.view_individuals_form" /> | ||
| <field name="arch" type="xml"> | ||
| <xpath expr="//div[@name='button_box']" position="inside"> | ||
| <button | ||
| name="action_view_scoring_results" | ||
| type="object" | ||
| class="oe_stat_button" | ||
| icon="fa-bar-chart" | ||
| > | ||
| <field | ||
| name="scoring_result_count" | ||
| widget="statinfo" | ||
| string="Scores" | ||
| /> | ||
| </button> | ||
| </xpath> | ||
| </field> | ||
| </record> | ||
| </odoo> |
| 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.