diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 41288816..61bcfe7b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -304,4 +304,4 @@ repos: # Only scan OpenSPP spp_* modules (not scripts, endpoint handlers, etc.) files: ^spp_ # Exclude test files, migrations, and demo-only modules - exclude: ^(tests/|scripts/tests/|.*/tests/.*|.*/migrations/.*|spp_4ps_demo/) + exclude: ^(tests/|scripts/tests/|.*/tests/.*|.*/migrations/.*|spp_4ps_demo/|spp_case_demo/|spp_grm_demo/) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..d2b5b9a6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,31 @@ +# Codecov configuration for OpenSPP +# https://docs.codecov.io/docs/codecov-yaml + +coverage: + status: + project: + default: + # Only check coverage on files changed in this PR, not the entire project. + # CI only tests changed modules, so project-wide coverage would always drop + # on PRs that don't re-test every module. + only_pulls: true + patch: + default: + target: 70% + +# Each module uploads with its own flag (flags: ${{ matrix.module }} in CI). +# Carry forward coverage from previous commits so modules not tested in this +# PR still contribute to overall project coverage. +flag_management: + default_rules: + carryforward: true + +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: false + +ignore: + - "**/tests/**" + - "**/migrations/**" + - "scripts/**" diff --git a/spp_case_base/README.rst b/spp_case_base/README.rst new file mode 100644 index 00000000..0dacf2ba --- /dev/null +++ b/spp_case_base/README.rst @@ -0,0 +1,188 @@ +============================ +OpenSPP Case Management Base +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e58edc2517398a8339ded8ff8bf1eb6b07df10f996637a160c55d36dbf41b8a6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_case_base + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Case management system for social protection programs. Tracks cases from +intake through assessment, intervention planning, and closure with +workflow stages, risk assessments, and team assignment. Automated review +scheduling via cron job ensures timely case monitoring. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Track cases for individuals, households, or groups with configurable + types and workflow stages +- Conduct assessments with risk scoring (0-100) and automatic risk level + classification (low/medium/high/critical) +- Create versioned intervention plans with approval workflows and + progress tracking +- Document case activities: visits, notes, referrals to external + services +- Assign cases to workers and teams with supervisor oversight +- Schedule automated review reminders for cases approaching or past + review dates + +Key Models +~~~~~~~~~~ + ++--------------------------------+-------------------------------------+ +| Model | Description | ++================================+=====================================+ +| ``spp.case`` | Core case record with client and | +| | assignment | ++--------------------------------+-------------------------------------+ +| ``spp.case.type`` | Case type with default intensity | +| | and caseload | ++--------------------------------+-------------------------------------+ +| ``spp.case.stage`` | Workflow stage with phase and | +| | requirements | ++--------------------------------+-------------------------------------+ +| ``spp.case.assessment`` | Assessment with risk score and | +| | findings | ++--------------------------------+-------------------------------------+ +| ``spp.case.intervention.plan`` | Versioned plan with approval | +| | workflow | ++--------------------------------+-------------------------------------+ +| ``spp.case.intervention`` | Individual intervention with status | +| | tracking | ++--------------------------------+-------------------------------------+ +| ``spp.case.visit`` | Client visit with type and notes | ++--------------------------------+-------------------------------------+ +| ``spp.case.note`` | Case note with confidentiality flag | ++--------------------------------+-------------------------------------+ +| ``spp.case.referral`` | External service referral with | +| | status | ++--------------------------------+-------------------------------------+ +| ``spp.case.team`` | Team with supervisor and members | ++--------------------------------+-------------------------------------+ +| ``spp.case.risk.factor`` | Risk factor with severity weight | ++--------------------------------+-------------------------------------+ +| ``spp.case.vulnerability`` | Vulnerability for assessment | ++--------------------------------+-------------------------------------+ +| ``spp.case.closure.reason`` | Closure reason with outcome type | ++--------------------------------+-------------------------------------+ + +Configuration +~~~~~~~~~~~~~ + +After installing: + +1. Navigate to **Case Management > Configuration > Case Setup > Case + Types** and create case types +2. Navigate to **Case Management > Configuration > Case Setup > Case + Stages** and define workflow stages +3. Navigate to **Case Management > Configuration > Case Setup > Case + Teams** and create teams +4. Navigate to **Case Management > Configuration > Assessment > Risk + Factors** and define risk factors +5. Navigate to **Case Management > Configuration > Assessment > + Vulnerabilities** and define vulnerabilities +6. Navigate to **Case Management > Configuration > Closure > Closure + Reasons** and set up closure reasons +7. Verify the cron job **Case Management: Check Review Schedules** is + active under **Settings > Technical > Scheduled Actions** + +UI Location +~~~~~~~~~~~ + +- **Cases**: Case Management > Cases > All Cases / My Cases / Unassigned + Cases +- **Activities**: Case Management > Activities > Visits / Notes / + Referrals / Assessments +- **Planning**: Case Management > Planning > Intervention Plans / + Interventions +- **Configuration**: Case Management > Configuration (Manager role + required) +- **Form tabs**: Details, Participants, Programs, History + +Security +~~~~~~~~ + +========================= ============================================== +Group Access +========================= ============================================== +``group_case_viewer`` Read-only access to all case records +``group_case_worker`` Full CRUD on cases and activities +``group_case_supervisor`` Full CRUD on cases and activities, read config +``group_case_manager`` Full CRUD including configuration +========================= ============================================== + +Extension Points +~~~~~~~~~~~~~~~~ + +- Override ``_check_stage_requirements()`` on ``spp.case`` for custom + stage validation +- Override ``_compute_risk_level()`` on ``spp.case.assessment`` to + customize risk calculation thresholds +- Extend ``spp.case.intervention.plan`` with domain-specific fields +- Hook ``_cron_check_reviews()`` to add custom review logic or + notification templates + +Dependencies +~~~~~~~~~~~~ + +``base``, ``mail``, ``portal``, ``spp_security`` + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 +.. |maintainer-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-emjay0921| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_case_base/__init__.py b/spp_case_base/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/spp_case_base/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/spp_case_base/__manifest__.py b/spp_case_base/__manifest__.py new file mode 100644 index 00000000..41ed5307 --- /dev/null +++ b/spp_case_base/__manifest__.py @@ -0,0 +1,42 @@ +# pylint: disable=pointless-statement +{ + "name": "OpenSPP Case Management Base", + "version": "19.0.1.0.0", + "category": "OpenSPP/Monitoring", + "summary": "Core case management functionality for OpenSPP", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Production/Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "depends": [ + "base", + "mail", + "portal", + "spp_security", + ], + "data": [ + # Security + "security/privileges.xml", + "security/groups.xml", + "security/case_security.xml", + "security/ir.model.access.csv", + "security/rules.xml", + # Data + "data/ir_cron.xml", + # Views - load actions first, then main case view that references them + "views/case_stage_views.xml", + "views/case_type_views.xml", + "views/case_assessment_views.xml", + "views/case_intervention_views.xml", + "views/case_activity_views.xml", + "views/case_config_views.xml", + "views/case_views.xml", + # Menus + "views/case_menus.xml", + ], + "demo": [], + "installable": True, + "application": True, + "auto_install": False, +} diff --git a/spp_case_base/data/ir_cron.xml b/spp_case_base/data/ir_cron.xml new file mode 100644 index 00000000..b5b32da0 --- /dev/null +++ b/spp_case_base/data/ir_cron.xml @@ -0,0 +1,12 @@ + + + + Case Management: Check Review Schedules + + code + model._cron_check_reviews() + 1 + days + + + diff --git a/spp_case_base/models/__init__.py b/spp_case_base/models/__init__.py new file mode 100644 index 00000000..b18a7f92 --- /dev/null +++ b/spp_case_base/models/__init__.py @@ -0,0 +1,10 @@ +from . import case_config +from . import case_stage +from . import case_type +from . import case +from . import case_assessment +from . import case_intervention_plan +from . import case_intervention +from . import case_visit +from . import case_note +from . import case_referral diff --git a/spp_case_base/models/case.py b/spp_case_base/models/case.py new file mode 100644 index 00000000..6a1e5fb7 --- /dev/null +++ b/spp_case_base/models/case.py @@ -0,0 +1,498 @@ +"""Case Management Core Model.""" + +import re +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class Case(models.Model): + """Core case management model.""" + + _name = "spp.case" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Case Management Case" + _order = "create_date desc" + + # Basic Information + name = fields.Char( + string="Case Number", + required=True, + readonly=True, + copy=False, + default=lambda self: self._get_next_case_number(), + ) + + case_type_id = fields.Many2one( + "spp.case.type", + string="Case Type", + required=True, + tracking=True, + ) + + intensity_level = fields.Selection( + [ + ("1", "Level 1 - Low Intensity"), + ("2", "Level 2 - Medium Intensity"), + ("3", "Level 3 - High Intensity"), + ], + default="2", + required=True, + tracking=True, + help="Level of case management intensity required", + ) + + priority = fields.Selection( + [ + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("urgent", "Urgent"), + ], + default="medium", + required=True, + tracking=True, + ) + + # Client Information + client_type = fields.Selection( + [ + ("individual", "Individual"), + ("household", "Household"), + ("group", "Group"), + ], + default="individual", + required=True, + ) + + partner_id = fields.Many2one( + "res.partner", + string="Client", + required=True, + tracking=True, + ) + + # Workflow + stage_id = fields.Many2one( + "spp.case.stage", + string="Stage", + tracking=True, + group_expand="_read_group_stage_ids", + ) + + # Assignment + case_worker_id = fields.Many2one( + "res.users", + string="Case Worker", + required=True, + tracking=True, + domain=[("share", "=", False)], + ) + + supervisor_id = fields.Many2one( + "res.users", + string="Supervisor", + tracking=True, + domain=[("share", "=", False)], + ) + + team_id = fields.Many2one( + "spp.case.team", + string="Team", + tracking=True, + ) + + # Dates + opened_date = fields.Date( + default=fields.Date.context_today, + required=True, + tracking=True, + ) + + target_closure_date = fields.Date( + tracking=True, + ) + + actual_closure_date = fields.Date( + readonly=True, + tracking=True, + ) + + # Intake Information + intake_source = fields.Selection( + [ + ("walk_in", "Walk-in"), + ("referral", "Referral"), + ("outreach", "Outreach"), + ("grm", "GRM/Complaint"), + ("program", "Program Enrollment"), + ("other", "Other"), + ], + tracking=True, + ) + + referral_source = fields.Char( + help="Name or organization that referred the client", + ) + + presenting_issue = fields.Html( + help="Main issue or need that led to case opening", + ) + + # Risk and Vulnerability + risk_factor_ids = fields.Many2many( + "spp.case.risk.factor", + "case_risk_factor_rel", + "case_id", + "risk_factor_id", + string="Risk Factors", + ) + + vulnerability_ids = fields.Many2many( + "spp.case.vulnerability", + "case_vulnerability_rel", + "case_id", + "vulnerability_id", + string="Vulnerabilities", + ) + + # Related Records + assessment_ids = fields.One2many( + "spp.case.assessment", + "case_id", + string="Assessments", + ) + + intervention_plan_ids = fields.One2many( + "spp.case.intervention.plan", + "case_id", + string="Intervention Plans", + ) + + visit_ids = fields.One2many( + "spp.case.visit", + "case_id", + string="Visits", + ) + + note_ids = fields.One2many( + "spp.case.note", + "case_id", + string="Case Notes", + ) + + referral_ids = fields.One2many( + "spp.case.referral", + "case_id", + string="Referrals", + ) + + # Count fields for stat buttons + assessment_count = fields.Integer( + compute="_compute_related_counts", + ) + intervention_plan_count = fields.Integer( + compute="_compute_related_counts", + ) + visit_count = fields.Integer( + compute="_compute_related_counts", + ) + note_count = fields.Integer( + compute="_compute_related_counts", + ) + referral_count = fields.Integer( + compute="_compute_related_counts", + ) + + # Closure Information + closure_reason_id = fields.Many2one( + "spp.case.closure.reason", + string="Closure Reason", + ) + + closure_outcome = fields.Selection( + [ + ("goals_achieved", "Goals Achieved"), + ("partial", "Partial Achievement"), + ("disengaged", "Client Disengaged"), + ("transferred", "Transferred"), + ("ineligible", "Ineligible"), + ("deceased", "Deceased"), + ("lost_contact", "Lost Contact"), + ("other", "Other"), + ], + ) + + closure_summary = fields.Html() + + # Review Dates + next_review_date = fields.Date( + tracking=True, + ) + + last_review_date = fields.Date() + + # Company + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + ) + + # UI Fields + active = fields.Boolean( + default=True, + help="If unchecked, this record will be hidden from active views.", + ) + + color = fields.Integer( + string="Color Index", + help="Color used for kanban view.", + ) + + # Computed Fields + days_open = fields.Integer( + compute="_compute_days_open", + store=False, + ) + + is_active = fields.Boolean( + compute="_compute_is_active", + store=True, + ) + + has_active_plan = fields.Boolean( + compute="_compute_has_active_plan", + store=False, + ) + + current_plan_id = fields.Many2one( + "spp.case.intervention.plan", + string="Current Plan", + compute="_compute_current_plan", + store=False, + ) + + @api.model + def _get_next_case_number(self): + """Generate next case number.""" + # nosemgrep: semgrep.odoo-sudo-without-context -- sequence generation requires sudo + sequence = self.env["ir.sequence"].sudo() + # Try to get existing sequence, create if doesn't exist + seq = sequence.search([("code", "=", "spp.case")], limit=1) + if not seq: + seq = sequence.create( + { + "name": "Case Number", + "code": "spp.case", + "prefix": "CASE-%(year)s-", + "padding": 5, + "company_id": False, + } + ) + return seq.next_by_id() + + @api.depends("assessment_ids", "intervention_plan_ids", "visit_ids", "note_ids", "referral_ids") + def _compute_related_counts(self): + """Compute counts for related records (for stat buttons).""" + for case in self: + case.assessment_count = len(case.assessment_ids) + case.intervention_plan_count = len(case.intervention_plan_ids) + case.visit_count = len(case.visit_ids) + case.note_count = len(case.note_ids) + case.referral_count = len(case.referral_ids) + + @api.depends("opened_date", "actual_closure_date") + def _compute_days_open(self): + """Compute number of days case has been open.""" + today = fields.Date.context_today(self) + for case in self: + if case.actual_closure_date: + end_date = case.actual_closure_date + else: + end_date = today + if case.opened_date: + delta = end_date - case.opened_date + case.days_open = delta.days + else: + case.days_open = 0 + + @api.depends("stage_id", "stage_id.is_closed") + def _compute_is_active(self): + """Compute if case is active (not in closed stage).""" + for case in self: + case.is_active = not case.stage_id.is_closed if case.stage_id else True + + @api.depends("intervention_plan_ids", "intervention_plan_ids.is_current", "intervention_plan_ids.state") + def _compute_has_active_plan(self): + """Compute if case has an active intervention plan.""" + for case in self: + case.has_active_plan = bool( + case.intervention_plan_ids.filtered(lambda p: p.is_current and p.state in ["approved", "active"]) + ) + + @api.depends("intervention_plan_ids", "intervention_plan_ids.is_current") + def _compute_current_plan(self): + """Get the current active intervention plan.""" + for case in self: + case.current_plan_id = case.intervention_plan_ids.filtered(lambda p: p.is_current)[:1] + + @api.model + def _read_group_stage_ids(self, stages, domain): + """Support kanban view with all stages.""" + return stages.search([]) + + @api.onchange("case_type_id") + def _onchange_case_type_id(self): + """Set default intensity when case type changes.""" + if self.case_type_id and self.case_type_id.default_intensity: + self.intensity_level = self.case_type_id.default_intensity + + @api.onchange("team_id") + def _onchange_team_id(self): + """Set supervisor when team changes.""" + if self.team_id and self.team_id.supervisor_id: + self.supervisor_id = self.team_id.supervisor_id + + @api.constrains("presenting_issue") + def _check_presenting_issue(self): + """Validate that presenting issue is provided.""" + for case in self: + # Html fields may contain empty tags like


, strip them + content = case.presenting_issue or "" + stripped = re.sub(r"<[^>]+>", "", content).strip() + if not stripped: + raise ValidationError(_("Please provide a Presenting Issue before saving the case.")) + + @api.constrains("stage_id") + def _check_stage_requirements(self): + """Validate stage change requirements.""" + for case in self: + if not case.stage_id: + continue + + # Check if plan is required + if case.stage_id.is_requires_plan and not case.has_active_plan: + raise ValidationError( + _("Stage '%(stage)s' requires an approved intervention plan.", stage=case.stage_id.name) + ) + + # Check minimum intensity level + if case.stage_id.min_intensity: + if int(case.intensity_level) < int(case.stage_id.min_intensity): + raise ValidationError( + _( + "Stage '%(stage)s' requires minimum intensity level %(level)s.", + stage=case.stage_id.name, + level=case.stage_id.min_intensity, + ) + ) + + def action_close_case(self): + """Close the case.""" + self.ensure_one() + closed_stage = self.env["spp.case.stage"].search([("is_closed", "=", True)], limit=1) + if not closed_stage: + raise ValidationError(_("No closed stage found. Please configure a closed stage.")) + self.write( + { + "stage_id": closed_stage.id, + "actual_closure_date": fields.Date.context_today(self), + } + ) + return True + + def action_reopen_case(self): + """Reopen a closed case.""" + self.ensure_one() + intake_stage = self.env["spp.case.stage"].search([("phase", "=", "intake"), ("is_closed", "=", False)], limit=1) + if not intake_stage: + raise ValidationError(_("No intake stage found. Please configure an intake stage.")) + self.write( + { + "stage_id": intake_stage.id, + "actual_closure_date": False, + } + ) + return True + + @api.model + def _cron_check_reviews(self): + """Cron job to check for overdue case reviews and send reminders.""" + today = fields.Date.today() + + # Find cases with overdue reviews + overdue_cases = self.search( + [ + ("stage_id.is_closed", "=", False), + ("next_review_date", "<=", today), + ] + ) + + for case in overdue_cases: + # Create activity for case worker + case.activity_schedule( + "mail.mail_activity_data_todo", + date_deadline=today, + summary=_("Case review overdue"), + note=_( + "Case %(case_name)s is due for review. Last review: %(last_review)s", + case_name=case.name, + last_review=case.last_review_date or _("Never"), + ), + user_id=case.case_worker_id.id, + ) + + # Find cases approaching review (3 days warning) + warning_date = today + timedelta(days=3) + upcoming_cases = self.search( + [ + ("stage_id.is_closed", "=", False), + ("next_review_date", ">", today), + ("next_review_date", "<=", warning_date), + ] + ) + + for case in upcoming_cases: + # Check if activity already exists + existing = self.env["mail.activity"].search( + [ + ("res_model", "=", "spp.case"), + ("res_id", "=", case.id), + ("summary", "ilike", "review upcoming"), + ], + limit=1, + ) + + if not existing: + case.activity_schedule( + "mail.mail_activity_data_todo", + date_deadline=case.next_review_date, + summary=_("Case review upcoming"), + note=_( + "Case %(case_name)s review is scheduled for %(review_date)s", + case_name=case.name, + review_date=case.next_review_date, + ), + user_id=case.case_worker_id.id, + ) + + return True + + def action_schedule_review(self): + """Schedule the next review based on case type settings.""" + self.ensure_one() + today = fields.Date.today() + + # Update last review date + self.last_review_date = today + + # Calculate next review date from case type + if self.case_type_id and self.case_type_id.review_frequency_days: + self.next_review_date = today + timedelta(days=self.case_type_id.review_frequency_days) + else: + # Default 30 days + self.next_review_date = today + timedelta(days=30) + + return True diff --git a/spp_case_base/models/case_assessment.py b/spp_case_base/models/case_assessment.py new file mode 100644 index 00000000..c44823ee --- /dev/null +++ b/spp_case_base/models/case_assessment.py @@ -0,0 +1,252 @@ +"""Case Assessment Model.""" + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class CaseAssessment(models.Model): + """Case Assessment for tracking evaluations and risk assessments.""" + + _name = "spp.case.assessment" + _description = "Case Assessment" + _order = "assessment_date desc, id desc" + _inherit = ["mail.thread", "mail.activity.mixin"] + + # Basic Information + name = fields.Char( + string="Assessment Name", + compute="_compute_name", + store=True, + readonly=True, + ) + + case_id = fields.Many2one( + "spp.case", + string="Case", + required=True, + ondelete="cascade", + tracking=True, + ) + + assessment_date = fields.Date( + required=True, + default=fields.Date.context_today, + tracking=True, + ) + + assessor_id = fields.Many2one( + "res.users", + string="Assessor", + default=lambda self: self.env.user, + required=True, + domain=[("share", "=", False)], + tracking=True, + ) + + assessment_type = fields.Selection( + [ + ("intake", "Intake Assessment"), + ("periodic", "Periodic Review"), + ("closure", "Closure Assessment"), + ("reassessment", "Reassessment"), + ], + required=True, + default="periodic", + tracking=True, + ) + + # Assessment Content + findings = fields.Html( + help="Key findings from the assessment", + ) + + recommendations = fields.Html( + help="Recommended actions based on assessment", + ) + + # Risk Assessment + risk_score = fields.Float( + help="Risk score from 0 to 100", + tracking=True, + ) + + risk_level = fields.Selection( + [ + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("critical", "Critical"), + ], + compute="_compute_risk_level", + store=True, + tracking=True, + ) + + vulnerability_ids = fields.Many2many( + "spp.case.vulnerability", + "case_assessment_vulnerability_rel", + "assessment_id", + "vulnerability_id", + string="Identified Vulnerabilities", + ) + + risk_factor_ids = fields.Many2many( + "spp.case.risk.factor", + "case_assessment_risk_factor_rel", + "assessment_id", + "risk_factor_id", + string="Risk Factors", + ) + + # Workflow + state = fields.Selection( + [ + ("draft", "Draft"), + ("completed", "Completed"), + ("reviewed", "Reviewed"), + ], + default="draft", + required=True, + tracking=True, + ) + + reviewed_by_id = fields.Many2one( + "res.users", + string="Reviewed By", + readonly=True, + domain=[("share", "=", False)], + tracking=True, + ) + + reviewed_date = fields.Datetime( + readonly=True, + tracking=True, + ) + + # Related Information + case_name = fields.Char( + related="case_id.name", + string="Case Number", + readonly=True, + ) + + case_worker_id = fields.Many2one( + related="case_id.case_worker_id", + string="Case Worker", + readonly=True, + ) + + # Company + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + ) + + # UI Fields + active = fields.Boolean( + default=True, + ) + + @api.depends("case_id", "case_id.name", "assessment_type", "assessment_date") + def _compute_name(self): + """Generate assessment name from case + type + date.""" + for assessment in self: + if assessment.case_id and assessment.assessment_type and assessment.assessment_date: + type_label = dict(assessment._fields["assessment_type"].selection).get(assessment.assessment_type, "") + assessment.name = f"{assessment.case_id.name} - {type_label} - {assessment.assessment_date}" + else: + assessment.name = "New Assessment" + + @api.depends("risk_score") + def _compute_risk_level(self): + """Compute risk level from risk score. + + Risk levels: + - 0-25: Low + - 26-50: Medium + - 51-75: High + - 76-100: Critical + """ + for assessment in self: + if assessment.risk_score is False or assessment.risk_score < 0: + assessment.risk_level = "low" + elif assessment.risk_score <= 25: + assessment.risk_level = "low" + elif assessment.risk_score <= 50: + assessment.risk_level = "medium" + elif assessment.risk_score <= 75: + assessment.risk_level = "high" + else: + assessment.risk_level = "critical" + + @api.constrains("risk_score") + def _check_risk_score(self): + """Validate risk score is between 0 and 100.""" + for assessment in self: + if assessment.risk_score and (assessment.risk_score < 0 or assessment.risk_score > 100): + raise UserError(_("Risk score must be between 0 and 100.")) + + def action_complete(self): + """Move assessment from draft to completed.""" + for assessment in self: + if assessment.state != "draft": + raise UserError(_("Only draft assessments can be completed.")) + assessment.write({"state": "completed"}) + return True + + def action_review(self): + """Move assessment from completed to reviewed. + + Requires supervisor permissions. + """ + for assessment in self: + if assessment.state != "completed": + raise UserError(_("Only completed assessments can be reviewed.")) + + # Check if current user is a supervisor + supervisor_group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if supervisor_group and supervisor_group not in self.env.user.group_ids: + raise UserError(_("Only supervisors can review assessments.")) + + assessment.write( + { + "state": "reviewed", + "reviewed_by_id": self.env.user.id, + "reviewed_date": fields.Datetime.now(), + } + ) + return True + + def action_reset_to_draft(self): + """Reset assessment to draft state.""" + for assessment in self: + if assessment.state == "reviewed": + raise UserError(_("Reviewed assessments cannot be reset to draft.")) + assessment.write( + { + "state": "draft", + "reviewed_by_id": False, + "reviewed_date": False, + } + ) + return True + + @api.onchange("case_id") + def _onchange_case_id(self): + """Pre-populate vulnerabilities and risk factors from case.""" + if self.case_id: + self.vulnerability_ids = self.case_id.vulnerability_ids + self.risk_factor_ids = self.case_id.risk_factor_ids + + def action_view_case(self): + """Open the related case in form view.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "Case", + "res_model": "spp.case", + "res_id": self.case_id.id, + "view_mode": "form", + "target": "current", + } diff --git a/spp_case_base/models/case_config.py b/spp_case_base/models/case_config.py new file mode 100644 index 00000000..def2697a --- /dev/null +++ b/spp_case_base/models/case_config.py @@ -0,0 +1,142 @@ +"""Case Management Configuration Models.""" + +from odoo import api, fields, models + + +class CaseRiskFactor(models.Model): + """Risk factors that can be associated with cases.""" + + _name = "spp.case.risk.factor" + _description = "Case Risk Factor" + _order = "sequence, name" + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True) + sequence = fields.Integer(default=10) + severity_weight = fields.Float( + default=1.0, + help="Weight for risk severity calculation (0.0-1.0)", + ) + description = fields.Text(translate=True) + active = fields.Boolean(default=True) + color = fields.Integer(string="Color Index") + + _code_unique = models.Constraint( + "unique (code)", + "Risk factor code must be unique!", + ) + + +class CaseVulnerability(models.Model): + """Vulnerabilities that can be associated with cases.""" + + _name = "spp.case.vulnerability" + _description = "Case Vulnerability" + _order = "sequence, name" + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True) + sequence = fields.Integer(default=10) + description = fields.Text(translate=True) + active = fields.Boolean(default=True) + color = fields.Integer(string="Color Index") + + _code_unique = models.Constraint( + "unique (code)", + "Vulnerability code must be unique!", + ) + + +class CaseClosureReason(models.Model): + """Reasons for case closure.""" + + _name = "spp.case.closure.reason" + _description = "Case Closure Reason" + _order = "sequence, name" + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True) + sequence = fields.Integer(default=10) + outcome_type = fields.Selection( + [ + ("positive", "Positive"), + ("neutral", "Neutral"), + ("negative", "Negative"), + ], + required=True, + default="neutral", + ) + description = fields.Text(translate=True) + active = fields.Boolean(default=True) + + _code_unique = models.Constraint( + "unique (code)", + "Closure reason code must be unique!", + ) + + +class CaseTeam(models.Model): + """Case management teams.""" + + _name = "spp.case.team" + _description = "Case Management Team" + _order = "name" + + name = fields.Char(required=True) + supervisor_id = fields.Many2one( + "res.users", + string="Team Supervisor", + domain=[("share", "=", False)], + ) + member_ids = fields.Many2many( + "res.users", + "case_team_user_rel", + "team_id", + "user_id", + string="Team Members", + domain=[("share", "=", False)], + ) + case_type_ids = fields.Many2many( + "spp.case.type", + "case_team_type_rel", + "team_id", + "type_id", + string="Case Types", + ) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + ) + color = fields.Integer(string="Color Index") + + # Computed fields for statistics + active_case_count = fields.Integer( + compute="_compute_case_counts", + string="Active Cases", + ) + total_case_count = fields.Integer( + compute="_compute_case_counts", + string="Total Cases", + ) + + @api.depends("member_ids") + def _compute_case_counts(self): + """Compute case counts for the team.""" + for team in self: + domain = [("team_id", "=", team.id)] + team.total_case_count = self.env["spp.case"].search_count(domain) + team.active_case_count = self.env["spp.case"].search_count(domain + [("stage_id.is_closed", "=", False)]) + + def action_view_cases(self): + """Open view with cases assigned to this team.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": f"Cases - {self.name}", + "res_model": "spp.case", + "view_mode": "kanban,list,form", + "domain": [("team_id", "=", self.id)], + "context": {"search_default_team_id": self.id}, + } diff --git a/spp_case_base/models/case_intervention.py b/spp_case_base/models/case_intervention.py new file mode 100644 index 00000000..a8f18e5b --- /dev/null +++ b/spp_case_base/models/case_intervention.py @@ -0,0 +1,170 @@ +"""Case Intervention Model.""" + +from odoo import api, fields, models + + +class CaseIntervention(models.Model): + """Individual interventions within a case intervention plan.""" + + _name = "spp.case.intervention" + _description = "Case Intervention" + _order = "plan_id, sequence, id" + + name = fields.Char( + string="Intervention", + required=True, + help="Name or title of the intervention", + ) + + plan_id = fields.Many2one( + "spp.case.intervention.plan", + string="Intervention Plan", + required=True, + ondelete="cascade", + ) + + case_id = fields.Many2one( + "spp.case", + string="Case", + related="plan_id.case_id", + store=True, + readonly=True, + ) + + sequence = fields.Integer( + default=10, + help="Order of interventions in the plan", + ) + + description = fields.Text( + help="Detailed description of the intervention", + ) + + target_outcome = fields.Char( + help="Expected result or outcome of this intervention", + ) + + responsible = fields.Selection( + [ + ("client", "Client"), + ("worker", "Case Worker"), + ("provider", "Service Provider"), + ("joint", "Joint Responsibility"), + ], + string="Responsible Party", + default="joint", + required=True, + help="Who is primarily responsible for completing this intervention", + ) + + provider_id = fields.Many2one( + "res.partner", + string="Service Provider", + help="External service provider (if applicable)", + ) + + # Dates + target_date = fields.Date( + help="Target completion date", + ) + + completed_date = fields.Date( + readonly=True, + ) + + # Status + state = fields.Selection( + [ + ("planned", "Planned"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("not_completed", "Not Completed"), + ("cancelled", "Cancelled"), + ], + string="Status", + default="planned", + required=True, + ) + + completion_notes = fields.Text( + help="Notes about the completion or outcome of the intervention", + ) + + # Computed fields + is_overdue = fields.Boolean( + compute="_compute_is_overdue", + store=False, + ) + + days_until_due = fields.Integer( + compute="_compute_days_until_due", + store=False, + ) + + @api.depends("target_date", "state") + def _compute_is_overdue(self): + """Check if intervention is overdue.""" + today = fields.Date.context_today(self) + for intervention in self: + intervention.is_overdue = ( + intervention.target_date + and intervention.target_date < today + and intervention.state not in ["completed", "cancelled", "not_completed"] + ) + + @api.depends("target_date") + def _compute_days_until_due(self): + """Compute days until target date.""" + today = fields.Date.context_today(self) + for intervention in self: + if intervention.target_date: + delta = intervention.target_date - today + intervention.days_until_due = delta.days + else: + intervention.days_until_due = 0 + + def action_start(self): + """Mark intervention as in progress.""" + for intervention in self: + intervention.state = "in_progress" + return True + + def action_complete(self): + """Mark intervention as completed.""" + for intervention in self: + intervention.write( + { + "state": "completed", + "completed_date": fields.Date.context_today(self), + } + ) + return True + + def action_mark_not_completed(self): + """Mark intervention as not completed.""" + for intervention in self: + intervention.state = "not_completed" + return True + + def action_cancel(self): + """Cancel the intervention.""" + for intervention in self: + intervention.state = "cancelled" + return True + + def action_reset(self): + """Reset intervention to planned.""" + for intervention in self: + intervention.write( + { + "state": "planned", + "completed_date": False, + } + ) + return True + + @api.onchange("responsible") + def _onchange_responsible(self): + """Clear provider if not applicable.""" + if self.responsible != "provider": + self.provider_id = False diff --git a/spp_case_base/models/case_intervention_plan.py b/spp_case_base/models/case_intervention_plan.py new file mode 100644 index 00000000..ef90f67c --- /dev/null +++ b/spp_case_base/models/case_intervention_plan.py @@ -0,0 +1,264 @@ +"""Case Intervention Plan Model.""" + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CaseInterventionPlan(models.Model): + """Intervention plans for cases.""" + + _name = "spp.case.intervention.plan" + _inherit = ["mail.thread"] + _description = "Case Intervention Plan" + _order = "case_id, version desc" + + name = fields.Char( + string="Plan Name", + required=True, + tracking=True, + ) + + case_id = fields.Many2one( + "spp.case", + string="Case", + required=True, + ondelete="cascade", + tracking=True, + ) + + version = fields.Integer( + default=1, + required=True, + readonly=True, + help="Plan version number", + ) + + is_current = fields.Boolean( + string="Is Current Plan", + default=True, + help="Whether this is the current active plan for the case", + ) + + previous_version_id = fields.Many2one( + "spp.case.intervention.plan", + string="Previous Version", + readonly=True, + help="Previous version of this plan", + ) + + # Plan Content + goals = fields.Html( + required=True, + help="Case management goals to be achieved", + ) + + expected_outcomes = fields.Html( + help="Expected outcomes and success criteria", + ) + + client_responsibilities = fields.Html( + help="Client's roles and responsibilities in the plan", + ) + + # Dates + start_date = fields.Date( + default=fields.Date.context_today, + required=True, + tracking=True, + ) + + target_end_date = fields.Date( + tracking=True, + ) + + actual_end_date = fields.Date( + readonly=True, + ) + + # Status + state = fields.Selection( + [ + ("draft", "Draft"), + ("pending_approval", "Pending Approval"), + ("approved", "Approved"), + ("active", "Active"), + ("completed", "Completed"), + ("revised", "Revised"), + ], + string="Status", + default="draft", + required=True, + tracking=True, + ) + + # Approval + approved_by_id = fields.Many2one( + "res.users", + string="Approved By", + readonly=True, + tracking=True, + ) + + approved_date = fields.Datetime( + readonly=True, + tracking=True, + ) + + # Related Records + intervention_ids = fields.One2many( + "spp.case.intervention", + "plan_id", + string="Interventions", + ) + + # Computed Fields + intervention_count = fields.Integer( + string="Interventions", + compute="_compute_intervention_count", + ) + + completed_intervention_count = fields.Integer( + string="Completed Interventions", + compute="_compute_intervention_count", + ) + + progress_percentage = fields.Float( + string="Progress %", + compute="_compute_progress", + ) + + @api.depends("intervention_ids", "intervention_ids.state") + def _compute_intervention_count(self): + """Compute intervention counts.""" + for plan in self: + plan.intervention_count = len(plan.intervention_ids) + plan.completed_intervention_count = len(plan.intervention_ids.filtered(lambda i: i.state == "completed")) + + @api.depends("intervention_ids", "intervention_ids.state") + def _compute_progress(self): + """Compute plan progress percentage.""" + for plan in self: + if plan.intervention_count > 0: + plan.progress_percentage = plan.completed_intervention_count / plan.intervention_count * 100 + else: + plan.progress_percentage = 0.0 + + @api.constrains("is_current") + def _check_single_current_plan(self): + """Ensure only one current plan per case.""" + for plan in self: + if plan.is_current: + other_current = self.search( + [ + ("case_id", "=", plan.case_id.id), + ("is_current", "=", True), + ("id", "!=", plan.id), + ] + ) + if other_current: + raise ValidationError(_("Only one plan can be marked as current for a case.")) + + def action_submit_for_approval(self): + """Submit plan for approval.""" + for plan in self: + if not plan.intervention_ids: + raise ValidationError( + _("Cannot submit plan without interventions. Please add at least one intervention.") + ) + plan.state = "pending_approval" + return True + + def action_approve(self): + """Approve the plan.""" + for plan in self: + plan.write( + { + "state": "approved", + "approved_by_id": self.env.user.id, + "approved_date": fields.Datetime.now(), + } + ) + return True + + def action_activate(self): + """Activate the plan.""" + for plan in self: + if plan.state != "approved": + raise ValidationError(_("Only approved plans can be activated.")) + plan.state = "active" + return True + + def action_complete(self): + """Mark plan as completed.""" + for plan in self: + plan.write( + { + "state": "completed", + "actual_end_date": fields.Date.context_today(self), + } + ) + return True + + def action_create_revision(self): + """Create a new version of this plan.""" + self.ensure_one() + + # Mark current plan as revised + self.write( + { + "is_current": False, + "state": "revised", + } + ) + + # Create new version + new_version = self.copy( + { + "version": self.version + 1, + "previous_version_id": self.id, + "is_current": True, + "state": "draft", + "approved_by_id": False, + "approved_date": False, + "actual_end_date": False, + "start_date": fields.Date.context_today(self), + } + ) + + return { + "type": "ir.actions.act_window", + "res_model": "spp.case.intervention.plan", + "res_id": new_version.id, + "view_mode": "form", + "target": "current", + } + + def action_reset_to_draft(self): + """Reset plan to draft.""" + for plan in self: + if plan.state in ["completed", "revised"]: + raise ValidationError(_("Cannot reset completed or revised plans.")) + plan.state = "draft" + return True + + def action_view_interventions(self): + """Open view with interventions of this plan.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": f"Interventions - {self.name}", + "res_model": "spp.case.intervention", + "view_mode": "list,form", + "domain": [("plan_id", "=", self.id)], + "context": {"default_plan_id": self.id}, + } + + @api.model_create_multi + def create(self, vals_list): + """Override create to generate plan name if not provided.""" + for vals in vals_list: + if not vals.get("name") and vals.get("case_id"): + case = self.env["spp.case"].browse(vals["case_id"]) + version = vals.get("version", 1) + vals["name"] = f"{case.name} - Plan v{version}" + return super().create(vals_list) diff --git a/spp_case_base/models/case_note.py b/spp_case_base/models/case_note.py new file mode 100644 index 00000000..08997bc8 --- /dev/null +++ b/spp_case_base/models/case_note.py @@ -0,0 +1,111 @@ +"""Case Note Model.""" + +from odoo import _, api, fields, models + + +class CaseNote(models.Model): + """Notes and documentation for cases.""" + + _name = "spp.case.note" + _description = "Case Note" + _order = "note_date desc" + + case_id = fields.Many2one( + "spp.case", + string="Case", + required=True, + ondelete="cascade", + ) + + note_date = fields.Datetime( + required=True, + default=fields.Datetime.now, + ) + + author_id = fields.Many2one( + "res.users", + string="Author", + required=True, + default=lambda self: self.env.user, + ) + + note_type = fields.Selection( + [ + ("general", "General Note"), + ("assessment", "Assessment"), + ("progress", "Progress Update"), + ("supervision", "Supervision Note"), + ], + required=True, + default="general", + ) + + content = fields.Html( + required=True, + ) + + is_confidential = fields.Boolean( + string="Confidential", + default=False, + help="Mark this note as confidential (restricted access)", + ) + + # Related fields for easy access + case_worker_id = fields.Many2one( + "res.users", + string="Case Worker", + related="case_id.case_worker_id", + store=True, + readonly=True, + ) + + client_id = fields.Many2one( + "res.partner", + string="Client", + related="case_id.partner_id", + store=True, + readonly=True, + ) + + # Computed fields + note_date_display = fields.Char( + string="Date", + compute="_compute_note_date_display", + store=False, + ) + + def _compute_note_date_display(self): + """Format note date for display.""" + for note in self: + if note.note_date: + note.note_date_display = fields.Datetime.to_string(note.note_date) + else: + note.note_date_display = "" + + def _compute_display_name(self): + """Compute display name for notes.""" + note_type_labels = dict(self._fields["note_type"].selection) + for note in self: + type_label = note_type_labels.get(note.note_type, note.note_type) + if note.note_date and note.client_id: + date_str = note.note_date.strftime("%b %d, %Y") + note.display_name = f"{type_label} - {date_str} - {note.client_id.name}" + elif note.note_date: + date_str = note.note_date.strftime("%b %d, %Y") + note.display_name = f"{type_label} - {date_str}" + else: + note.display_name = type_label + + @api.model_create_multi + def create(self, vals_list): + """Override create to log note creation.""" + notes = super().create(vals_list) + # Post message to case chatter + for note in notes: + if note.case_id: + note_type_label = dict(note._fields["note_type"].selection).get(note.note_type) + note.case_id.message_post( + body=_("New %(type)s added by %(author)s", type=note_type_label, author=note.author_id.name), + subject=_("Case Note: %(type)s", type=note_type_label), + ) + return notes diff --git a/spp_case_base/models/case_referral.py b/spp_case_base/models/case_referral.py new file mode 100644 index 00000000..74ed06c3 --- /dev/null +++ b/spp_case_base/models/case_referral.py @@ -0,0 +1,140 @@ +"""Case Referral Model.""" + +from odoo import _, api, fields, models + + +class CaseReferral(models.Model): + """Referrals to external services for cases.""" + + _name = "spp.case.referral" + _description = "Case Referral" + _order = "referral_date desc" + + case_id = fields.Many2one( + "spp.case", + string="Case", + required=True, + ondelete="cascade", + ) + + referral_date = fields.Date( + required=True, + default=fields.Date.context_today, + ) + + referred_by_id = fields.Many2one( + "res.users", + string="Referred By", + required=True, + default=lambda self: self.env.user, + ) + + service_name = fields.Char( + required=True, + help="Name of the service being referred to", + ) + + provider_name = fields.Char( + help="Name of the organization or person providing the service", + ) + + reason = fields.Text( + string="Reason for Referral", + required=True, + help="Reason or need for the referral", + ) + + status = fields.Selection( + [ + ("pending", "Pending"), + ("accepted", "Accepted"), + ("rejected", "Rejected"), + ("completed", "Completed"), + ], + required=True, + default="pending", + ) + + outcome = fields.Text( + help="Result or outcome of the referral", + ) + + follow_up_date = fields.Date( + string="Follow-up Date", + help="Date to follow up on the referral", + ) + + # Related fields for easy access + case_worker_id = fields.Many2one( + "res.users", + string="Case Worker", + related="case_id.case_worker_id", + store=True, + readonly=True, + ) + + client_id = fields.Many2one( + "res.partner", + string="Client", + related="case_id.partner_id", + store=True, + readonly=True, + ) + + # Computed fields + is_overdue = fields.Boolean( + compute="_compute_is_overdue", + store=False, + ) + + @api.depends("follow_up_date", "status") + def _compute_is_overdue(self): + """Check if follow-up is overdue.""" + today = fields.Date.context_today(self) + for referral in self: + referral.is_overdue = ( + referral.follow_up_date + and referral.follow_up_date < today + and referral.status not in ["completed", "rejected"] + ) + + def action_accept(self): + """Mark referral as accepted.""" + for referral in self: + referral.status = "accepted" + return True + + def action_reject(self): + """Mark referral as rejected.""" + for referral in self: + referral.status = "rejected" + return True + + def action_complete(self): + """Mark referral as completed.""" + for referral in self: + referral.status = "completed" + return True + + def _compute_display_name(self): + """Compute display name for referrals.""" + for referral in self: + if referral.service_name and referral.client_id: + referral.display_name = f"{referral.service_name} - {referral.client_id.name}" + elif referral.service_name: + referral.display_name = referral.service_name + else: + referral.display_name = f"Referral #{referral.id}" + + @api.model_create_multi + def create(self, vals_list): + """Override create to log referral creation.""" + referrals = super().create(vals_list) + # Post message to case chatter + for referral in referrals: + if referral.case_id: + referral.case_id.message_post( + body=_("Referral created to %(service)s", service=referral.service_name), + subject=_("New Referral"), + ) + return referrals diff --git a/spp_case_base/models/case_stage.py b/spp_case_base/models/case_stage.py new file mode 100644 index 00000000..0113445e --- /dev/null +++ b/spp_case_base/models/case_stage.py @@ -0,0 +1,76 @@ +"""Case Stage Model.""" + +from odoo import fields, models + + +class CaseStage(models.Model): + """Stages for case management workflow.""" + + _name = "spp.case.stage" + _description = "Case Stage" + _order = "sequence, name" + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(default=10) + phase = fields.Selection( + [ + ("intake", "Intake"), + ("assessment", "Assessment"), + ("planning", "Planning"), + ("implementation", "Implementation"), + ("monitoring", "Monitoring"), + ("evaluation", "Evaluation"), + ("closure", "Closure"), + ], + required=True, + default="intake", + help="The phase of the case management process", + ) + is_closed = fields.Boolean( + string="Is Closed Stage", + default=False, + help="Cases in this stage are considered closed", + ) + is_requires_plan = fields.Boolean( + string="Requires Intervention Plan", + default=False, + help="Cases must have an approved intervention plan to enter this stage", + ) + is_requires_assessment = fields.Boolean( + string="Requires Assessment", + default=False, + help="Cases must have a completed assessment to enter this stage", + ) + is_requires_approval = fields.Boolean( + string="Requires Approval", + default=False, + help="Stage change requires supervisor approval", + ) + min_intensity = fields.Selection( + [ + ("1", "Level 1"), + ("2", "Level 2"), + ("3", "Level 3"), + ], + string="Minimum Intensity Level", + help="Minimum case intensity level required for this stage", + ) + fold = fields.Boolean( + string="Folded in Kanban", + default=False, + help="This stage is folded in the kanban view", + ) + color = fields.Integer(string="Color Index") + description = fields.Text(translate=True) + active = fields.Boolean(default=True) + + # Computed field for case counts + case_count = fields.Integer( + compute="_compute_case_count", + string="Cases", + ) + + def _compute_case_count(self): + """Compute number of cases in this stage.""" + for stage in self: + stage.case_count = self.env["spp.case"].search_count([("stage_id", "=", stage.id)]) diff --git a/spp_case_base/models/case_type.py b/spp_case_base/models/case_type.py new file mode 100644 index 00000000..25fc5589 --- /dev/null +++ b/spp_case_base/models/case_type.py @@ -0,0 +1,111 @@ +"""Case Type Model.""" + +from odoo import api, fields, models + + +class CaseType(models.Model): + """Types of cases in the case management system.""" + + _name = "spp.case.type" + _description = "Case Type" + _order = "sequence, name" + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True, help="Unique code for the case type") + sequence = fields.Integer(default=10) + description = fields.Text(translate=True) + active = fields.Boolean(default=True) + + domain = fields.Selection( + [ + ("social_protection", "Social Protection"), + ("child_protection", "Child Protection"), + ("gbv", "Gender-Based Violence"), + ("disability", "Disability"), + ("elderly", "Elderly Care"), + ("livelihoods", "Livelihoods"), + ("agriculture", "Agriculture"), + ("health", "Health"), + ("education", "Education"), + ("other", "Other"), + ], + required=True, + default="social_protection", + help="Domain or sector of the case type", + ) + + default_intensity = fields.Selection( + [ + ("1", "Level 1"), + ("2", "Level 2"), + ("3", "Level 3"), + ], + string="Default Intensity Level", + default="2", + help="Default intensity level for cases of this type", + ) + + stage_ids = fields.Many2many( + "spp.case.stage", + "case_type_stage_rel", + "type_id", + "stage_id", + string="Available Stages", + help="Stages available for this case type", + ) + + review_frequency_days = fields.Integer( + string="Review Frequency (Days)", + default=30, + help="Number of days between case reviews", + ) + + recommended_caseload = fields.Integer( + default=25, + help="Recommended number of active cases per case worker", + ) + + max_caseload = fields.Integer( + string="Maximum Caseload", + default=40, + help="Maximum number of active cases per case worker", + ) + + color = fields.Integer(string="Color Index") + + # Computed fields for statistics + case_count = fields.Integer( + compute="_compute_case_count", + string="Total Cases", + ) + active_case_count = fields.Integer( + compute="_compute_case_count", + string="Active Cases", + ) + + _code_unique = models.Constraint( + "unique (code)", + "Case type code must be unique!", + ) + + @api.depends("active") + def _compute_case_count(self): + """Compute case counts for this case type.""" + for case_type in self: + domain = [("case_type_id", "=", case_type.id)] + case_type.case_count = self.env["spp.case"].search_count(domain) + case_type.active_case_count = self.env["spp.case"].search_count( + domain + [("stage_id.is_closed", "=", False)] + ) + + def action_view_cases(self): + """Open view with cases of this type.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": f"Cases - {self.name}", + "res_model": "spp.case", + "view_mode": "kanban,list,form", + "domain": [("case_type_id", "=", self.id)], + "context": {"default_case_type_id": self.id}, + } diff --git a/spp_case_base/models/case_visit.py b/spp_case_base/models/case_visit.py new file mode 100644 index 00000000..60985f42 --- /dev/null +++ b/spp_case_base/models/case_visit.py @@ -0,0 +1,120 @@ +"""Case Visit Model.""" + +from odoo import api, fields, models + + +class CaseVisit(models.Model): + """Visits and contacts with case clients.""" + + _name = "spp.case.visit" + _description = "Case Visit" + _order = "visit_date desc" + + case_id = fields.Many2one( + "spp.case", + string="Case", + required=True, + ondelete="cascade", + ) + + visit_date = fields.Datetime( + required=True, + default=fields.Datetime.now, + ) + + visit_type = fields.Selection( + [ + ("home", "Home Visit"), + ("office", "Office Visit"), + ("phone", "Phone Call"), + ("virtual", "Virtual Meeting"), + ], + required=True, + default="office", + ) + + duration_minutes = fields.Integer( + string="Duration (Minutes)", + help="Duration of the visit in minutes", + ) + + purpose = fields.Char( + help="Main purpose or objective of the visit", + ) + + notes = fields.Html( + string="Visit Notes", + help="Detailed notes from the visit", + ) + + attendee_ids = fields.Many2many( + "res.partner", + "case_visit_attendee_rel", + "visit_id", + "partner_id", + string="Attendees", + help="People who attended the visit", + ) + + conducted_by_id = fields.Many2one( + "res.users", + string="Conducted By", + required=True, + default=lambda self: self.env.user, + ) + + # Related fields for easy access + case_worker_id = fields.Many2one( + "res.users", + string="Case Worker", + related="case_id.case_worker_id", + store=True, + readonly=True, + ) + + client_id = fields.Many2one( + "res.partner", + string="Client", + related="case_id.partner_id", + store=True, + readonly=True, + ) + + # Computed fields + visit_date_display = fields.Char( + string="Visit Date", + compute="_compute_visit_date_display", + store=False, + ) + + def _compute_visit_date_display(self): + """Format visit date for display.""" + for visit in self: + if visit.visit_date: + visit.visit_date_display = fields.Datetime.to_string(visit.visit_date) + else: + visit.visit_date_display = "" + + @api.model_create_multi + def create(self, vals_list): + """Override create to add default attendee.""" + visits = super().create(vals_list) + # Add client as default attendee if not already added + for visit in visits: + if visit.case_id.partner_id and visit.case_id.partner_id not in visit.attendee_ids: + visit.attendee_ids = [(4, visit.case_id.partner_id.id)] + return visits + + def _compute_display_name(self): + """Compute display name for visits.""" + visit_type_labels = dict(self._fields["visit_type"].selection) + for visit in self: + type_label = visit_type_labels.get(visit.visit_type, visit.visit_type) + if visit.visit_date and visit.client_id: + date_str = visit.visit_date.strftime("%b %d, %Y") + visit.display_name = f"{type_label} - {date_str} - {visit.client_id.name}" + elif visit.visit_date: + date_str = visit.visit_date.strftime("%b %d, %Y") + visit.display_name = f"{type_label} - {date_str}" + else: + visit.display_name = type_label diff --git a/spp_case_base/pyproject.toml b/spp_case_base/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_case_base/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_case_base/readme/DESCRIPTION.md b/spp_case_base/readme/DESCRIPTION.md new file mode 100644 index 00000000..68f2a9b6 --- /dev/null +++ b/spp_case_base/readme/DESCRIPTION.md @@ -0,0 +1,68 @@ +Case management system for social protection programs. Tracks cases from intake through assessment, intervention planning, and closure with workflow stages, risk assessments, and team assignment. Automated review scheduling via cron job ensures timely case monitoring. + +### Key Capabilities + +- Track cases for individuals, households, or groups with configurable types and workflow stages +- Conduct assessments with risk scoring (0-100) and automatic risk level classification (low/medium/high/critical) +- Create versioned intervention plans with approval workflows and progress tracking +- Document case activities: visits, notes, referrals to external services +- Assign cases to workers and teams with supervisor oversight +- Schedule automated review reminders for cases approaching or past review dates + +### Key Models + +| Model | Description | +| ---------------------------- | ----------------------------------------------- | +| `spp.case` | Core case record with client and assignment | +| `spp.case.type` | Case type with default intensity and caseload | +| `spp.case.stage` | Workflow stage with phase and requirements | +| `spp.case.assessment` | Assessment with risk score and findings | +| `spp.case.intervention.plan` | Versioned plan with approval workflow | +| `spp.case.intervention` | Individual intervention with status tracking | +| `spp.case.visit` | Client visit with type and notes | +| `spp.case.note` | Case note with confidentiality flag | +| `spp.case.referral` | External service referral with status | +| `spp.case.team` | Team with supervisor and members | +| `spp.case.risk.factor` | Risk factor with severity weight | +| `spp.case.vulnerability` | Vulnerability for assessment | +| `spp.case.closure.reason` | Closure reason with outcome type | + +### Configuration + +After installing: + +1. Navigate to **Case Management > Configuration > Case Setup > Case Types** and create case types +2. Navigate to **Case Management > Configuration > Case Setup > Case Stages** and define workflow stages +3. Navigate to **Case Management > Configuration > Case Setup > Case Teams** and create teams +4. Navigate to **Case Management > Configuration > Assessment > Risk Factors** and define risk factors +5. Navigate to **Case Management > Configuration > Assessment > Vulnerabilities** and define vulnerabilities +6. Navigate to **Case Management > Configuration > Closure > Closure Reasons** and set up closure reasons +7. Verify the cron job **Case Management: Check Review Schedules** is active under **Settings > Technical > Scheduled Actions** + +### UI Location + +- **Cases**: Case Management > Cases > All Cases / My Cases / Unassigned Cases +- **Activities**: Case Management > Activities > Visits / Notes / Referrals / Assessments +- **Planning**: Case Management > Planning > Intervention Plans / Interventions +- **Configuration**: Case Management > Configuration (Manager role required) +- **Form tabs**: Details, Participants, Programs, History + +### Security + +| Group | Access | +| ------------------------------- | ----------------------------------------------- | +| `group_case_viewer` | Read-only access to all case records | +| `group_case_worker` | Full CRUD on cases and activities | +| `group_case_supervisor` | Full CRUD on cases and activities, read config | +| `group_case_manager` | Full CRUD including configuration | + +### Extension Points + +- Override `_check_stage_requirements()` on `spp.case` for custom stage validation +- Override `_compute_risk_level()` on `spp.case.assessment` to customize risk calculation thresholds +- Extend `spp.case.intervention.plan` with domain-specific fields +- Hook `_cron_check_reviews()` to add custom review logic or notification templates + +### Dependencies + +`base`, `mail`, `portal`, `spp_security` diff --git a/spp_case_base/security/case_security.xml b/spp_case_base/security/case_security.xml new file mode 100644 index 00000000..7f2a59bb --- /dev/null +++ b/spp_case_base/security/case_security.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/spp_case_base/security/compliance.yaml b/spp_case_base/security/compliance.yaml new file mode 100644 index 00000000..c6f8344f --- /dev/null +++ b/spp_case_base/security/compliance.yaml @@ -0,0 +1,510 @@ +# OpenSPP Access Management Compliance Specification +# Module: spp_case_base +# Version: 1.0 +# +# This file declares the expected access control configuration for this module. +# Run: python -m scripts.compliance.checker spp_case_base +# Generate tests: python -m scripts.compliance.test_generator spp_case_base + +module: spp_case_base +domain: case +version: "1.0" + +# ============================================================================= +# GROUP DEFINITIONS +# Following ADR-004 Three-Tier Architecture with case-specific roles +# ============================================================================= + +groups: + # --- Tier 3: Technical base groups --- + - id: group_case_read + tier: 3 + comment: "Technical group for read access to case models." + + - id: group_case_write + tier: 3 + implied_ids: [group_case_read] + comment: "Technical group for write access to case models." + + # --- Tier 2: User-facing groups --- + - id: group_case_viewer + tier: 2 + privilege_id: privilege_case_viewer + implied_ids: [group_case_read] + comment: "Case Management: View all cases (read-only access)" + + - id: group_case_officer + tier: 2 + privilege_id: privilege_case_officer + implied_ids: [group_case_viewer, group_case_write] + comment: "Case Management: Can view and edit case records. Cannot delete." + + # --- Special role groups --- + - id: group_case_worker + tier: 2 + privilege_id: privilege_case_user + implied_ids: [group_case_viewer] + comment: + "SPECIALIZED ROLE: Manage own cases and case activities. Between Viewer and + Officer." + + - id: group_case_supervisor + tier: 2 + privilege_id: privilege_case_supervisor + implied_ids: [group_case_worker] + comment: + "SPECIALIZED ROLE: Supervise team cases and approve plans. Between Officer and + Manager." + + - id: group_case_manager + tier: 2 + privilege_id: privilege_case_manager + implied_ids: [group_case_officer, group_case_supervisor] + comment: "Full access including configuration and delete operations." + +# Admin linkage - manager group links to spp_security.group_spp_admin +admin_link_group: group_case_manager + +# ============================================================================= +# MODEL ACCESS (ir.model.access.csv) +# Case management uses custom roles: viewer/worker/supervisor/manager +# ============================================================================= + +model_access: + # Core case model + - model: spp.case + viewer: [read] + worker: [read, write, create, unlink] + supervisor: [read, write, create, unlink] + manager: [read, write, create, unlink] + + # Case assessment + - model: spp.case.assessment + viewer: [read] + worker: [read, write, create, unlink] + supervisor: [read, write, create, unlink] + manager: [read, write, create, unlink] + + # Configuration models - viewer/worker/supervisor read-only, manager full CRUD + - model: spp.case.stage + viewer: [read] + worker: [read] + supervisor: [read] + manager: [read, write, create, unlink] + + - model: spp.case.type + viewer: [read] + worker: [read] + supervisor: [read] + manager: [read, write, create, unlink] + + # Case intervention plan + - model: spp.case.intervention.plan + viewer: [read] + worker: [read, write, create, unlink] + supervisor: [read, write, create, unlink] + manager: [read, write, create, unlink] + + # Case intervention + - model: spp.case.intervention + viewer: [read] + worker: [read, write, create, unlink] + supervisor: [read, write, create, unlink] + manager: [read, write, create, unlink] + + # Case visit + - model: spp.case.visit + viewer: [read] + worker: [read, write, create, unlink] + supervisor: [read, write, create, unlink] + manager: [read, write, create, unlink] + + # Case note + - model: spp.case.note + viewer: [read] + worker: [read, write, create, unlink] + supervisor: [read, write, create, unlink] + manager: [read, write, create, unlink] + + # Case referral + - model: spp.case.referral + viewer: [read] + worker: [read, write, create, unlink] + supervisor: [read, write, create, unlink] + manager: [read, write, create, unlink] + + # Configuration models - read-only for non-managers + - model: spp.case.risk.factor + viewer: [read] + worker: [read] + supervisor: [read] + manager: [read, write, create, unlink] + + - model: spp.case.vulnerability + viewer: [read] + worker: [read] + supervisor: [read] + manager: [read, write, create, unlink] + + - model: spp.case.closure.reason + viewer: [read] + worker: [read] + supervisor: [read] + manager: [read, write, create, unlink] + + - model: spp.case.team + viewer: [read] + worker: [read] + supervisor: [read] + manager: [read, write, create, unlink] + +# ============================================================================= +# RECORD RULES (ir.rule) +# Data visibility rules based on group membership +# ============================================================================= + +record_rules: + # Case access - worker sees own cases + - id: rule_spp_case_worker + model: spp.case + groups: [group_case_worker] + domain_description: "Worker can only see cases assigned to them" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + domain_pattern: assigned_to_me + domain: "[('case_worker_id', '=', user.id)]" + domain_field: case_worker_id + + # Case access - supervisor sees team cases + - id: rule_spp_case_supervisor + model: spp.case + groups: [group_case_supervisor] + domain_description: "Supervisor can see cases they supervise or team cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + domain_pattern: team_records + domain: + "['|', ('supervisor_id', '=', user.id), ('team_id.supervisor_id', '=', user.id)]" + domain_field: supervisor_id + + # Case access - manager sees all + - id: rule_spp_case_manager + model: spp.case + groups: [group_case_manager] + domain_description: "Manager can see all cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: true + domain_pattern: all_records + domain: "[(1, '=', 1)]" + + # Intervention plan - worker + - id: rule_spp_case_intervention_plan_worker + model: spp.case.intervention.plan + groups: [group_case_worker] + domain_description: "Worker can only see plans for own cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Intervention plan - supervisor + - id: rule_spp_case_intervention_plan_supervisor + model: spp.case.intervention.plan + groups: [group_case_supervisor] + domain_description: "Supervisor can see plans for team cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Intervention plan - manager + - id: rule_spp_case_intervention_plan_manager + model: spp.case.intervention.plan + groups: [group_case_manager] + domain_description: "Manager can see all plans" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: true + domain_pattern: all_records + domain: "[(1, '=', 1)]" + + # Intervention - worker + - id: rule_spp_case_intervention_worker + model: spp.case.intervention + groups: [group_case_worker] + domain_description: "Worker can only see interventions for own cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Intervention - supervisor + - id: rule_spp_case_intervention_supervisor + model: spp.case.intervention + groups: [group_case_supervisor] + domain_description: "Supervisor can see interventions for team cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Intervention - manager + - id: rule_spp_case_intervention_manager + model: spp.case.intervention + groups: [group_case_manager] + domain_description: "Manager can see all interventions" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: true + + # Visit - worker + - id: rule_spp_case_visit_worker + model: spp.case.visit + groups: [group_case_worker] + domain_description: "Worker can only see visits for own cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Visit - supervisor + - id: rule_spp_case_visit_supervisor + model: spp.case.visit + groups: [group_case_supervisor] + domain_description: "Supervisor can see visits for team cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Visit - manager + - id: rule_spp_case_visit_manager + model: spp.case.visit + groups: [group_case_manager] + domain_description: "Manager can see all visits" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: true + + # Note - worker + - id: rule_spp_case_note_worker + model: spp.case.note + groups: [group_case_worker] + domain_description: "Worker can only see notes for own cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Note - supervisor + - id: rule_spp_case_note_supervisor + model: spp.case.note + groups: [group_case_supervisor] + domain_description: "Supervisor can see notes for team cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Note - manager + - id: rule_spp_case_note_manager + model: spp.case.note + groups: [group_case_manager] + domain_description: "Manager can see all notes" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: true + + # Referral - worker + - id: rule_spp_case_referral_worker + model: spp.case.referral + groups: [group_case_worker] + domain_description: "Worker can only see referrals for own cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Referral - supervisor + - id: rule_spp_case_referral_supervisor + model: spp.case.referral + groups: [group_case_supervisor] + domain_description: "Supervisor can see referrals for team cases" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: false + + # Referral - manager + - id: rule_spp_case_referral_manager + model: spp.case.referral + groups: [group_case_manager] + domain_description: "Manager can see all referrals" + perm_read: true + perm_write: true + perm_create: true + perm_unlink: true + +# ============================================================================= +# MENU VISIBILITY +# Which groups can see which menus +# ============================================================================= + +menus: + # Main case management menu + - id: menu_case_management_root + name: "Case Management" + groups: [] # No explicit restriction - visibility via child menus + + # Cases submenu + - id: menu_case_management_cases + name: "Cases" + parent: menu_case_management_root + groups: [] + + - id: menu_case_my_cases + name: "My Cases" + parent: menu_case_management_cases + groups: [] + + - id: menu_case_unassigned + name: "Unassigned" + parent: menu_case_management_cases + groups: [] + + - id: menu_case_all + name: "All Cases" + parent: menu_case_management_cases + groups: [] + + # Activities submenu + - id: menu_case_management_activities + name: "Activities" + parent: menu_case_management_root + groups: [] + + - id: menu_case_visits + name: "Visits" + parent: menu_case_management_activities + groups: [] + + - id: menu_case_notes + name: "Notes" + parent: menu_case_management_activities + groups: [] + + - id: menu_case_referrals + name: "Referrals" + parent: menu_case_management_activities + groups: [] + + - id: menu_case_assessment + name: "Assessments" + parent: menu_case_management_activities + groups: [] + + # Planning submenu + - id: menu_case_management_planning + name: "Planning" + parent: menu_case_management_root + groups: [] + + - id: menu_case_intervention_plans + name: "Intervention Plans" + parent: menu_case_management_planning + groups: [] + + - id: menu_case_interventions + name: "Interventions" + parent: menu_case_management_planning + groups: [] + + # Configuration submenu - managers only + - id: menu_case_management_config + name: "Configuration" + parent: menu_case_management_root + groups: [group_case_manager] + + - id: menu_case_config_case_setup + name: "Case Setup" + parent: menu_case_management_config + groups: [] + + - id: menu_case_types + name: "Case Types" + parent: menu_case_config_case_setup + groups: [] + + - id: menu_case_stages + name: "Stages" + parent: menu_case_config_case_setup + groups: [] + + - id: menu_case_teams + name: "Teams" + parent: menu_case_config_case_setup + groups: [] + + - id: menu_case_config_assessment + name: "Assessment" + parent: menu_case_management_config + groups: [] + + - id: menu_case_risk_factors + name: "Risk Factors" + parent: menu_case_config_assessment + groups: [] + + - id: menu_case_vulnerabilities + name: "Vulnerabilities" + parent: menu_case_config_assessment + groups: [] + + - id: menu_case_config_closure + name: "Closure" + parent: menu_case_management_config + groups: [] + + - id: menu_case_closure_reasons + name: "Closure Reasons" + parent: menu_case_config_closure + groups: [] + +# ============================================================================= +# FIELD RESTRICTIONS +# Fields with group-based visibility in views +# ============================================================================= + +field_restrictions: [] + +# ============================================================================= +# ACTION RESTRICTIONS +# Actions that are restricted to specific groups +# ============================================================================= + +actions: [] +# ============================================================================= +# NOTES +# ============================================================================= + +# Role Hierarchy: +# - Viewer: Read-only access to cases (via UI, limited by record rules) +# - Worker: Manage own assigned cases and activities +# - Supervisor: Manage team cases and approve plans +# - Officer: Edit all case records (standard role) +# - Manager: Full access including configuration and delete +# +# Record Rules: +# - Worker: Can only see cases where case_worker_id = user.id +# - Supervisor: Can see cases where supervisor_id = user.id or team supervisor +# - Manager: Can see all cases (domain: [(1, '=', 1)]) diff --git a/spp_case_base/security/groups.xml b/spp_case_base/security/groups.xml new file mode 100644 index 00000000..8e86d8db --- /dev/null +++ b/spp_case_base/security/groups.xml @@ -0,0 +1,74 @@ + + + + + + + + Case: Read + Technical group for read access to case models. + + + + Case: Write + Technical group for write access to case models. + + + + + + Viewer + + Case Management: View all cases (read-only access) + + + + + Officer + + Case Management: Can view and edit case records. Cannot delete. + + + + + + Worker + + Case Management: Manage own cases and case activities. Positioned between Viewer and Officer in the permission hierarchy. Inherits viewer permissions. + + + + + + Supervisor + + Case Management: Supervise team cases and approve plans. Positioned between Officer and Manager in the permission hierarchy. Inherits worker permissions. + + + + + Manager + + Case Management: Full access including configuration and delete operations. + + + + + + + + diff --git a/spp_case_base/security/ir.model.access.csv b/spp_case_base/security/ir.model.access.csv new file mode 100644 index 00000000..f53b479e --- /dev/null +++ b/spp_case_base/security/ir.model.access.csv @@ -0,0 +1,53 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_case_manager,access_spp_case_manager,model_spp_case,group_case_manager,1,1,1,1 +access_spp_case_supervisor,access_spp_case_supervisor,model_spp_case,group_case_supervisor,1,1,1,1 +access_spp_case_worker,access_spp_case_worker,model_spp_case,group_case_worker,1,1,1,1 +access_spp_case_viewer,access_spp_case_viewer,model_spp_case,group_case_viewer,1,0,0,0 +access_spp_case_assessment_manager,access_spp_case_assessment_manager,model_spp_case_assessment,group_case_manager,1,1,1,1 +access_spp_case_assessment_supervisor,access_spp_case_assessment_supervisor,model_spp_case_assessment,group_case_supervisor,1,1,1,1 +access_spp_case_assessment_worker,access_spp_case_assessment_worker,model_spp_case_assessment,group_case_worker,1,1,1,1 +access_spp_case_assessment_viewer,access_spp_case_assessment_viewer,model_spp_case_assessment,group_case_viewer,1,0,0,0 +access_spp_case_stage_manager,access_spp_case_stage_manager,model_spp_case_stage,group_case_manager,1,1,1,1 +access_spp_case_stage_supervisor,access_spp_case_stage_supervisor,model_spp_case_stage,group_case_supervisor,1,0,0,0 +access_spp_case_stage_worker,access_spp_case_stage_worker,model_spp_case_stage,group_case_worker,1,0,0,0 +access_spp_case_stage_viewer,access_spp_case_stage_viewer,model_spp_case_stage,group_case_viewer,1,0,0,0 +access_spp_case_type_manager,access_spp_case_type_manager,model_spp_case_type,group_case_manager,1,1,1,1 +access_spp_case_type_supervisor,access_spp_case_type_supervisor,model_spp_case_type,group_case_supervisor,1,0,0,0 +access_spp_case_type_worker,access_spp_case_type_worker,model_spp_case_type,group_case_worker,1,0,0,0 +access_spp_case_type_viewer,access_spp_case_type_viewer,model_spp_case_type,group_case_viewer,1,0,0,0 +access_spp_case_intervention_plan_manager,access_spp_case_intervention_plan_manager,model_spp_case_intervention_plan,group_case_manager,1,1,1,1 +access_spp_case_intervention_plan_supervisor,access_spp_case_intervention_plan_supervisor,model_spp_case_intervention_plan,group_case_supervisor,1,1,1,1 +access_spp_case_intervention_plan_worker,access_spp_case_intervention_plan_worker,model_spp_case_intervention_plan,group_case_worker,1,1,1,1 +access_spp_case_intervention_plan_viewer,access_spp_case_intervention_plan_viewer,model_spp_case_intervention_plan,group_case_viewer,1,0,0,0 +access_spp_case_intervention_manager,access_spp_case_intervention_manager,model_spp_case_intervention,group_case_manager,1,1,1,1 +access_spp_case_intervention_supervisor,access_spp_case_intervention_supervisor,model_spp_case_intervention,group_case_supervisor,1,1,1,1 +access_spp_case_intervention_worker,access_spp_case_intervention_worker,model_spp_case_intervention,group_case_worker,1,1,1,1 +access_spp_case_intervention_viewer,access_spp_case_intervention_viewer,model_spp_case_intervention,group_case_viewer,1,0,0,0 +access_spp_case_visit_manager,access_spp_case_visit_manager,model_spp_case_visit,group_case_manager,1,1,1,1 +access_spp_case_visit_supervisor,access_spp_case_visit_supervisor,model_spp_case_visit,group_case_supervisor,1,1,1,1 +access_spp_case_visit_worker,access_spp_case_visit_worker,model_spp_case_visit,group_case_worker,1,1,1,1 +access_spp_case_visit_viewer,access_spp_case_visit_viewer,model_spp_case_visit,group_case_viewer,1,0,0,0 +access_spp_case_note_manager,access_spp_case_note_manager,model_spp_case_note,group_case_manager,1,1,1,1 +access_spp_case_note_supervisor,access_spp_case_note_supervisor,model_spp_case_note,group_case_supervisor,1,1,1,1 +access_spp_case_note_worker,access_spp_case_note_worker,model_spp_case_note,group_case_worker,1,1,1,1 +access_spp_case_note_viewer,access_spp_case_note_viewer,model_spp_case_note,group_case_viewer,1,0,0,0 +access_spp_case_referral_manager,access_spp_case_referral_manager,model_spp_case_referral,group_case_manager,1,1,1,1 +access_spp_case_referral_supervisor,access_spp_case_referral_supervisor,model_spp_case_referral,group_case_supervisor,1,1,1,1 +access_spp_case_referral_worker,access_spp_case_referral_worker,model_spp_case_referral,group_case_worker,1,1,1,1 +access_spp_case_referral_viewer,access_spp_case_referral_viewer,model_spp_case_referral,group_case_viewer,1,0,0,0 +access_spp_case_risk_factor_manager,access_spp_case_risk_factor_manager,model_spp_case_risk_factor,group_case_manager,1,1,1,1 +access_spp_case_risk_factor_supervisor,access_spp_case_risk_factor_supervisor,model_spp_case_risk_factor,group_case_supervisor,1,0,0,0 +access_spp_case_risk_factor_worker,access_spp_case_risk_factor_worker,model_spp_case_risk_factor,group_case_worker,1,0,0,0 +access_spp_case_risk_factor_viewer,access_spp_case_risk_factor_viewer,model_spp_case_risk_factor,group_case_viewer,1,0,0,0 +access_spp_case_vulnerability_manager,access_spp_case_vulnerability_manager,model_spp_case_vulnerability,group_case_manager,1,1,1,1 +access_spp_case_vulnerability_supervisor,access_spp_case_vulnerability_supervisor,model_spp_case_vulnerability,group_case_supervisor,1,0,0,0 +access_spp_case_vulnerability_worker,access_spp_case_vulnerability_worker,model_spp_case_vulnerability,group_case_worker,1,0,0,0 +access_spp_case_vulnerability_viewer,access_spp_case_vulnerability_viewer,model_spp_case_vulnerability,group_case_viewer,1,0,0,0 +access_spp_case_closure_reason_manager,access_spp_case_closure_reason_manager,model_spp_case_closure_reason,group_case_manager,1,1,1,1 +access_spp_case_closure_reason_supervisor,access_spp_case_closure_reason_supervisor,model_spp_case_closure_reason,group_case_supervisor,1,0,0,0 +access_spp_case_closure_reason_worker,access_spp_case_closure_reason_worker,model_spp_case_closure_reason,group_case_worker,1,0,0,0 +access_spp_case_closure_reason_viewer,access_spp_case_closure_reason_viewer,model_spp_case_closure_reason,group_case_viewer,1,0,0,0 +access_spp_case_team_manager,access_spp_case_team_manager,model_spp_case_team,group_case_manager,1,1,1,1 +access_spp_case_team_supervisor,access_spp_case_team_supervisor,model_spp_case_team,group_case_supervisor,1,0,0,0 +access_spp_case_team_worker,access_spp_case_team_worker,model_spp_case_team,group_case_worker,1,0,0,0 +access_spp_case_team_viewer,access_spp_case_team_viewer,model_spp_case_team,group_case_viewer,1,0,0,0 diff --git a/spp_case_base/security/privileges.xml b/spp_case_base/security/privileges.xml new file mode 100644 index 00000000..57fb3fab --- /dev/null +++ b/spp_case_base/security/privileges.xml @@ -0,0 +1,11 @@ + + + + + + + Case Management + + Access to case management and social work + + diff --git a/spp_case_base/security/rules.xml b/spp_case_base/security/rules.xml new file mode 100644 index 00000000..ce393bb0 --- /dev/null +++ b/spp_case_base/security/rules.xml @@ -0,0 +1,221 @@ + + + + + + + + Case: Worker Own Cases Only + + [('case_worker_id', '=', user.id)] + + + + + + + + + Case: Supervisor Team Cases + + ['|', ('supervisor_id', '=', user.id), ('team_id.supervisor_id', '=', user.id)] + + + + + + + + + Case: Manager All Cases + + [(1, '=', 1)] + + + + + + + + + + Case Intervention Plan: Worker Own Case Plans Only + + [('case_id.case_worker_id', '=', user.id)] + + + + + + + + + Case Intervention Plan: Supervisor Team Case Plans + + ['|', ('case_id.supervisor_id', '=', user.id), ('case_id.team_id.supervisor_id', '=', user.id)] + + + + + + + + + Case Intervention Plan: Manager All Case Plans + + [(1, '=', 1)] + + + + + + + + + + Case Intervention: Worker Own Case Interventions Only + + [('case_id.case_worker_id', '=', user.id)] + + + + + + + + + Case Intervention: Supervisor Team Case Interventions + + ['|', ('case_id.supervisor_id', '=', user.id), ('case_id.team_id.supervisor_id', '=', user.id)] + + + + + + + + + Case Intervention: Manager All Case Interventions + + [(1, '=', 1)] + + + + + + + + + + Case Visit: Worker Own Case Visits Only + + [('case_id.case_worker_id', '=', user.id)] + + + + + + + + + Case Visit: Supervisor Team Case Visits + + ['|', ('case_id.supervisor_id', '=', user.id), ('case_id.team_id.supervisor_id', '=', user.id)] + + + + + + + + + Case Visit: Manager All Case Visits + + [(1, '=', 1)] + + + + + + + + + + Case Note: Worker Own Case Notes Only + + [('case_id.case_worker_id', '=', user.id)] + + + + + + + + + Case Note: Supervisor Team Case Notes + + ['|', ('case_id.supervisor_id', '=', user.id), ('case_id.team_id.supervisor_id', '=', user.id)] + + + + + + + + + Case Note: Manager All Case Notes + + [(1, '=', 1)] + + + + + + + + + + Case Referral: Worker Own Case Referrals Only + + [('case_id.case_worker_id', '=', user.id)] + + + + + + + + + Case Referral: Supervisor Team Case Referrals + + ['|', ('case_id.supervisor_id', '=', user.id), ('case_id.team_id.supervisor_id', '=', user.id)] + + + + + + + + + Case Referral: Manager All Case Referrals + + [(1, '=', 1)] + + + + + + + diff --git a/spp_case_base/static/description/OpenSPP-Case-Management-Icons.png b/spp_case_base/static/description/OpenSPP-Case-Management-Icons.png new file mode 100644 index 00000000..3d131499 Binary files /dev/null and b/spp_case_base/static/description/OpenSPP-Case-Management-Icons.png differ diff --git a/spp_case_base/static/description/icon.png b/spp_case_base/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_case_base/static/description/icon.png differ diff --git a/spp_case_base/static/description/index.html b/spp_case_base/static/description/index.html new file mode 100644 index 00000000..135d3408 --- /dev/null +++ b/spp_case_base/static/description/index.html @@ -0,0 +1,570 @@ + + + + + +OpenSPP Case Management Base + + + +
+

OpenSPP Case Management Base

+ + +

Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

+

Case management system for social protection programs. Tracks cases from +intake through assessment, intervention planning, and closure with +workflow stages, risk assessments, and team assignment. Automated review +scheduling via cron job ensures timely case monitoring.

+
+

Key Capabilities

+
    +
  • Track cases for individuals, households, or groups with configurable +types and workflow stages
  • +
  • Conduct assessments with risk scoring (0-100) and automatic risk level +classification (low/medium/high/critical)
  • +
  • Create versioned intervention plans with approval workflows and +progress tracking
  • +
  • Document case activities: visits, notes, referrals to external +services
  • +
  • Assign cases to workers and teams with supervisor oversight
  • +
  • Schedule automated review reminders for cases approaching or past +review dates
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelDescription
spp.caseCore case record with client and +assignment
spp.case.typeCase type with default intensity +and caseload
spp.case.stageWorkflow stage with phase and +requirements
spp.case.assessmentAssessment with risk score and +findings
spp.case.intervention.planVersioned plan with approval +workflow
spp.case.interventionIndividual intervention with status +tracking
spp.case.visitClient visit with type and notes
spp.case.noteCase note with confidentiality flag
spp.case.referralExternal service referral with +status
spp.case.teamTeam with supervisor and members
spp.case.risk.factorRisk factor with severity weight
spp.case.vulnerabilityVulnerability for assessment
spp.case.closure.reasonClosure reason with outcome type
+
+
+

Configuration

+

After installing:

+
    +
  1. Navigate to Case Management > Configuration > Case Setup > Case +Types and create case types
  2. +
  3. Navigate to Case Management > Configuration > Case Setup > Case +Stages and define workflow stages
  4. +
  5. Navigate to Case Management > Configuration > Case Setup > Case +Teams and create teams
  6. +
  7. Navigate to Case Management > Configuration > Assessment > Risk +Factors and define risk factors
  8. +
  9. Navigate to Case Management > Configuration > Assessment > +Vulnerabilities and define vulnerabilities
  10. +
  11. Navigate to Case Management > Configuration > Closure > Closure +Reasons and set up closure reasons
  12. +
  13. Verify the cron job Case Management: Check Review Schedules is +active under Settings > Technical > Scheduled Actions
  14. +
+
+
+

UI Location

+
    +
  • Cases: Case Management > Cases > All Cases / My Cases / Unassigned +Cases
  • +
  • Activities: Case Management > Activities > Visits / Notes / +Referrals / Assessments
  • +
  • Planning: Case Management > Planning > Intervention Plans / +Interventions
  • +
  • Configuration: Case Management > Configuration (Manager role +required)
  • +
  • Form tabs: Details, Participants, Programs, History
  • +
+
+
+

Security

+ ++++ + + + + + + + + + + + + + + + + + + + +
GroupAccess
group_case_viewerRead-only access to all case records
group_case_workerFull CRUD on cases and activities
group_case_supervisorFull CRUD on cases and activities, read config
group_case_managerFull CRUD including configuration
+
+
+

Extension Points

+
    +
  • Override _check_stage_requirements() on spp.case for custom +stage validation
  • +
  • Override _compute_risk_level() on spp.case.assessment to +customize risk calculation thresholds
  • +
  • Extend spp.case.intervention.plan with domain-specific fields
  • +
  • Hook _cron_check_reviews() to add custom review logic or +notification templates
  • +
+
+
+

Dependencies

+

base, mail, portal, spp_security

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123 emjay0921

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_case_base/tests/__init__.py b/spp_case_base/tests/__init__.py new file mode 100644 index 00000000..b35a4ebf --- /dev/null +++ b/spp_case_base/tests/__init__.py @@ -0,0 +1,8 @@ +"""Tests for spp_case_base module.""" + +from . import test_case +from . import test_case_intervention_plan +from . import test_case_intervention +from . import test_case_security +from . import test_compliance_generated +from . import test_case_models diff --git a/spp_case_base/tests/test_case.py b/spp_case_base/tests/test_case.py new file mode 100644 index 00000000..3c0e161f --- /dev/null +++ b/spp_case_base/tests/test_case.py @@ -0,0 +1,405 @@ +"""Test cases for spp.case model.""" + +from datetime import date, timedelta + +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCase(TransactionCase): + """Test the main spp.case model.""" + + @classmethod + def setUpClass(cls): + """Set up test data for all test methods.""" + super().setUpClass() + + # Create test users (include base.group_user for internal user access) + cls.case_worker = cls.env["res.users"].create( + { + "name": "Test Case Worker", + "login": "test_worker", + "email": "worker@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + + cls.supervisor = cls.env["res.users"].create( + { + "name": "Test Supervisor", + "login": "test_supervisor", + "email": "supervisor@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_supervisor").id), + ], + } + ) + + # Create test partner (client) + cls.client = cls.env["res.partner"].create( + { + "name": "Test Client", + "email": "client@test.com", + } + ) + + # Create case type + cls.case_type = cls.env["spp.case.type"].create( + { + "name": "Social Protection", + "code": "SP001", + "domain": "social_protection", + "default_intensity": "2", + } + ) + + # Create case stages + cls.stage_intake = cls.env["spp.case.stage"].create( + { + "name": "Intake", + "sequence": 1, + "phase": "intake", + "is_closed": False, + } + ) + + cls.stage_planning = cls.env["spp.case.stage"].create( + { + "name": "Planning", + "sequence": 2, + "phase": "planning", + "is_requires_plan": True, + "is_closed": False, + } + ) + + cls.stage_closed = cls.env["spp.case.stage"].create( + { + "name": "Closed", + "sequence": 99, + "phase": "closure", + "is_closed": True, + } + ) + + # Create case team + cls.team = cls.env["spp.case.team"].create( + { + "name": "Test Team", + "supervisor_id": cls.supervisor.id, + "member_ids": [Command.set([cls.case_worker.id])], + } + ) + + def test_create_case(self): + """Test creating a case and verify defaults.""" + case = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Client needs assistance

", + } + ) + + self.assertTrue(case.name, "Case number should be generated") + self.assertTrue(case.name.startswith("CASE-"), "Case number should have prefix") + self.assertEqual(case.intensity_level, "2", "Default intensity should be level 2") + self.assertEqual(case.priority, "medium", "Default priority should be medium") + self.assertEqual(case.client_type, "individual", "Default client type should be individual") + self.assertEqual(case.opened_date, date.today(), "Opened date should be today") + self.assertTrue(case.is_active, "Case should be active") + + def test_case_number_generation(self): + """Test that case numbers are auto-generated and unique.""" + case1 = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Issue 1

", + } + ) + + case2 = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Issue 2

", + } + ) + + self.assertTrue(case1.name, "Case 1 should have a number") + self.assertTrue(case2.name, "Case 2 should have a number") + self.assertNotEqual(case1.name, case2.name, "Case numbers should be unique") + + def test_case_stage_transition(self): + """Test stage changes.""" + case = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Test issue

", + } + ) + + # Verify initial stage + self.assertEqual(case.stage_id, self.stage_intake) + self.assertTrue(case.is_active, "Case should be active in intake stage") + + # Try moving to stage that requires plan (should fail) + with self.assertRaises( + ValidationError, + msg="Should not allow transition to stage requiring plan without plan", + ): + case.stage_id = self.stage_planning + + def test_case_close_reopen(self): + """Test closure and reopening of cases.""" + case = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Test issue

", + } + ) + + # Close the case + case.action_close_case() + + self.assertEqual(case.stage_id, self.stage_closed, "Case should be in closed stage") + self.assertFalse(case.is_active, "Case should not be active when closed") + self.assertEqual( + case.actual_closure_date, + date.today(), + "Closure date should be today", + ) + + # Reopen the case + case.action_reopen_case() + + self.assertEqual(case.stage_id, self.stage_intake, "Case should be back in intake stage") + self.assertTrue(case.is_active, "Case should be active after reopening") + self.assertFalse(case.actual_closure_date, "Closure date should be cleared after reopening") + + def test_days_open_computation(self): + """Test days_open computed field.""" + # Create case with opened_date in the past + past_date = date.today() - timedelta(days=10) + case = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "opened_date": past_date, + "presenting_issue": "

Test issue

", + } + ) + + # Compute days_open + case._compute_days_open() + + self.assertEqual( + case.days_open, + 10, + "Days open should be 10 for case opened 10 days ago", + ) + + # Close the case + case.write( + { + "stage_id": self.stage_closed.id, + "actual_closure_date": date.today(), + } + ) + + # Recompute days_open + case._compute_days_open() + + self.assertEqual( + case.days_open, + 10, + "Days open should be 10 (from open to close)", + ) + + def test_intensity_level_constraints(self): + """Test intensity level validation with stages.""" + # Create stage requiring minimum intensity + high_intensity_stage = self.env["spp.case.stage"].create( + { + "name": "High Intensity Stage", + "phase": "implementation", + "min_intensity": "3", + } + ) + + # Create case with low intensity + case = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "intensity_level": "1", + "presenting_issue": "

Test issue

", + } + ) + + # Try to move to high intensity stage (should fail) + with self.assertRaises( + ValidationError, + msg="Should not allow transition to high intensity stage with low intensity case", + ): + case.stage_id = high_intensity_stage + + # Increase intensity and try again + case.intensity_level = "3" + case.stage_id = high_intensity_stage + self.assertEqual( + case.stage_id, + high_intensity_stage, + "Should allow transition after increasing intensity", + ) + + def test_onchange_case_type(self): + """Test that changing case type sets default intensity.""" + case = self.env["spp.case"].new( + { + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Test issue

", + } + ) + + # Set case type + case.case_type_id = self.case_type + case._onchange_case_type_id() + + self.assertEqual( + case.intensity_level, + self.case_type.default_intensity, + "Intensity should match case type default", + ) + + def test_onchange_team(self): + """Test that changing team sets supervisor.""" + case = self.env["spp.case"].new( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Test issue

", + } + ) + + # Set team + case.team_id = self.team + case._onchange_team_id() + + self.assertEqual( + case.supervisor_id, + self.team.supervisor_id, + "Supervisor should match team supervisor", + ) + + def test_has_active_plan_computation(self): + """Test has_active_plan computed field.""" + case = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Test issue

", + } + ) + + # Case should not have active plan initially + case._compute_has_active_plan() + self.assertFalse(case.has_active_plan, "New case should not have active plan") + + # Create an approved plan + self.env["spp.case.intervention.plan"].create( + { + "case_id": case.id, + "name": "Test Plan", + "goals": "

Test goals

", + "state": "approved", + "is_current": True, + } + ) + + # Recompute + case._compute_has_active_plan() + self.assertTrue(case.has_active_plan, "Case should have active plan") + + def test_current_plan_computation(self): + """Test current_plan_id computed field.""" + case = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Test issue

", + } + ) + + # Create a current plan + current_plan = self.env["spp.case.intervention.plan"].create( + { + "case_id": case.id, + "name": "Current Plan", + "goals": "

Test goals

", + "is_current": True, + } + ) + + # Create an old plan + self.env["spp.case.intervention.plan"].create( + { + "case_id": case.id, + "name": "Old Plan", + "goals": "

Old goals

", + "is_current": False, + } + ) + + # Compute current plan + case._compute_current_plan() + self.assertEqual( + case.current_plan_id, + current_plan, + "Should return the current plan", + ) + + def test_is_active_computation(self): + """Test is_active computed field based on stage.""" + case = self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Test issue

", + } + ) + + # Should be active in intake stage + case._compute_is_active() + self.assertTrue(case.is_active, "Case should be active in open stage") + + # Move to closed stage + case.stage_id = self.stage_closed + + # Should not be active in closed stage + case._compute_is_active() + self.assertFalse(case.is_active, "Case should not be active in closed stage") diff --git a/spp_case_base/tests/test_case_intervention.py b/spp_case_base/tests/test_case_intervention.py new file mode 100644 index 00000000..f56f619e --- /dev/null +++ b/spp_case_base/tests/test_case_intervention.py @@ -0,0 +1,436 @@ +"""Test cases for spp.case.intervention model.""" + +from datetime import date, timedelta + +from odoo import Command +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCaseIntervention(TransactionCase): + """Test the intervention model.""" + + @classmethod + def setUpClass(cls): + """Set up test data for all test methods.""" + super().setUpClass() + + # Create test users (include base.group_user for internal user access) + cls.case_worker = cls.env["res.users"].create( + { + "name": "Test Case Worker", + "login": "test_worker_intervention", + "email": "worker_intervention@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + + # Create test partner (client) + cls.client = cls.env["res.partner"].create( + { + "name": "Test Client Intervention", + "email": "client_intervention@test.com", + } + ) + + # Create service provider + cls.provider = cls.env["res.partner"].create( + { + "name": "Service Provider", + "email": "provider@test.com", + } + ) + + # Create case type + cls.case_type = cls.env["spp.case.type"].create( + { + "name": "Social Protection Intervention", + "code": "SPI001", + "domain": "social_protection", + } + ) + + # Create test case + cls.case = cls.env["spp.case"].create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client.id, + "case_worker_id": cls.case_worker.id, + "presenting_issue": "

Client needs assistance

", + } + ) + + # Create test plan + cls.plan = cls.env["spp.case.intervention.plan"].create( + { + "name": "Test Plan", + "case_id": cls.case.id, + "goals": "

Help client achieve goals

", + } + ) + + def test_create_intervention(self): + """Test creating an intervention and verify defaults.""" + intervention = self.env["spp.case.intervention"].create( + { + "name": "Job Training", + "plan_id": self.plan.id, + "description": "Enroll client in job training program", + "target_outcome": "Client completes job training", + } + ) + + self.assertEqual(intervention.state, "planned", "New intervention should be in planned state") + self.assertEqual( + intervention.responsible, + "joint", + "Default responsible party should be joint", + ) + self.assertEqual( + intervention.case_id, + self.case, + "Case should be related through plan", + ) + self.assertEqual(intervention.sequence, 10, "Default sequence should be 10") + + def test_intervention_completion(self): + """Test intervention completion workflow.""" + intervention = self.env["spp.case.intervention"].create( + { + "name": "Test Intervention", + "plan_id": self.plan.id, + "state": "planned", + } + ) + + # Start the intervention + intervention.action_start() + self.assertEqual( + intervention.state, + "in_progress", + "Intervention should be in progress after start", + ) + + # Complete the intervention + intervention.action_complete() + self.assertEqual( + intervention.state, + "completed", + "Intervention should be completed", + ) + self.assertEqual( + intervention.completed_date, + date.today(), + "Completed date should be today", + ) + + def test_intervention_not_completed(self): + """Test marking intervention as not completed.""" + intervention = self.env["spp.case.intervention"].create( + { + "name": "Test Intervention", + "plan_id": self.plan.id, + "state": "in_progress", + } + ) + + # Mark as not completed + intervention.action_mark_not_completed() + self.assertEqual( + intervention.state, + "not_completed", + "Intervention should be marked as not completed", + ) + + def test_intervention_cancel(self): + """Test cancelling an intervention.""" + intervention = self.env["spp.case.intervention"].create( + { + "name": "Test Intervention", + "plan_id": self.plan.id, + "state": "planned", + } + ) + + # Cancel the intervention + intervention.action_cancel() + self.assertEqual(intervention.state, "cancelled", "Intervention should be cancelled") + + def test_intervention_reset(self): + """Test resetting an intervention back to planned.""" + intervention = self.env["spp.case.intervention"].create( + { + "name": "Test Intervention", + "plan_id": self.plan.id, + "state": "completed", + "completed_date": date.today(), + } + ) + + # Reset the intervention + intervention.action_reset() + self.assertEqual(intervention.state, "planned", "Intervention should be reset to planned") + self.assertFalse( + intervention.completed_date, + "Completed date should be cleared", + ) + + def test_case_id_related_field(self): + """Test that case_id is properly related through plan.""" + intervention = self.env["spp.case.intervention"].create( + { + "name": "Test Intervention", + "plan_id": self.plan.id, + } + ) + + # Verify case is accessible through related field + self.assertEqual( + intervention.case_id, + self.case, + "Case should be accessible through plan", + ) + self.assertEqual( + intervention.case_id.partner_id, + self.client, + "Should be able to access case fields through related field", + ) + + def test_is_overdue_computation(self): + """Test is_overdue computed field.""" + # Create intervention with past target date + past_date = date.today() - timedelta(days=5) + intervention = self.env["spp.case.intervention"].create( + { + "name": "Overdue Intervention", + "plan_id": self.plan.id, + "target_date": past_date, + "state": "in_progress", + } + ) + + # Compute is_overdue + intervention._compute_is_overdue() + self.assertTrue( + intervention.is_overdue, + "Intervention with past target date should be overdue", + ) + + # Create intervention with future target date + future_date = date.today() + timedelta(days=5) + intervention2 = self.env["spp.case.intervention"].create( + { + "name": "Future Intervention", + "plan_id": self.plan.id, + "target_date": future_date, + "state": "planned", + } + ) + + intervention2._compute_is_overdue() + self.assertFalse( + intervention2.is_overdue, + "Intervention with future target date should not be overdue", + ) + + # Completed interventions should not be overdue + intervention3 = self.env["spp.case.intervention"].create( + { + "name": "Completed Intervention", + "plan_id": self.plan.id, + "target_date": past_date, + "state": "completed", + } + ) + + intervention3._compute_is_overdue() + self.assertFalse( + intervention3.is_overdue, + "Completed intervention should not be overdue", + ) + + def test_days_until_due_computation(self): + """Test days_until_due computed field.""" + # Create intervention due in 10 days + future_date = date.today() + timedelta(days=10) + intervention = self.env["spp.case.intervention"].create( + { + "name": "Future Intervention", + "plan_id": self.plan.id, + "target_date": future_date, + } + ) + + intervention._compute_days_until_due() + self.assertEqual( + intervention.days_until_due, + 10, + "Should show 10 days until due", + ) + + # Create intervention that was due 5 days ago + past_date = date.today() - timedelta(days=5) + intervention2 = self.env["spp.case.intervention"].create( + { + "name": "Past Intervention", + "plan_id": self.plan.id, + "target_date": past_date, + } + ) + + intervention2._compute_days_until_due() + self.assertEqual( + intervention2.days_until_due, + -5, + "Should show -5 days (overdue by 5 days)", + ) + + def test_responsible_party_types(self): + """Test different responsible party types.""" + # Client responsible + intervention1 = self.env["spp.case.intervention"].create( + { + "name": "Client Task", + "plan_id": self.plan.id, + "responsible": "client", + } + ) + self.assertEqual(intervention1.responsible, "client") + + # Worker responsible + intervention2 = self.env["spp.case.intervention"].create( + { + "name": "Worker Task", + "plan_id": self.plan.id, + "responsible": "worker", + } + ) + self.assertEqual(intervention2.responsible, "worker") + + # Provider responsible + intervention3 = self.env["spp.case.intervention"].create( + { + "name": "Provider Task", + "plan_id": self.plan.id, + "responsible": "provider", + "provider_id": self.provider.id, + } + ) + self.assertEqual(intervention3.responsible, "provider") + self.assertEqual(intervention3.provider_id, self.provider) + + def test_onchange_responsible(self): + """Test that provider is cleared when responsible changes.""" + intervention = self.env["spp.case.intervention"].new( + { + "name": "Test Intervention", + "plan_id": self.plan.id, + "responsible": "provider", + "provider_id": self.provider.id, + } + ) + + # Change responsible to non-provider + intervention.responsible = "worker" + intervention._onchange_responsible() + + self.assertFalse( + intervention.provider_id, + "Provider should be cleared when responsible changes", + ) + + def test_intervention_sequence_ordering(self): + """Test that interventions are ordered by sequence.""" + # Create interventions with different sequences + intervention1 = self.env["spp.case.intervention"].create( + { + "name": "Third", + "plan_id": self.plan.id, + "sequence": 30, + } + ) + + intervention2 = self.env["spp.case.intervention"].create( + { + "name": "First", + "plan_id": self.plan.id, + "sequence": 10, + } + ) + + intervention3 = self.env["spp.case.intervention"].create( + { + "name": "Second", + "plan_id": self.plan.id, + "sequence": 20, + } + ) + + # Search interventions for this plan + interventions = self.env["spp.case.intervention"].search([("plan_id", "=", self.plan.id)]) + + # Verify order + self.assertEqual(interventions[0], intervention2, "First should be sequence 10") + self.assertEqual(interventions[1], intervention3, "Second should be sequence 20") + self.assertEqual(interventions[2], intervention1, "Third should be sequence 30") + + def test_intervention_without_target_date(self): + """Test intervention without target date.""" + intervention = self.env["spp.case.intervention"].create( + { + "name": "No Date Intervention", + "plan_id": self.plan.id, + } + ) + + intervention._compute_is_overdue() + intervention._compute_days_until_due() + + self.assertFalse( + intervention.is_overdue, + "Intervention without target date should not be overdue", + ) + self.assertEqual( + intervention.days_until_due, + 0, + "Intervention without target date should show 0 days", + ) + + def test_multiple_interventions_progress(self): + """Test that multiple interventions affect plan progress correctly.""" + # Create multiple interventions + interventions = [] + for i in range(5): + intervention = self.env["spp.case.intervention"].create( + { + "name": f"Intervention {i + 1}", + "plan_id": self.plan.id, + "state": "planned", + "sequence": (i + 1) * 10, + } + ) + interventions.append(intervention) + + # Verify all are planned + for intervention in interventions: + self.assertEqual(intervention.state, "planned") + + # Complete some interventions + interventions[0].action_complete() + interventions[1].action_complete() + + # Verify states + self.assertEqual(interventions[0].state, "completed") + self.assertEqual(interventions[1].state, "completed") + self.assertEqual(interventions[2].state, "planned") + + # Check plan progress + self.plan._compute_progress() + self.assertEqual( + self.plan.progress_percentage, + 40.0, + "Plan should be 40% complete (2 of 5)", + ) diff --git a/spp_case_base/tests/test_case_intervention_plan.py b/spp_case_base/tests/test_case_intervention_plan.py new file mode 100644 index 00000000..52dd8db0 --- /dev/null +++ b/spp_case_base/tests/test_case_intervention_plan.py @@ -0,0 +1,408 @@ +"""Test cases for spp.case.intervention.plan model.""" + +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCaseInterventionPlan(TransactionCase): + """Test the intervention plan model.""" + + @classmethod + def setUpClass(cls): + """Set up test data for all test methods.""" + super().setUpClass() + + # Create test users (include base.group_user for internal user access) + cls.case_worker = cls.env["res.users"].create( + { + "name": "Test Case Worker", + "login": "test_worker_plan", + "email": "worker_plan@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + + cls.supervisor = cls.env["res.users"].create( + { + "name": "Test Supervisor", + "login": "test_supervisor_plan", + "email": "supervisor_plan@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_supervisor").id), + ], + } + ) + + # Create test partner (client) + cls.client = cls.env["res.partner"].create( + { + "name": "Test Client Plan", + "email": "client_plan@test.com", + } + ) + + # Create case type + cls.case_type = cls.env["spp.case.type"].create( + { + "name": "Social Protection Plan", + "code": "SPP001", + "domain": "social_protection", + } + ) + + # Create test case (with supervisor assigned for approval workflow tests) + cls.case = cls.env["spp.case"].create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client.id, + "case_worker_id": cls.case_worker.id, + "supervisor_id": cls.supervisor.id, + "presenting_issue": "

Client needs assistance

", + } + ) + + def test_create_plan(self): + """Test creating a plan and verify defaults.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Test Intervention Plan", + "case_id": self.case.id, + "goals": "

Help client achieve self-sufficiency

", + "expected_outcomes": "

Client finds employment

", + } + ) + + self.assertEqual(plan.state, "draft", "New plan should be in draft state") + self.assertEqual(plan.version, 1, "New plan should be version 1") + self.assertTrue(plan.is_current, "New plan should be marked as current") + self.assertFalse(plan.approved_by_id, "Draft plan should not have approver") + self.assertFalse(plan.approved_date, "Draft plan should not have approval date") + + def test_auto_name_generation(self): + """Test that plan name is auto-generated if not provided.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "case_id": self.case.id, + "goals": "

Test goals

", + } + ) + + self.assertTrue(plan.name, "Plan name should be generated") + self.assertIn(self.case.name, plan.name, "Plan name should include case number") + self.assertIn("Plan v1", plan.name, "Plan name should include version") + + def test_plan_versioning(self): + """Test version creation.""" + # Create initial plan + plan_v1 = self.env["spp.case.intervention.plan"].create( + { + "name": "Initial Plan", + "case_id": self.case.id, + "goals": "

Version 1 goals

", + "state": "approved", + } + ) + + self.assertEqual(plan_v1.version, 1, "First plan should be version 1") + self.assertTrue(plan_v1.is_current, "First plan should be current") + + # Create revision + result = plan_v1.action_create_revision() + + # Get the new version + plan_v2 = self.env["spp.case.intervention.plan"].browse(result["res_id"]) + + self.assertEqual(plan_v2.version, 2, "New plan should be version 2") + self.assertTrue(plan_v2.is_current, "New plan should be current") + self.assertEqual(plan_v2.state, "draft", "New plan should be in draft state") + self.assertEqual( + plan_v2.previous_version_id, + plan_v1, + "New plan should reference previous version", + ) + + # Check that old plan is no longer current + self.assertFalse(plan_v1.is_current, "Old plan should not be current") + self.assertEqual(plan_v1.state, "revised", "Old plan should be revised") + + def test_plan_approval_workflow(self): + """Test state transitions through approval workflow.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Test Plan", + "case_id": self.case.id, + "goals": "

Test goals

", + } + ) + + # Create intervention (required for submission) + self.env["spp.case.intervention"].create( + { + "name": "Test Intervention", + "plan_id": plan.id, + "description": "Test intervention", + } + ) + + # Test draft -> pending_approval + self.assertEqual(plan.state, "draft") + plan.action_submit_for_approval() + self.assertEqual( + plan.state, + "pending_approval", + "Plan should be pending approval after submission", + ) + + # Test pending_approval -> approved + plan.with_user(self.supervisor).action_approve() + self.assertEqual(plan.state, "approved", "Plan should be approved after approval action") + self.assertEqual( + plan.approved_by_id, + self.supervisor, + "Approver should be recorded", + ) + self.assertTrue(plan.approved_date, "Approval date should be recorded") + + # Test approved -> active + plan.action_activate() + self.assertEqual(plan.state, "active", "Plan should be active after activation") + + # Test active -> completed + plan.action_complete() + self.assertEqual(plan.state, "completed", "Plan should be completed after completion action") + self.assertTrue(plan.actual_end_date, "Completion date should be recorded") + + def test_submit_without_interventions(self): + """Test that plan cannot be submitted without interventions.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Empty Plan", + "case_id": self.case.id, + "goals": "

Test goals

", + } + ) + + # Try to submit without interventions + with self.assertRaises( + ValidationError, + msg="Should not allow submission without interventions", + ): + plan.action_submit_for_approval() + + def test_activate_without_approval(self): + """Test that only approved plans can be activated.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Test Plan", + "case_id": self.case.id, + "goals": "

Test goals

", + } + ) + + # Try to activate draft plan + with self.assertRaises( + ValidationError, + msg="Should not allow activation of non-approved plan", + ): + plan.action_activate() + + def test_reset_to_draft(self): + """Test resetting plan back to draft.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Test Plan", + "case_id": self.case.id, + "goals": "

Test goals

", + } + ) + + # Create intervention and submit + self.env["spp.case.intervention"].create( + { + "name": "Test Intervention", + "plan_id": plan.id, + } + ) + + plan.action_submit_for_approval() + self.assertEqual(plan.state, "pending_approval") + + # Reset to draft + plan.action_reset_to_draft() + self.assertEqual(plan.state, "draft", "Plan should be reset to draft") + + def test_cannot_reset_completed_plan(self): + """Test that completed plans cannot be reset to draft.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Test Plan", + "case_id": self.case.id, + "goals": "

Test goals

", + "state": "completed", + } + ) + + # Try to reset completed plan + with self.assertRaises( + ValidationError, + msg="Should not allow resetting completed plan", + ): + plan.action_reset_to_draft() + + def test_progress_computation(self): + """Test progress percentage computation.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Test Plan", + "case_id": self.case.id, + "goals": "

Test goals

", + } + ) + + # Create 4 interventions + interventions = [] + for i in range(4): + intervention = self.env["spp.case.intervention"].create( + { + "name": f"Intervention {i + 1}", + "plan_id": plan.id, + "state": "planned", + } + ) + interventions.append(intervention) + + # Initially 0% complete + plan._compute_progress() + self.assertEqual(plan.progress_percentage, 0.0, "Progress should be 0% initially") + + # Complete 2 interventions (50%) + interventions[0].state = "completed" + interventions[1].state = "completed" + + plan._compute_progress() + self.assertEqual(plan.progress_percentage, 50.0, "Progress should be 50% with 2/4 complete") + + # Complete all interventions (100%) + interventions[2].state = "completed" + interventions[3].state = "completed" + + plan._compute_progress() + self.assertEqual( + plan.progress_percentage, + 100.0, + "Progress should be 100% with all complete", + ) + + def test_intervention_count_computation(self): + """Test intervention count computations.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Test Plan", + "case_id": self.case.id, + "goals": "

Test goals

", + } + ) + + # Create interventions with different states + self.env["spp.case.intervention"].create( + { + "name": "Intervention 1", + "plan_id": plan.id, + "state": "planned", + } + ) + + self.env["spp.case.intervention"].create( + { + "name": "Intervention 2", + "plan_id": plan.id, + "state": "completed", + } + ) + + self.env["spp.case.intervention"].create( + { + "name": "Intervention 3", + "plan_id": plan.id, + "state": "completed", + } + ) + + # Compute counts + plan._compute_intervention_count() + + self.assertEqual(plan.intervention_count, 3, "Should have 3 total interventions") + self.assertEqual( + plan.completed_intervention_count, + 2, + "Should have 2 completed interventions", + ) + + def test_single_current_plan_constraint(self): + """Test that only one plan can be current per case.""" + # Create first current plan + self.env["spp.case.intervention.plan"].create( + { + "name": "Plan 1", + "case_id": self.case.id, + "goals": "

Goals 1

", + "is_current": True, + } + ) + + # Try to create second current plan + with self.assertRaises( + ValidationError, + msg="Should not allow multiple current plans for same case", + ): + self.env["spp.case.intervention.plan"].create( + { + "name": "Plan 2", + "case_id": self.case.id, + "goals": "

Goals 2

", + "is_current": True, + } + ) + + # Create non-current plan (should work) + plan2 = self.env["spp.case.intervention.plan"].create( + { + "name": "Plan 2", + "case_id": self.case.id, + "goals": "

Goals 2

", + "is_current": False, + } + ) + + self.assertFalse(plan2.is_current, "Second plan should not be current") + + def test_plan_copy_behavior(self): + """Test that plan copy creates new version with proper defaults.""" + original_plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Original Plan", + "case_id": self.case.id, + "goals": "

Original goals

", + "version": 5, + "state": "approved", + "approved_by_id": self.supervisor.id, + } + ) + + # Use action_create_revision which uses copy internally + result = original_plan.action_create_revision() + new_plan = self.env["spp.case.intervention.plan"].browse(result["res_id"]) + + # Check that new version has correct values + self.assertEqual(new_plan.version, 6, "Version should be incremented") + self.assertEqual(new_plan.state, "draft", "Copy should be in draft") + self.assertFalse(new_plan.approved_by_id, "Copy should not have approver") + self.assertFalse(new_plan.approved_date, "Copy should not have approval date") + self.assertTrue(new_plan.is_current, "New version should be current") + self.assertFalse(original_plan.is_current, "Old version should not be current") diff --git a/spp_case_base/tests/test_case_models.py b/spp_case_base/tests/test_case_models.py new file mode 100644 index 00000000..095c716d --- /dev/null +++ b/spp_case_base/tests/test_case_models.py @@ -0,0 +1,529 @@ +"""Tests for case assessment, referral, note, and visit models.""" + +from datetime import timedelta + +from odoo import Command, fields +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCaseModels(TransactionCase): + """Tests for spp.case.assessment, spp.case.referral, spp.case.note, spp.case.visit.""" + + @classmethod + def setUpClass(cls): + """Set up test data shared across all test methods.""" + super().setUpClass() + + # Create test users + cls.case_worker = cls.env["res.users"].create( + { + "name": "Test Case Worker", + "login": "test_cm_worker", + "email": "cm_worker@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + + cls.supervisor = cls.env["res.users"].create( + { + "name": "Test CM Supervisor", + "login": "test_cm_supervisor", + "email": "cm_supervisor@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_supervisor").id), + ], + } + ) + + # Create test partner (client) + cls.client = cls.env["res.partner"].create( + { + "name": "Test CM Client", + "email": "cm_client@test.com", + } + ) + + # Create case type + cls.case_type = cls.env["spp.case.type"].create( + { + "name": "CM Test Type", + "code": "CMT001", + "domain": "social_protection", + "default_intensity": "2", + } + ) + + # Create case stage + cls.stage = cls.env["spp.case.stage"].create( + { + "name": "CM Test Intake", + "sequence": 1, + "phase": "intake", + "is_closed": False, + } + ) + + # Create a test case + cls.case = cls.env["spp.case"].create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client.id, + "case_worker_id": cls.case_worker.id, + "stage_id": cls.stage.id, + "presenting_issue": "

Test presenting issue

", + } + ) + + # ------------------------------------------------------------------------- + # spp.case.assessment tests + # ------------------------------------------------------------------------- + + def _make_assessment(self, **kwargs): + """Helper to create a case assessment with sensible defaults.""" + vals = { + "case_id": self.case.id, + "assessment_type": "periodic", + "assessment_date": fields.Date.today(), + "assessor_id": self.case_worker.id, + } + vals.update(kwargs) + return self.env["spp.case.assessment"].create(vals) + + def test_assessment_compute_name_complete(self): + """_compute_name produces a name when all fields are present.""" + assessment = self._make_assessment(assessment_type="intake") + # Name should include the case name, type label, and date + self.assertIn(self.case.name, assessment.name) + self.assertIn("Intake Assessment", assessment.name) + self.assertIn(str(fields.Date.today()), assessment.name) + + def test_assessment_compute_name_incomplete(self): + """_compute_name returns 'New Assessment' when required fields are missing.""" + # Use new() to get an unsaved record missing case_id + assessment = self.env["spp.case.assessment"].new( + { + "assessment_type": "periodic", + "assessment_date": fields.Date.today(), + } + ) + assessment._compute_name() + self.assertEqual(assessment.name, "New Assessment") + + def test_assessment_compute_risk_level_low(self): + """_compute_risk_level maps score 0-25 to 'low'.""" + assessment = self._make_assessment(risk_score=0) + self.assertEqual(assessment.risk_level, "low") + + assessment.risk_score = 25 + assessment._compute_risk_level() + self.assertEqual(assessment.risk_level, "low") + + def test_assessment_compute_risk_level_medium(self): + """_compute_risk_level maps score 26-50 to 'medium'.""" + assessment = self._make_assessment(risk_score=26) + self.assertEqual(assessment.risk_level, "medium") + + assessment.risk_score = 50 + assessment._compute_risk_level() + self.assertEqual(assessment.risk_level, "medium") + + def test_assessment_compute_risk_level_high(self): + """_compute_risk_level maps score 51-75 to 'high'.""" + assessment = self._make_assessment(risk_score=51) + self.assertEqual(assessment.risk_level, "high") + + assessment.risk_score = 75 + assessment._compute_risk_level() + self.assertEqual(assessment.risk_level, "high") + + def test_assessment_compute_risk_level_critical(self): + """_compute_risk_level maps score 76+ to 'critical'.""" + assessment = self._make_assessment(risk_score=76) + self.assertEqual(assessment.risk_level, "critical") + + assessment.risk_score = 100 + assessment._compute_risk_level() + self.assertEqual(assessment.risk_level, "critical") + + def test_assessment_compute_risk_level_negative_treated_as_low(self): + """_compute_risk_level treats a negative score as 'low' (defensive branch).""" + # Test the compute logic directly via new() to bypass the constraint. + record = self.env["spp.case.assessment"].new( + { + "case_id": self.case.id, + "assessment_type": "periodic", + "assessment_date": fields.Date.today(), + "risk_score": -5, + } + ) + record._compute_risk_level() + self.assertEqual(record.risk_level, "low") + + def test_assessment_check_risk_score_valid(self): + """_check_risk_score does not raise for scores within 0-100.""" + # Should not raise + assessment = self._make_assessment(risk_score=50) + self.assertEqual(assessment.risk_score, 50) + + def test_assessment_check_risk_score_too_high(self): + """_check_risk_score raises UserError when score > 100.""" + with self.assertRaises(UserError): + self._make_assessment(risk_score=101) + + def test_assessment_action_complete_from_draft(self): + """action_complete moves a draft assessment to completed.""" + assessment = self._make_assessment() + self.assertEqual(assessment.state, "draft") + + assessment.action_complete() + self.assertEqual(assessment.state, "completed") + + def test_assessment_action_complete_non_draft_raises(self): + """action_complete raises UserError if assessment is not in draft state.""" + assessment = self._make_assessment() + assessment.action_complete() # draft → completed + # Calling again (now completed) should fail + with self.assertRaises(UserError): + assessment.action_complete() + + def test_assessment_action_review_requires_supervisor(self): + """action_review raises UserError when called by a non-supervisor.""" + assessment = self._make_assessment() + assessment.action_complete() # → completed + + # Switch to case_worker context (not a supervisor) + env_worker = self.env(user=self.case_worker) + assessment_as_worker = assessment.with_env(env_worker) + with self.assertRaises(UserError): + assessment_as_worker.action_review() + + def test_assessment_action_review_by_supervisor(self): + """action_review moves completed assessment to reviewed when called by supervisor.""" + assessment = self._make_assessment() + assessment.action_complete() # → completed + + env_supervisor = self.env(user=self.supervisor) + assessment_as_supervisor = assessment.with_env(env_supervisor) + assessment_as_supervisor.action_review() + + self.assertEqual(assessment.state, "reviewed") + self.assertEqual(assessment.reviewed_by_id, self.supervisor) + self.assertTrue(assessment.reviewed_date) + + def test_assessment_action_review_non_completed_raises(self): + """action_review raises UserError if assessment is not completed.""" + assessment = self._make_assessment() + # Still in draft state + env_supervisor = self.env(user=self.supervisor) + assessment_as_supervisor = assessment.with_env(env_supervisor) + with self.assertRaises(UserError): + assessment_as_supervisor.action_review() + + def test_assessment_action_reset_to_draft(self): + """action_reset_to_draft returns a completed assessment to draft.""" + assessment = self._make_assessment() + assessment.action_complete() + self.assertEqual(assessment.state, "completed") + + assessment.action_reset_to_draft() + self.assertEqual(assessment.state, "draft") + self.assertFalse(assessment.reviewed_by_id) + self.assertFalse(assessment.reviewed_date) + + def test_assessment_action_reset_to_draft_reviewed_raises(self): + """action_reset_to_draft raises UserError when assessment is already reviewed.""" + assessment = self._make_assessment() + assessment.action_complete() + + env_supervisor = self.env(user=self.supervisor) + assessment.with_env(env_supervisor).action_review() + self.assertEqual(assessment.state, "reviewed") + + with self.assertRaises(UserError): + assessment.action_reset_to_draft() + + def test_assessment_action_view_case(self): + """action_view_case returns an act_window dict pointing to the case.""" + assessment = self._make_assessment() + result = assessment.action_view_case() + + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(result["res_model"], "spp.case") + self.assertEqual(result["res_id"], self.case.id) + self.assertEqual(result["view_mode"], "form") + + # ------------------------------------------------------------------------- + # spp.case.referral tests + # ------------------------------------------------------------------------- + + def _make_referral(self, **kwargs): + """Helper to create a case referral with sensible defaults.""" + vals = { + "case_id": self.case.id, + "service_name": "Test Service", + "reason": "Client needs this service", + "referral_date": fields.Date.today(), + "referred_by_id": self.case_worker.id, + } + vals.update(kwargs) + return self.env["spp.case.referral"].create(vals) + + def test_referral_compute_is_overdue_past_date_pending(self): + """_compute_is_overdue is True when follow_up_date is in the past and status is pending.""" + past_date = fields.Date.today() - timedelta(days=1) + referral = self._make_referral(follow_up_date=past_date, status="pending") + referral._compute_is_overdue() + self.assertTrue(referral.is_overdue) + + def test_referral_compute_is_overdue_future_date(self): + """_compute_is_overdue is False when follow_up_date is in the future.""" + future_date = fields.Date.today() + timedelta(days=1) + referral = self._make_referral(follow_up_date=future_date, status="pending") + referral._compute_is_overdue() + self.assertFalse(referral.is_overdue) + + def test_referral_compute_is_overdue_completed_not_overdue(self): + """_compute_is_overdue is False when status is completed, even with past date.""" + past_date = fields.Date.today() - timedelta(days=5) + referral = self._make_referral(follow_up_date=past_date, status="completed") + referral._compute_is_overdue() + self.assertFalse(referral.is_overdue) + + def test_referral_compute_is_overdue_rejected_not_overdue(self): + """_compute_is_overdue is False when status is rejected.""" + past_date = fields.Date.today() - timedelta(days=5) + referral = self._make_referral(follow_up_date=past_date, status="rejected") + referral._compute_is_overdue() + self.assertFalse(referral.is_overdue) + + def test_referral_compute_display_name_with_client(self): + """_compute_display_name includes service and client name.""" + referral = self._make_referral(service_name="Housing Support") + referral._compute_display_name() + self.assertIn("Housing Support", referral.display_name) + self.assertIn(self.client.name, referral.display_name) + + def test_referral_compute_display_name_service_only(self): + """_compute_display_name uses service name when no client is on the referral.""" + # Create a case with no partner to test the service-only branch + referral = self.env["spp.case.referral"].new( + { + "service_name": "Orphan Service", + "reason": "Test", + "referral_date": fields.Date.today(), + "referred_by_id": self.case_worker.id, + # No case_id means no client_id + } + ) + referral._compute_display_name() + self.assertEqual(referral.display_name, "Orphan Service") + + def test_referral_compute_display_name_no_service(self): + """_compute_display_name falls back to 'Referral #ID' when service_name is empty.""" + # Create a real record, then clear service_name to trigger the fallback branch + referral = self._make_referral(service_name="Temporary") + referral.service_name = False + referral._compute_display_name() + self.assertIn("Referral #", referral.display_name) + self.assertIn(str(referral.id), referral.display_name) + + def test_referral_action_accept(self): + """action_accept changes referral status to 'accepted'.""" + referral = self._make_referral() + self.assertEqual(referral.status, "pending") + referral.action_accept() + self.assertEqual(referral.status, "accepted") + + def test_referral_action_reject(self): + """action_reject changes referral status to 'rejected'.""" + referral = self._make_referral() + referral.action_reject() + self.assertEqual(referral.status, "rejected") + + def test_referral_action_complete(self): + """action_complete changes referral status to 'completed'.""" + referral = self._make_referral() + referral.action_complete() + self.assertEqual(referral.status, "completed") + + def test_referral_create_posts_case_message(self): + """create() override posts a message to the case chatter.""" + message_count_before = len(self.case.message_ids) + self._make_referral(service_name="Food Aid") + message_count_after = len(self.case.message_ids) + self.assertGreater(message_count_after, message_count_before) + + # ------------------------------------------------------------------------- + # spp.case.note tests + # ------------------------------------------------------------------------- + + def _make_note(self, **kwargs): + """Helper to create a case note with sensible defaults.""" + vals = { + "case_id": self.case.id, + "note_type": "general", + "content": "

Test note content

", + "author_id": self.case_worker.id, + "note_date": fields.Datetime.now(), + } + vals.update(kwargs) + return self.env["spp.case.note"].create(vals) + + def test_note_compute_note_date_display_with_date(self): + """_compute_note_date_display returns a formatted string when note_date is set.""" + note = self._make_note() + note._compute_note_date_display() + self.assertTrue(note.note_date_display) + # The value should be the Odoo string representation of the datetime + expected = fields.Datetime.to_string(note.note_date) + self.assertEqual(note.note_date_display, expected) + + def test_note_compute_note_date_display_without_date(self): + """_compute_note_date_display returns empty string when note_date is not set.""" + record = self.env["spp.case.note"].new( + { + "case_id": self.case.id, + "note_type": "general", + "content": "

content

", + "author_id": self.case_worker.id, + } + ) + # Clear the default that note_date receives from fields.Datetime.now + record.note_date = False + record._compute_note_date_display() + self.assertEqual(record.note_date_display, "") + + def test_note_compute_display_name_with_date_and_client(self): + """_compute_display_name includes type, date, and client name.""" + note = self._make_note(note_type="progress") + note._compute_display_name() + self.assertIn("Progress Update", note.display_name) + self.assertIn(self.client.name, note.display_name) + + def test_note_compute_display_name_with_date_no_client(self): + """_compute_display_name includes type and date when no client is linked.""" + record = self.env["spp.case.note"].new( + { + "note_type": "assessment", + "content": "

content

", + "author_id": self.case_worker.id, + "note_date": fields.Datetime.now(), + # No case_id, so no client_id + } + ) + record._compute_display_name() + self.assertIn("Assessment", record.display_name) + # Should not contain a client name separator beyond the date + self.assertNotIn(self.client.name, record.display_name) + + def test_note_compute_display_name_no_date(self): + """_compute_display_name returns just the type label when date is missing.""" + record = self.env["spp.case.note"].new( + { + "note_type": "supervision", + "content": "

content

", + "author_id": self.case_worker.id, + } + ) + # Clear the default that note_date receives from fields.Datetime.now + record.note_date = False + record._compute_display_name() + self.assertEqual(record.display_name, "Supervision Note") + + def test_note_create_posts_case_message(self): + """create() override posts a message to the case chatter.""" + message_count_before = len(self.case.message_ids) + self._make_note(note_type="general") + message_count_after = len(self.case.message_ids) + self.assertGreater(message_count_after, message_count_before) + + # ------------------------------------------------------------------------- + # spp.case.visit tests + # ------------------------------------------------------------------------- + + def _make_visit(self, **kwargs): + """Helper to create a case visit with sensible defaults.""" + vals = { + "case_id": self.case.id, + "visit_type": "office", + "visit_date": fields.Datetime.now(), + "conducted_by_id": self.case_worker.id, + } + vals.update(kwargs) + return self.env["spp.case.visit"].create(vals) + + def test_visit_compute_visit_date_display_with_date(self): + """_compute_visit_date_display returns a formatted string when visit_date is set.""" + visit = self._make_visit() + visit._compute_visit_date_display() + self.assertTrue(visit.visit_date_display) + expected = fields.Datetime.to_string(visit.visit_date) + self.assertEqual(visit.visit_date_display, expected) + + def test_visit_compute_visit_date_display_without_date(self): + """_compute_visit_date_display returns empty string when visit_date is not set.""" + record = self.env["spp.case.visit"].new( + { + "case_id": self.case.id, + "visit_type": "office", + "conducted_by_id": self.case_worker.id, + } + ) + # Clear the default that visit_date receives from fields.Datetime.now + record.visit_date = False + record._compute_visit_date_display() + self.assertEqual(record.visit_date_display, "") + + def test_visit_compute_display_name_with_date_and_client(self): + """_compute_display_name includes type, date, and client name.""" + visit = self._make_visit(visit_type="home") + visit._compute_display_name() + self.assertIn("Home Visit", visit.display_name) + self.assertIn(self.client.name, visit.display_name) + + def test_visit_compute_display_name_with_date_no_client(self): + """_compute_display_name includes type and date when no client is linked.""" + record = self.env["spp.case.visit"].new( + { + "visit_type": "phone", + "visit_date": fields.Datetime.now(), + "conducted_by_id": self.case_worker.id, + # No case_id, so no client_id + } + ) + record._compute_display_name() + self.assertIn("Phone Call", record.display_name) + self.assertNotIn(self.client.name, record.display_name) + + def test_visit_compute_display_name_no_date(self): + """_compute_display_name returns just the type label when visit_date is missing.""" + record = self.env["spp.case.visit"].new( + { + "visit_type": "virtual", + "conducted_by_id": self.case_worker.id, + } + ) + # Clear the default that visit_date receives from fields.Datetime.now + record.visit_date = False + record._compute_display_name() + self.assertEqual(record.display_name, "Virtual Meeting") + + def test_visit_create_adds_client_as_attendee(self): + """create() override adds the case client as a default attendee.""" + visit = self._make_visit() + self.assertIn(self.client, visit.attendee_ids) + + def test_visit_create_does_not_duplicate_client_attendee(self): + """create() does not add the client twice when already specified as attendee.""" + visit = self._make_visit( + attendee_ids=[Command.set([self.client.id])], + ) + # Client should appear exactly once + client_in_attendees = visit.attendee_ids.filtered(lambda p: p.id == self.client.id) + self.assertEqual(len(client_in_attendees), 1) diff --git a/spp_case_base/tests/test_case_security.py b/spp_case_base/tests/test_case_security.py new file mode 100644 index 00000000..d155a8a4 --- /dev/null +++ b/spp_case_base/tests/test_case_security.py @@ -0,0 +1,468 @@ +"""Test cases for security rules in spp_case_base.""" + +from odoo import Command +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCaseSecurity(TransactionCase): + """Test security rules for case management.""" + + @classmethod + def setUpClass(cls): + """Set up test data for all test methods.""" + super().setUpClass() + + # Create test users with different roles (include base.group_user for internal user access) + cls.worker1 = cls.env["res.users"].create( + { + "name": "Case Worker 1", + "login": "worker1", + "email": "worker1@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + + cls.worker2 = cls.env["res.users"].create( + { + "name": "Case Worker 2", + "login": "worker2", + "email": "worker2@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + + cls.supervisor = cls.env["res.users"].create( + { + "name": "Supervisor", + "login": "supervisor", + "email": "supervisor@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_supervisor").id), + ], + } + ) + + cls.manager = cls.env["res.users"].create( + { + "name": "Manager", + "login": "manager", + "email": "manager@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_manager").id), + ], + } + ) + + # Create test clients + cls.client1 = cls.env["res.partner"].create( + { + "name": "Client 1", + "email": "client1@test.com", + } + ) + + cls.client2 = cls.env["res.partner"].create( + { + "name": "Client 2", + "email": "client2@test.com", + } + ) + + cls.client3 = cls.env["res.partner"].create( + { + "name": "Client 3", + "email": "client3@test.com", + } + ) + + # Create case type + cls.case_type = cls.env["spp.case.type"].create( + { + "name": "Social Protection Security", + "code": "SPS001", + "domain": "social_protection", + } + ) + + # Create team + cls.team = cls.env["spp.case.team"].create( + { + "name": "Test Team", + "supervisor_id": cls.supervisor.id, + "member_ids": [Command.set([cls.worker1.id, cls.worker2.id])], + } + ) + + # Create cases with different assignments + # Worker 1's own case + cls.case_worker1 = ( + cls.env["spp.case"] + .with_user(cls.worker1) + .create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client1.id, + "case_worker_id": cls.worker1.id, + "presenting_issue": "

Worker 1's case

", + } + ) + ) + + # Worker 2's own case + cls.case_worker2 = ( + cls.env["spp.case"] + .with_user(cls.worker2) + .create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client2.id, + "case_worker_id": cls.worker2.id, + "presenting_issue": "

Worker 2's case

", + } + ) + ) + + # Supervisor's case (in team) + cls.case_supervisor = ( + cls.env["spp.case"] + .with_user(cls.supervisor) + .create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client3.id, + "case_worker_id": cls.worker1.id, + "supervisor_id": cls.supervisor.id, + "team_id": cls.team.id, + "presenting_issue": "

Supervisor's case

", + } + ) + ) + + def test_worker_access_own_cases(self): + """Test that workers can only see their own cases.""" + # Worker 1 should see only their own case + cases = self.env["spp.case"].with_user(self.worker1).search([]) + + # Should see case_worker1 and case_supervisor (both assigned to worker1) + self.assertIn( + self.case_worker1, + cases, + "Worker 1 should see their own case", + ) + self.assertIn( + self.case_supervisor, + cases, + "Worker 1 should see case assigned to them", + ) + self.assertNotIn( + self.case_worker2, + cases, + "Worker 1 should not see Worker 2's case", + ) + + # Worker 2 should see only their own case + cases = self.env["spp.case"].with_user(self.worker2).search([]) + + self.assertIn( + self.case_worker2, + cases, + "Worker 2 should see their own case", + ) + self.assertNotIn( + self.case_worker1, + cases, + "Worker 2 should not see Worker 1's case", + ) + self.assertNotIn( + self.case_supervisor, + cases, + "Worker 2 should not see supervisor's case", + ) + + def test_supervisor_access_team_cases(self): + """Test that supervisors can see team cases.""" + # Supervisor should see cases they supervise and team cases + cases = self.env["spp.case"].with_user(self.supervisor).search([]) + + # Should see case_supervisor (supervised by them) + self.assertIn( + self.case_supervisor, + cases, + "Supervisor should see cases they supervise", + ) + + # Count should include supervised cases + self.assertGreaterEqual( + len(cases), + 1, + "Supervisor should see at least supervised cases", + ) + + def test_manager_access_all_cases(self): + """Test that managers can see all cases.""" + # Manager should see all cases + cases = self.env["spp.case"].with_user(self.manager).search([]) + + self.assertIn( + self.case_worker1, + cases, + "Manager should see Worker 1's case", + ) + self.assertIn( + self.case_worker2, + cases, + "Manager should see Worker 2's case", + ) + self.assertIn( + self.case_supervisor, + cases, + "Manager should see Supervisor's case", + ) + + # Should see all 3 cases + self.assertGreaterEqual( + len(cases), + 3, + "Manager should see all cases", + ) + + def test_worker_create_case(self): + """Test that workers can create cases assigned to themselves.""" + new_client = self.env["res.partner"].create( + { + "name": "New Client", + "email": "newclient@test.com", + } + ) + + # Worker should be able to create their own case + new_case = ( + self.env["spp.case"] + .with_user(self.worker1) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": new_client.id, + "case_worker_id": self.worker1.id, + "presenting_issue": "

New case

", + } + ) + ) + + self.assertTrue(new_case, "Worker should be able to create case") + self.assertEqual( + new_case.case_worker_id, + self.worker1, + "Case should be assigned to worker", + ) + + def test_worker_write_own_case(self): + """Test that workers can modify their own cases.""" + # Worker 1 should be able to modify their own case + self.case_worker1.with_user(self.worker1).write( + { + "priority": "high", + } + ) + + self.assertEqual( + self.case_worker1.priority, + "high", + "Worker should be able to modify their own case", + ) + + def test_intervention_plan_security(self): + """Test security rules for intervention plans.""" + # Create plan for Worker 1's case + plan1 = ( + self.env["spp.case.intervention.plan"] + .with_user(self.worker1) + .create( + { + "case_id": self.case_worker1.id, + "name": "Worker 1 Plan", + "goals": "

Test goals

", + } + ) + ) + + # Worker 1 should see their own plan + plans = self.env["spp.case.intervention.plan"].with_user(self.worker1).search([]) + self.assertIn(plan1, plans, "Worker should see their own case plan") + + # Create plan for Worker 2's case + plan2 = ( + self.env["spp.case.intervention.plan"] + .with_user(self.worker2) + .create( + { + "case_id": self.case_worker2.id, + "name": "Worker 2 Plan", + "goals": "

Test goals

", + } + ) + ) + + # Worker 1 should NOT see Worker 2's plan + plans = self.env["spp.case.intervention.plan"].with_user(self.worker1).search([]) + self.assertNotIn( + plan2, + plans, + "Worker should not see other worker's case plan", + ) + + # Manager should see all plans + plans = self.env["spp.case.intervention.plan"].with_user(self.manager).search([]) + self.assertIn(plan1, plans, "Manager should see all plans") + self.assertIn(plan2, plans, "Manager should see all plans") + + def test_intervention_security(self): + """Test security rules for interventions.""" + # Create plan and intervention for Worker 1 + plan1 = ( + self.env["spp.case.intervention.plan"] + .with_user(self.worker1) + .create( + { + "case_id": self.case_worker1.id, + "name": "Plan 1", + "goals": "

Goals 1

", + } + ) + ) + + intervention1 = ( + self.env["spp.case.intervention"] + .with_user(self.worker1) + .create( + { + "name": "Intervention 1", + "plan_id": plan1.id, + } + ) + ) + + # Worker 1 should see their own intervention + interventions = self.env["spp.case.intervention"].with_user(self.worker1).search([]) + self.assertIn( + intervention1, + interventions, + "Worker should see their own case intervention", + ) + + # Create plan and intervention for Worker 2 + plan2 = ( + self.env["spp.case.intervention.plan"] + .with_user(self.worker2) + .create( + { + "case_id": self.case_worker2.id, + "name": "Plan 2", + "goals": "

Goals 2

", + } + ) + ) + + intervention2 = ( + self.env["spp.case.intervention"] + .with_user(self.worker2) + .create( + { + "name": "Intervention 2", + "plan_id": plan2.id, + } + ) + ) + + # Worker 1 should NOT see Worker 2's intervention + interventions = self.env["spp.case.intervention"].with_user(self.worker1).search([]) + self.assertNotIn( + intervention2, + interventions, + "Worker should not see other worker's intervention", + ) + + # Manager should see all interventions + interventions = self.env["spp.case.intervention"].with_user(self.manager).search([]) + self.assertIn( + intervention1, + interventions, + "Manager should see all interventions", + ) + self.assertIn( + intervention2, + interventions, + "Manager should see all interventions", + ) + + def test_supervisor_approve_team_plans(self): + """Test that supervisors can approve team plans.""" + # Create plan for case supervised by supervisor + plan = ( + self.env["spp.case.intervention.plan"] + .with_user(self.worker1) + .create( + { + "case_id": self.case_supervisor.id, + "name": "Team Plan", + "goals": "

Team goals

", + } + ) + ) + + # Add intervention so it can be submitted + self.env["spp.case.intervention"].with_user(self.worker1).create( + { + "name": "Team Intervention", + "plan_id": plan.id, + } + ) + + # Submit for approval + plan.with_user(self.worker1).action_submit_for_approval() + + # Supervisor should be able to approve + plan.with_user(self.supervisor).action_approve() + + self.assertEqual( + plan.state, + "approved", + "Supervisor should be able to approve team plan", + ) + self.assertEqual( + plan.approved_by_id, + self.supervisor, + "Supervisor should be recorded as approver", + ) + + def test_group_hierarchy(self): + """Test that group hierarchy is correct.""" + # Supervisor should have worker permissions (through implied groups) + self.assertIn( + self.env.ref("spp_case_base.group_case_worker"), + self.supervisor.all_group_ids, + "Supervisor should have worker permissions", + ) + + # Manager should have supervisor permissions (through implied groups) + self.assertIn( + self.env.ref("spp_case_base.group_case_supervisor"), + self.manager.all_group_ids, + "Manager should have supervisor permissions", + ) + + # Manager should have worker permissions (through hierarchy) + self.assertIn( + self.env.ref("spp_case_base.group_case_worker"), + self.manager.all_group_ids, + "Manager should have worker permissions through hierarchy", + ) diff --git a/spp_case_base/tests/test_compliance_generated.py b/spp_case_base/tests/test_compliance_generated.py new file mode 100644 index 00000000..d6801df3 --- /dev/null +++ b/spp_case_base/tests/test_compliance_generated.py @@ -0,0 +1,1698 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +# AUTO-GENERATED from compliance.yaml - DO NOT EDIT MANUALLY +# Regenerate with: python -m scripts.compliance.test_generator spp_case_base + +""" +Access control compliance tests for spp_case_base. + +Generated from: spp_case_base/security/compliance.yaml +Tests validate: +- Group hierarchy and implied_ids +- Model CRUD permissions per role +- Menu visibility per role +- Action access restrictions +""" + +from odoo import Command +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install", "access_control", "compliance") +class TestComplianceBase(TransactionCase): + """Base class for spp_case_base compliance tests.""" + + @classmethod + def setUpClass(cls): + """Set up test users with different roles.""" + super().setUpClass() + + # Helper to safely get reference + def safe_ref(xml_id): + try: + return cls.env.ref(xml_id) + except ValueError: + return None + + # Store all role groups and users + cls.role_groups = {} + cls.role_users = {} + + # Viewer role + cls.role_groups["viewer"] = safe_ref("spp_case_base.group_case_viewer") + cls.role_users["viewer"] = cls._create_test_user("viewer", cls.role_groups["viewer"]) + + # Officer role + cls.role_groups["officer"] = safe_ref("spp_case_base.group_case_officer") + cls.role_users["officer"] = cls._create_test_user("officer", cls.role_groups["officer"]) + + # Manager role + cls.role_groups["manager"] = safe_ref("spp_case_base.group_case_manager") + cls.role_users["manager"] = cls._create_test_user("manager", cls.role_groups["manager"]) + + # Supervisor role + cls.role_groups["supervisor"] = safe_ref("spp_case_base.group_case_supervisor") + cls.role_users["supervisor"] = cls._create_test_user("supervisor", cls.role_groups["supervisor"]) + + # Worker role + cls.role_groups["worker"] = safe_ref("spp_case_base.group_case_worker") + cls.role_users["worker"] = cls._create_test_user("worker", cls.role_groups["worker"]) + + # Read role + cls.role_groups["read"] = safe_ref("spp_case_base.group_case_read") + cls.role_users["read"] = cls._create_test_user("read", cls.role_groups["read"]) + + # Write role + cls.role_groups["write"] = safe_ref("spp_case_base.group_case_write") + cls.role_users["write"] = cls._create_test_user("write", cls.role_groups["write"]) + + # Backward-compatible aliases for standard roles + cls.group_viewer = cls.role_groups.get("viewer") + cls.group_officer = cls.role_groups.get("officer") + cls.group_manager = cls.role_groups.get("manager") + cls.user_viewer = cls.role_users.get("viewer") + cls.user_officer = cls.role_users.get("officer") + cls.user_manager = cls.role_users.get("manager") + + # Admin user + admin_group = safe_ref("spp_security.group_spp_admin") + cls.user_admin = cls._create_test_user("admin", admin_group) + + @classmethod + def _create_test_user(cls, role, group): + """Create a test user with the specified group.""" + if not group: + return None + return cls.env["res.users"].create( + { + "name": f"Test {role.title()}", + "login": f"test_{role}_compliance", + "email": f"{role}_compliance@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(group.id), + ], + } + ) + + def _get_user_for_role(self, role): + """Get test user for a given role name.""" + return self.role_users.get(role) + + def _assert_access(self, model, user, perm, should_have): + """Assert user has or doesn't have permission on model.""" + Model = self.env[model].with_user(user) + has_access = Model.check_access_rights(perm, raise_exception=False) + if should_have: + self.assertTrue(has_access, f"{user.name} should have {perm} on {model}") + else: + self.assertFalse(has_access, f"{user.name} should NOT have {perm} on {model}") + + +@tagged("post_install", "-at_install", "access_control", "compliance") +class TestGroupHierarchy(TestComplianceBase): + """Test group hierarchy and implied_ids.""" + + def test_manager_implies_officer(self): + """Manager group should imply officer group.""" + if not self.user_manager or not self.group_officer: + self.skipTest("Required groups not found") + self.assertTrue( + self.user_manager.has_group("spp_case_base.group_case_officer"), "Manager should have officer privileges" + ) + + def test_officer_implies_viewer(self): + """Officer group should imply viewer group.""" + if not self.user_officer or not self.group_viewer: + self.skipTest("Required groups not found") + self.assertTrue( + self.user_officer.has_group("spp_case_base.group_case_viewer"), "Officer should have viewer privileges" + ) + + def test_group_case_write_implies(self): + """Test group_case_write implies correct groups.""" + group = self.env.ref("spp_case_base.group_case_write", raise_if_not_found=False) + if not group: + self.skipTest("Group group_case_write not found") + implied_ids = group.implied_ids.mapped("id") + implied_group = self.env.ref("spp_case_base.group_case_read", raise_if_not_found=False) + if implied_group: + self.assertIn(implied_group.id, implied_ids, "group_case_write should imply group_case_read") + + def test_group_case_viewer_implies(self): + """Test group_case_viewer implies correct groups.""" + group = self.env.ref("spp_case_base.group_case_viewer", raise_if_not_found=False) + if not group: + self.skipTest("Group group_case_viewer not found") + implied_ids = group.implied_ids.mapped("id") + implied_group = self.env.ref("spp_case_base.group_case_read", raise_if_not_found=False) + if implied_group: + self.assertIn(implied_group.id, implied_ids, "group_case_viewer should imply group_case_read") + + def test_group_case_officer_implies(self): + """Test group_case_officer implies correct groups.""" + group = self.env.ref("spp_case_base.group_case_officer", raise_if_not_found=False) + if not group: + self.skipTest("Group group_case_officer not found") + implied_ids = group.implied_ids.mapped("id") + implied_group = self.env.ref("spp_case_base.group_case_viewer", raise_if_not_found=False) + if implied_group: + self.assertIn(implied_group.id, implied_ids, "group_case_officer should imply group_case_viewer") + implied_group = self.env.ref("spp_case_base.group_case_write", raise_if_not_found=False) + if implied_group: + self.assertIn(implied_group.id, implied_ids, "group_case_officer should imply group_case_write") + + def test_group_case_worker_implies(self): + """Test group_case_worker implies correct groups.""" + group = self.env.ref("spp_case_base.group_case_worker", raise_if_not_found=False) + if not group: + self.skipTest("Group group_case_worker not found") + implied_ids = group.implied_ids.mapped("id") + implied_group = self.env.ref("spp_case_base.group_case_viewer", raise_if_not_found=False) + if implied_group: + self.assertIn(implied_group.id, implied_ids, "group_case_worker should imply group_case_viewer") + + def test_group_case_supervisor_implies(self): + """Test group_case_supervisor implies correct groups.""" + group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if not group: + self.skipTest("Group group_case_supervisor not found") + implied_ids = group.implied_ids.mapped("id") + implied_group = self.env.ref("spp_case_base.group_case_worker", raise_if_not_found=False) + if implied_group: + self.assertIn(implied_group.id, implied_ids, "group_case_supervisor should imply group_case_worker") + + def test_group_case_manager_implies(self): + """Test group_case_manager implies correct groups.""" + group = self.env.ref("spp_case_base.group_case_manager", raise_if_not_found=False) + if not group: + self.skipTest("Group group_case_manager not found") + implied_ids = group.implied_ids.mapped("id") + implied_group = self.env.ref("spp_case_base.group_case_officer", raise_if_not_found=False) + if implied_group: + self.assertIn(implied_group.id, implied_ids, "group_case_manager should imply group_case_officer") + implied_group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if implied_group: + self.assertIn(implied_group.id, implied_ids, "group_case_manager should imply group_case_supervisor") + + +@tagged("post_install", "-at_install", "access_control", "compliance") +class TestModelAccess(TestComplianceBase): + """Test model CRUD permissions per role.""" + + def test_spp_case_viewer_access(self): + """Test viewer permissions on spp.case.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case", user, "read", True) + self._assert_access("spp.case", user, "write", False) + self._assert_access("spp.case", user, "create", False) + self._assert_access("spp.case", user, "unlink", False) + + def test_spp_case_manager_access(self): + """Test manager permissions on spp.case.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case", user, "read", True) + self._assert_access("spp.case", user, "write", True) + self._assert_access("spp.case", user, "create", True) + self._assert_access("spp.case", user, "unlink", True) + + def test_spp_case_supervisor_access(self): + """Test supervisor permissions on spp.case.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case", user, "read", True) + self._assert_access("spp.case", user, "write", True) + self._assert_access("spp.case", user, "create", True) + self._assert_access("spp.case", user, "unlink", True) + + def test_spp_case_worker_access(self): + """Test worker permissions on spp.case.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case", user, "read", True) + self._assert_access("spp.case", user, "write", True) + self._assert_access("spp.case", user, "create", True) + self._assert_access("spp.case", user, "unlink", True) + + def test_spp_case_assessment_viewer_access(self): + """Test viewer permissions on spp.case.assessment.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.assessment", user, "read", True) + self._assert_access("spp.case.assessment", user, "write", False) + self._assert_access("spp.case.assessment", user, "create", False) + self._assert_access("spp.case.assessment", user, "unlink", False) + + def test_spp_case_assessment_manager_access(self): + """Test manager permissions on spp.case.assessment.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.assessment", user, "read", True) + self._assert_access("spp.case.assessment", user, "write", True) + self._assert_access("spp.case.assessment", user, "create", True) + self._assert_access("spp.case.assessment", user, "unlink", True) + + def test_spp_case_assessment_supervisor_access(self): + """Test supervisor permissions on spp.case.assessment.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.assessment", user, "read", True) + self._assert_access("spp.case.assessment", user, "write", True) + self._assert_access("spp.case.assessment", user, "create", True) + self._assert_access("spp.case.assessment", user, "unlink", True) + + def test_spp_case_assessment_worker_access(self): + """Test worker permissions on spp.case.assessment.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.assessment", user, "read", True) + self._assert_access("spp.case.assessment", user, "write", True) + self._assert_access("spp.case.assessment", user, "create", True) + self._assert_access("spp.case.assessment", user, "unlink", True) + + def test_spp_case_stage_viewer_access(self): + """Test viewer permissions on spp.case.stage.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.stage", user, "read", True) + self._assert_access("spp.case.stage", user, "write", False) + self._assert_access("spp.case.stage", user, "create", False) + self._assert_access("spp.case.stage", user, "unlink", False) + + def test_spp_case_stage_manager_access(self): + """Test manager permissions on spp.case.stage.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.stage", user, "read", True) + self._assert_access("spp.case.stage", user, "write", True) + self._assert_access("spp.case.stage", user, "create", True) + self._assert_access("spp.case.stage", user, "unlink", True) + + def test_spp_case_stage_supervisor_access(self): + """Test supervisor permissions on spp.case.stage.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.stage", user, "read", True) + self._assert_access("spp.case.stage", user, "write", False) + self._assert_access("spp.case.stage", user, "create", False) + self._assert_access("spp.case.stage", user, "unlink", False) + + def test_spp_case_stage_worker_access(self): + """Test worker permissions on spp.case.stage.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.stage", user, "read", True) + self._assert_access("spp.case.stage", user, "write", False) + self._assert_access("spp.case.stage", user, "create", False) + self._assert_access("spp.case.stage", user, "unlink", False) + + def test_spp_case_type_viewer_access(self): + """Test viewer permissions on spp.case.type.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.type", user, "read", True) + self._assert_access("spp.case.type", user, "write", False) + self._assert_access("spp.case.type", user, "create", False) + self._assert_access("spp.case.type", user, "unlink", False) + + def test_spp_case_type_manager_access(self): + """Test manager permissions on spp.case.type.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.type", user, "read", True) + self._assert_access("spp.case.type", user, "write", True) + self._assert_access("spp.case.type", user, "create", True) + self._assert_access("spp.case.type", user, "unlink", True) + + def test_spp_case_type_supervisor_access(self): + """Test supervisor permissions on spp.case.type.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.type", user, "read", True) + self._assert_access("spp.case.type", user, "write", False) + self._assert_access("spp.case.type", user, "create", False) + self._assert_access("spp.case.type", user, "unlink", False) + + def test_spp_case_type_worker_access(self): + """Test worker permissions on spp.case.type.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.type", user, "read", True) + self._assert_access("spp.case.type", user, "write", False) + self._assert_access("spp.case.type", user, "create", False) + self._assert_access("spp.case.type", user, "unlink", False) + + def test_spp_case_intervention_plan_viewer_access(self): + """Test viewer permissions on spp.case.intervention.plan.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.intervention.plan", user, "read", True) + self._assert_access("spp.case.intervention.plan", user, "write", False) + self._assert_access("spp.case.intervention.plan", user, "create", False) + self._assert_access("spp.case.intervention.plan", user, "unlink", False) + + def test_spp_case_intervention_plan_manager_access(self): + """Test manager permissions on spp.case.intervention.plan.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.intervention.plan", user, "read", True) + self._assert_access("spp.case.intervention.plan", user, "write", True) + self._assert_access("spp.case.intervention.plan", user, "create", True) + self._assert_access("spp.case.intervention.plan", user, "unlink", True) + + def test_spp_case_intervention_plan_supervisor_access(self): + """Test supervisor permissions on spp.case.intervention.plan.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.intervention.plan", user, "read", True) + self._assert_access("spp.case.intervention.plan", user, "write", True) + self._assert_access("spp.case.intervention.plan", user, "create", True) + self._assert_access("spp.case.intervention.plan", user, "unlink", True) + + def test_spp_case_intervention_plan_worker_access(self): + """Test worker permissions on spp.case.intervention.plan.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.intervention.plan", user, "read", True) + self._assert_access("spp.case.intervention.plan", user, "write", True) + self._assert_access("spp.case.intervention.plan", user, "create", True) + self._assert_access("spp.case.intervention.plan", user, "unlink", True) + + def test_spp_case_intervention_viewer_access(self): + """Test viewer permissions on spp.case.intervention.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.intervention", user, "read", True) + self._assert_access("spp.case.intervention", user, "write", False) + self._assert_access("spp.case.intervention", user, "create", False) + self._assert_access("spp.case.intervention", user, "unlink", False) + + def test_spp_case_intervention_manager_access(self): + """Test manager permissions on spp.case.intervention.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.intervention", user, "read", True) + self._assert_access("spp.case.intervention", user, "write", True) + self._assert_access("spp.case.intervention", user, "create", True) + self._assert_access("spp.case.intervention", user, "unlink", True) + + def test_spp_case_intervention_supervisor_access(self): + """Test supervisor permissions on spp.case.intervention.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.intervention", user, "read", True) + self._assert_access("spp.case.intervention", user, "write", True) + self._assert_access("spp.case.intervention", user, "create", True) + self._assert_access("spp.case.intervention", user, "unlink", True) + + def test_spp_case_intervention_worker_access(self): + """Test worker permissions on spp.case.intervention.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.intervention", user, "read", True) + self._assert_access("spp.case.intervention", user, "write", True) + self._assert_access("spp.case.intervention", user, "create", True) + self._assert_access("spp.case.intervention", user, "unlink", True) + + def test_spp_case_visit_viewer_access(self): + """Test viewer permissions on spp.case.visit.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.visit", user, "read", True) + self._assert_access("spp.case.visit", user, "write", False) + self._assert_access("spp.case.visit", user, "create", False) + self._assert_access("spp.case.visit", user, "unlink", False) + + def test_spp_case_visit_manager_access(self): + """Test manager permissions on spp.case.visit.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.visit", user, "read", True) + self._assert_access("spp.case.visit", user, "write", True) + self._assert_access("spp.case.visit", user, "create", True) + self._assert_access("spp.case.visit", user, "unlink", True) + + def test_spp_case_visit_supervisor_access(self): + """Test supervisor permissions on spp.case.visit.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.visit", user, "read", True) + self._assert_access("spp.case.visit", user, "write", True) + self._assert_access("spp.case.visit", user, "create", True) + self._assert_access("spp.case.visit", user, "unlink", True) + + def test_spp_case_visit_worker_access(self): + """Test worker permissions on spp.case.visit.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.visit", user, "read", True) + self._assert_access("spp.case.visit", user, "write", True) + self._assert_access("spp.case.visit", user, "create", True) + self._assert_access("spp.case.visit", user, "unlink", True) + + def test_spp_case_note_viewer_access(self): + """Test viewer permissions on spp.case.note.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.note", user, "read", True) + self._assert_access("spp.case.note", user, "write", False) + self._assert_access("spp.case.note", user, "create", False) + self._assert_access("spp.case.note", user, "unlink", False) + + def test_spp_case_note_manager_access(self): + """Test manager permissions on spp.case.note.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.note", user, "read", True) + self._assert_access("spp.case.note", user, "write", True) + self._assert_access("spp.case.note", user, "create", True) + self._assert_access("spp.case.note", user, "unlink", True) + + def test_spp_case_note_supervisor_access(self): + """Test supervisor permissions on spp.case.note.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.note", user, "read", True) + self._assert_access("spp.case.note", user, "write", True) + self._assert_access("spp.case.note", user, "create", True) + self._assert_access("spp.case.note", user, "unlink", True) + + def test_spp_case_note_worker_access(self): + """Test worker permissions on spp.case.note.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.note", user, "read", True) + self._assert_access("spp.case.note", user, "write", True) + self._assert_access("spp.case.note", user, "create", True) + self._assert_access("spp.case.note", user, "unlink", True) + + def test_spp_case_referral_viewer_access(self): + """Test viewer permissions on spp.case.referral.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.referral", user, "read", True) + self._assert_access("spp.case.referral", user, "write", False) + self._assert_access("spp.case.referral", user, "create", False) + self._assert_access("spp.case.referral", user, "unlink", False) + + def test_spp_case_referral_manager_access(self): + """Test manager permissions on spp.case.referral.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.referral", user, "read", True) + self._assert_access("spp.case.referral", user, "write", True) + self._assert_access("spp.case.referral", user, "create", True) + self._assert_access("spp.case.referral", user, "unlink", True) + + def test_spp_case_referral_supervisor_access(self): + """Test supervisor permissions on spp.case.referral.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.referral", user, "read", True) + self._assert_access("spp.case.referral", user, "write", True) + self._assert_access("spp.case.referral", user, "create", True) + self._assert_access("spp.case.referral", user, "unlink", True) + + def test_spp_case_referral_worker_access(self): + """Test worker permissions on spp.case.referral.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.referral", user, "read", True) + self._assert_access("spp.case.referral", user, "write", True) + self._assert_access("spp.case.referral", user, "create", True) + self._assert_access("spp.case.referral", user, "unlink", True) + + def test_spp_case_risk_factor_viewer_access(self): + """Test viewer permissions on spp.case.risk.factor.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.risk.factor", user, "read", True) + self._assert_access("spp.case.risk.factor", user, "write", False) + self._assert_access("spp.case.risk.factor", user, "create", False) + self._assert_access("spp.case.risk.factor", user, "unlink", False) + + def test_spp_case_risk_factor_manager_access(self): + """Test manager permissions on spp.case.risk.factor.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.risk.factor", user, "read", True) + self._assert_access("spp.case.risk.factor", user, "write", True) + self._assert_access("spp.case.risk.factor", user, "create", True) + self._assert_access("spp.case.risk.factor", user, "unlink", True) + + def test_spp_case_risk_factor_supervisor_access(self): + """Test supervisor permissions on spp.case.risk.factor.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.risk.factor", user, "read", True) + self._assert_access("spp.case.risk.factor", user, "write", False) + self._assert_access("spp.case.risk.factor", user, "create", False) + self._assert_access("spp.case.risk.factor", user, "unlink", False) + + def test_spp_case_risk_factor_worker_access(self): + """Test worker permissions on spp.case.risk.factor.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.risk.factor", user, "read", True) + self._assert_access("spp.case.risk.factor", user, "write", False) + self._assert_access("spp.case.risk.factor", user, "create", False) + self._assert_access("spp.case.risk.factor", user, "unlink", False) + + def test_spp_case_vulnerability_viewer_access(self): + """Test viewer permissions on spp.case.vulnerability.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.vulnerability", user, "read", True) + self._assert_access("spp.case.vulnerability", user, "write", False) + self._assert_access("spp.case.vulnerability", user, "create", False) + self._assert_access("spp.case.vulnerability", user, "unlink", False) + + def test_spp_case_vulnerability_manager_access(self): + """Test manager permissions on spp.case.vulnerability.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.vulnerability", user, "read", True) + self._assert_access("spp.case.vulnerability", user, "write", True) + self._assert_access("spp.case.vulnerability", user, "create", True) + self._assert_access("spp.case.vulnerability", user, "unlink", True) + + def test_spp_case_vulnerability_supervisor_access(self): + """Test supervisor permissions on spp.case.vulnerability.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.vulnerability", user, "read", True) + self._assert_access("spp.case.vulnerability", user, "write", False) + self._assert_access("spp.case.vulnerability", user, "create", False) + self._assert_access("spp.case.vulnerability", user, "unlink", False) + + def test_spp_case_vulnerability_worker_access(self): + """Test worker permissions on spp.case.vulnerability.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.vulnerability", user, "read", True) + self._assert_access("spp.case.vulnerability", user, "write", False) + self._assert_access("spp.case.vulnerability", user, "create", False) + self._assert_access("spp.case.vulnerability", user, "unlink", False) + + def test_spp_case_closure_reason_viewer_access(self): + """Test viewer permissions on spp.case.closure.reason.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.closure.reason", user, "read", True) + self._assert_access("spp.case.closure.reason", user, "write", False) + self._assert_access("spp.case.closure.reason", user, "create", False) + self._assert_access("spp.case.closure.reason", user, "unlink", False) + + def test_spp_case_closure_reason_manager_access(self): + """Test manager permissions on spp.case.closure.reason.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.closure.reason", user, "read", True) + self._assert_access("spp.case.closure.reason", user, "write", True) + self._assert_access("spp.case.closure.reason", user, "create", True) + self._assert_access("spp.case.closure.reason", user, "unlink", True) + + def test_spp_case_closure_reason_supervisor_access(self): + """Test supervisor permissions on spp.case.closure.reason.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.closure.reason", user, "read", True) + self._assert_access("spp.case.closure.reason", user, "write", False) + self._assert_access("spp.case.closure.reason", user, "create", False) + self._assert_access("spp.case.closure.reason", user, "unlink", False) + + def test_spp_case_closure_reason_worker_access(self): + """Test worker permissions on spp.case.closure.reason.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.closure.reason", user, "read", True) + self._assert_access("spp.case.closure.reason", user, "write", False) + self._assert_access("spp.case.closure.reason", user, "create", False) + self._assert_access("spp.case.closure.reason", user, "unlink", False) + + def test_spp_case_team_viewer_access(self): + """Test viewer permissions on spp.case.team.""" + user = self._get_user_for_role("viewer") + if not user: + self.skipTest("Viewer user not found") + self._assert_access("spp.case.team", user, "read", True) + self._assert_access("spp.case.team", user, "write", False) + self._assert_access("spp.case.team", user, "create", False) + self._assert_access("spp.case.team", user, "unlink", False) + + def test_spp_case_team_manager_access(self): + """Test manager permissions on spp.case.team.""" + user = self._get_user_for_role("manager") + if not user: + self.skipTest("Manager user not found") + self._assert_access("spp.case.team", user, "read", True) + self._assert_access("spp.case.team", user, "write", True) + self._assert_access("spp.case.team", user, "create", True) + self._assert_access("spp.case.team", user, "unlink", True) + + def test_spp_case_team_supervisor_access(self): + """Test supervisor permissions on spp.case.team.""" + user = self._get_user_for_role("supervisor") + if not user: + self.skipTest("Supervisor user not found") + self._assert_access("spp.case.team", user, "read", True) + self._assert_access("spp.case.team", user, "write", False) + self._assert_access("spp.case.team", user, "create", False) + self._assert_access("spp.case.team", user, "unlink", False) + + def test_spp_case_team_worker_access(self): + """Test worker permissions on spp.case.team.""" + user = self._get_user_for_role("worker") + if not user: + self.skipTest("Worker user not found") + self._assert_access("spp.case.team", user, "read", True) + self._assert_access("spp.case.team", user, "write", False) + self._assert_access("spp.case.team", user, "create", False) + self._assert_access("spp.case.team", user, "unlink", False) + + +@tagged("post_install", "-at_install", "access_control", "compliance") +class TestMenuVisibility(TestComplianceBase): + """Test menu visibility per role.""" + + def _menu_visible(self, menu_xml_id, user): + """Check if menu is visible to user based on group restrictions.""" + menu = self.env.ref(menu_xml_id, raise_if_not_found=False) + if not menu: + return None # Menu not found + # If menu has no group restriction, it's visible to all internal users + if not menu.group_ids: + return True + # Check if user belongs to any of the menu's required groups + # Use has_group() to correctly resolve implied group hierarchy + for group in menu.group_ids: + ext_ids = group.get_external_id() + xml_id = ext_ids.get(group.id, "") + if xml_id and user.has_group(xml_id): + return True + return False + + def test_menu_menu_case_management_root_visibility(self): + """Test visibility of menu Case Management.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_management_root", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Case Management (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_management_root", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Case Management") + + def test_menu_menu_case_management_cases_visibility(self): + """Test visibility of menu Cases.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_management_cases", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Cases (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_management_cases", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Cases") + + def test_menu_menu_case_my_cases_visibility(self): + """Test visibility of menu My Cases.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_my_cases", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see My Cases (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_my_cases", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see My Cases") + + def test_menu_menu_case_unassigned_visibility(self): + """Test visibility of menu Unassigned.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_unassigned", self.user_viewer) + if visible is not None: + self.assertFalse(visible, "Viewer should NOT see Unassigned") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_unassigned", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Unassigned") + + def test_menu_menu_case_all_visibility(self): + """Test visibility of menu All Cases.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_all", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see All Cases (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_all", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see All Cases") + + def test_menu_menu_case_management_activities_visibility(self): + """Test visibility of menu Activities.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_management_activities", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Activities (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_management_activities", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Activities") + + def test_menu_menu_case_visits_visibility(self): + """Test visibility of menu Visits.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_visits", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Visits (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_visits", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Visits") + + def test_menu_menu_case_notes_visibility(self): + """Test visibility of menu Notes.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_notes", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Notes (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_notes", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Notes") + + def test_menu_menu_case_referrals_visibility(self): + """Test visibility of menu Referrals.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_referrals", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Referrals (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_referrals", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Referrals") + + def test_menu_menu_case_assessment_visibility(self): + """Test visibility of menu Assessments.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_assessment", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Assessments (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_assessment", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Assessments") + + def test_menu_menu_case_management_planning_visibility(self): + """Test visibility of menu Planning.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_management_planning", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Planning (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_management_planning", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Planning") + + def test_menu_menu_case_intervention_plans_visibility(self): + """Test visibility of menu Intervention Plans.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_intervention_plans", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Intervention Plans (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_intervention_plans", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Intervention Plans") + + def test_menu_menu_case_interventions_visibility(self): + """Test visibility of menu Interventions.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_interventions", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Interventions (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_interventions", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Interventions") + + def test_menu_menu_case_management_config_visibility(self): + """Test visibility of menu Configuration.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_management_config", self.user_viewer) + if visible is not None: + self.assertFalse(visible, "Viewer should NOT see Configuration") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_management_config", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Configuration") + + def test_menu_menu_case_config_case_setup_visibility(self): + """Test visibility of menu Case Setup.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_config_case_setup", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Case Setup (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_config_case_setup", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Case Setup") + + def test_menu_menu_case_types_visibility(self): + """Test visibility of menu Case Types.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_types", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Case Types (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_types", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Case Types") + + def test_menu_menu_case_stages_visibility(self): + """Test visibility of menu Stages.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_stages", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Stages (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_stages", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Stages") + + def test_menu_menu_case_teams_visibility(self): + """Test visibility of menu Teams.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_teams", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Teams (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_teams", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Teams") + + def test_menu_menu_case_config_assessment_visibility(self): + """Test visibility of menu Assessment.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_config_assessment", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Assessment config (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_config_assessment", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Assessment") + + def test_menu_menu_case_risk_factors_visibility(self): + """Test visibility of menu Risk Factors.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_risk_factors", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Risk Factors (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_risk_factors", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Risk Factors") + + def test_menu_menu_case_vulnerabilities_visibility(self): + """Test visibility of menu Vulnerabilities.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_vulnerabilities", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Vulnerabilities (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_vulnerabilities", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Vulnerabilities") + + def test_menu_menu_case_config_closure_visibility(self): + """Test visibility of menu Closure.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_config_closure", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Closure (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_config_closure", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Closure") + + def test_menu_menu_case_closure_reasons_visibility(self): + """Test visibility of menu Closure Reasons.""" + if self.user_viewer: + visible = self._menu_visible("spp_case_base.menu_case_closure_reasons", self.user_viewer) + if visible is not None: + self.assertTrue(visible, "Viewer should see Closure Reasons (no group restriction)") + if self.user_manager: + visible = self._menu_visible("spp_case_base.menu_case_closure_reasons", self.user_manager) + if visible is not None: + self.assertTrue(visible, "Manager should see Closure Reasons") + + +@tagged("post_install", "-at_install", "access_control", "compliance", "record_rules") +class TestRecordRules(TestComplianceBase): + """Test record rules (data visibility filtering). + + These tests verify that users can only see records they are allowed + to see based on ir.rule domain filters. + """ + + def _rule_exists(self, rule_xml_id): + """Check if a record rule exists.""" + return bool(self.env.ref(rule_xml_id, raise_if_not_found=False)) + + def _get_rule(self, rule_xml_id): + """Get a record rule by XML ID.""" + return self.env.ref(rule_xml_id, raise_if_not_found=False) + + def test_rule_rule_spp_case_worker_exists(self): + """Test record rule rule_spp_case_worker exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_worker") + if not rule: + self.skipTest("Rule rule_spp_case_worker not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case", "Rule should be for model spp.case") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_worker_groups(self): + """Test record rule rule_spp_case_worker is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_worker") + if not rule: + self.skipTest("Rule rule_spp_case_worker not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_worker", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_worker") + + def test_rule_rule_spp_case_supervisor_exists(self): + """Test record rule rule_spp_case_supervisor exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_supervisor not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case", "Rule should be for model spp.case") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_supervisor_groups(self): + """Test record rule rule_spp_case_supervisor is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_supervisor not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_supervisor") + + def test_rule_rule_spp_case_manager_exists(self): + """Test record rule rule_spp_case_manager exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_manager") + if not rule: + self.skipTest("Rule rule_spp_case_manager not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case", "Rule should be for model spp.case") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, True) + + def test_rule_rule_spp_case_manager_groups(self): + """Test record rule rule_spp_case_manager is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_manager") + if not rule: + self.skipTest("Rule rule_spp_case_manager not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_manager", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_manager") + + def test_rule_rule_spp_case_intervention_plan_worker_exists(self): + """Test record rule rule_spp_case_intervention_plan_worker exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_plan_worker") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_plan_worker not found") + + # Verify rule model + self.assertEqual( + rule.model_id.model, "spp.case.intervention.plan", "Rule should be for model spp.case.intervention.plan" + ) + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_intervention_plan_worker_groups(self): + """Test record rule rule_spp_case_intervention_plan_worker is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_plan_worker") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_plan_worker not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_worker", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_worker") + + def test_rule_rule_spp_case_intervention_plan_supervisor_exists(self): + """Test record rule rule_spp_case_intervention_plan_supervisor exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_plan_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_plan_supervisor not found") + + # Verify rule model + self.assertEqual( + rule.model_id.model, "spp.case.intervention.plan", "Rule should be for model spp.case.intervention.plan" + ) + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_intervention_plan_supervisor_groups(self): + """Test record rule rule_spp_case_intervention_plan_supervisor is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_plan_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_plan_supervisor not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_supervisor") + + def test_rule_rule_spp_case_intervention_plan_manager_exists(self): + """Test record rule rule_spp_case_intervention_plan_manager exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_plan_manager") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_plan_manager not found") + + # Verify rule model + self.assertEqual( + rule.model_id.model, "spp.case.intervention.plan", "Rule should be for model spp.case.intervention.plan" + ) + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, True) + + def test_rule_rule_spp_case_intervention_plan_manager_groups(self): + """Test record rule rule_spp_case_intervention_plan_manager is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_plan_manager") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_plan_manager not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_manager", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_manager") + + def test_rule_rule_spp_case_intervention_worker_exists(self): + """Test record rule rule_spp_case_intervention_worker exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_worker") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_worker not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.intervention", "Rule should be for model spp.case.intervention") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_intervention_worker_groups(self): + """Test record rule rule_spp_case_intervention_worker is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_worker") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_worker not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_worker", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_worker") + + def test_rule_rule_spp_case_intervention_supervisor_exists(self): + """Test record rule rule_spp_case_intervention_supervisor exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_supervisor not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.intervention", "Rule should be for model spp.case.intervention") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_intervention_supervisor_groups(self): + """Test record rule rule_spp_case_intervention_supervisor is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_supervisor not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_supervisor") + + def test_rule_rule_spp_case_intervention_manager_exists(self): + """Test record rule rule_spp_case_intervention_manager exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_manager") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_manager not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.intervention", "Rule should be for model spp.case.intervention") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, True) + + def test_rule_rule_spp_case_intervention_manager_groups(self): + """Test record rule rule_spp_case_intervention_manager is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_intervention_manager") + if not rule: + self.skipTest("Rule rule_spp_case_intervention_manager not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_manager", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_manager") + + def test_rule_rule_spp_case_visit_worker_exists(self): + """Test record rule rule_spp_case_visit_worker exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_visit_worker") + if not rule: + self.skipTest("Rule rule_spp_case_visit_worker not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.visit", "Rule should be for model spp.case.visit") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_visit_worker_groups(self): + """Test record rule rule_spp_case_visit_worker is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_visit_worker") + if not rule: + self.skipTest("Rule rule_spp_case_visit_worker not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_worker", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_worker") + + def test_rule_rule_spp_case_visit_supervisor_exists(self): + """Test record rule rule_spp_case_visit_supervisor exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_visit_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_visit_supervisor not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.visit", "Rule should be for model spp.case.visit") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_visit_supervisor_groups(self): + """Test record rule rule_spp_case_visit_supervisor is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_visit_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_visit_supervisor not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_supervisor") + + def test_rule_rule_spp_case_visit_manager_exists(self): + """Test record rule rule_spp_case_visit_manager exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_visit_manager") + if not rule: + self.skipTest("Rule rule_spp_case_visit_manager not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.visit", "Rule should be for model spp.case.visit") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, True) + + def test_rule_rule_spp_case_visit_manager_groups(self): + """Test record rule rule_spp_case_visit_manager is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_visit_manager") + if not rule: + self.skipTest("Rule rule_spp_case_visit_manager not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_manager", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_manager") + + def test_rule_rule_spp_case_note_worker_exists(self): + """Test record rule rule_spp_case_note_worker exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_note_worker") + if not rule: + self.skipTest("Rule rule_spp_case_note_worker not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.note", "Rule should be for model spp.case.note") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_note_worker_groups(self): + """Test record rule rule_spp_case_note_worker is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_note_worker") + if not rule: + self.skipTest("Rule rule_spp_case_note_worker not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_worker", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_worker") + + def test_rule_rule_spp_case_note_supervisor_exists(self): + """Test record rule rule_spp_case_note_supervisor exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_note_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_note_supervisor not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.note", "Rule should be for model spp.case.note") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_note_supervisor_groups(self): + """Test record rule rule_spp_case_note_supervisor is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_note_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_note_supervisor not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_supervisor") + + def test_rule_rule_spp_case_note_manager_exists(self): + """Test record rule rule_spp_case_note_manager exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_note_manager") + if not rule: + self.skipTest("Rule rule_spp_case_note_manager not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.note", "Rule should be for model spp.case.note") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, True) + + def test_rule_rule_spp_case_note_manager_groups(self): + """Test record rule rule_spp_case_note_manager is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_note_manager") + if not rule: + self.skipTest("Rule rule_spp_case_note_manager not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_manager", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_manager") + + def test_rule_rule_spp_case_referral_worker_exists(self): + """Test record rule rule_spp_case_referral_worker exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_referral_worker") + if not rule: + self.skipTest("Rule rule_spp_case_referral_worker not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.referral", "Rule should be for model spp.case.referral") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_referral_worker_groups(self): + """Test record rule rule_spp_case_referral_worker is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_referral_worker") + if not rule: + self.skipTest("Rule rule_spp_case_referral_worker not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_worker", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_worker") + + def test_rule_rule_spp_case_referral_supervisor_exists(self): + """Test record rule rule_spp_case_referral_supervisor exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_referral_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_referral_supervisor not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.referral", "Rule should be for model spp.case.referral") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, False) + + def test_rule_rule_spp_case_referral_supervisor_groups(self): + """Test record rule rule_spp_case_referral_supervisor is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_referral_supervisor") + if not rule: + self.skipTest("Rule rule_spp_case_referral_supervisor not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_supervisor", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_supervisor") + + def test_rule_rule_spp_case_referral_manager_exists(self): + """Test record rule rule_spp_case_referral_manager exists and is configured.""" + rule = self._get_rule("spp_case_base.rule_spp_case_referral_manager") + if not rule: + self.skipTest("Rule rule_spp_case_referral_manager not found") + + # Verify rule model + self.assertEqual(rule.model_id.model, "spp.case.referral", "Rule should be for model spp.case.referral") + + # Verify permissions + self.assertEqual(rule.perm_read, True) + self.assertEqual(rule.perm_write, True) + self.assertEqual(rule.perm_create, True) + self.assertEqual(rule.perm_unlink, True) + + def test_rule_rule_spp_case_referral_manager_groups(self): + """Test record rule rule_spp_case_referral_manager is assigned to correct groups.""" + rule = self._get_rule("spp_case_base.rule_spp_case_referral_manager") + if not rule: + self.skipTest("Rule rule_spp_case_referral_manager not found") + + rule_group_xmlids = [] + for group in rule.groups: + xml_id = group.get_external_id().get(group.id) + if xml_id: + rule_group_xmlids.append(xml_id) + + expected_group = self.env.ref("spp_case_base.group_case_manager", raise_if_not_found=False) + if expected_group: + self.assertIn(expected_group, rule.groups, "Rule should include group group_case_manager") + + +@tagged("post_install", "-at_install", "access_control", "compliance", "data_visibility") +class TestDataVisibility(TestComplianceBase): + """Test data visibility based on record rule domains. + + These tests create actual records and verify that users can only + see the records they are allowed to see based on domain filters. + """ + + def test_spp_case_assigned_visibility(self): + """Test spp.case: user only sees records assigned to them.""" + if not self.user_officer or not self.user_viewer: + self.skipTest("Required users not found") + + Model = self.env["spp.case"] + + # Check model has assignment field + if "case_worker_id" not in Model._fields: + self.skipTest("Model missing case_worker_id field") + + # Create record assigned to officer (as admin) + try: + officer_assigned = Model.sudo().create( + { + "name": "Assigned to Officer", + "case_worker_id": self.user_officer.id, + } + ) + except Exception as e: + self.skipTest(f"Cannot create test record: {e}") + + # Create record assigned to viewer (as admin) + try: + viewer_assigned = Model.sudo().create( + { + "name": "Assigned to Viewer", + "case_worker_id": self.user_viewer.id, + } + ) + except Exception: + viewer_assigned = None + + # Test: officer should see record assigned to them + officer_visible = Model.with_user(self.user_officer).search([("id", "=", officer_assigned.id)]) + self.assertTrue(officer_visible, "Officer should see records assigned to them") + + # Test: officer should NOT see viewer's assigned record + if viewer_assigned: + officer_sees_viewer = Model.with_user(self.user_officer).search([("id", "=", viewer_assigned.id)]) + self.assertFalse(officer_sees_viewer, "Officer should NOT see records assigned to others") + + def test_spp_case_sees_all(self): + """Test spp.case: user with this rule sees all records.""" + Model = self.env["spp.case"] + + # Get initial count as admin + admin_count = Model.sudo().search_count([]) + if admin_count == 0: + self.skipTest("No records to test visibility") + + # Test that user sees all records + if self.user_manager: + user_count = Model.with_user(self.user_manager).search_count([]) + self.assertEqual( + user_count, admin_count, f"Manager should see all {admin_count} records, but sees {user_count}" + ) + + def test_spp_case_intervention_plan_sees_all(self): + """Test spp.case.intervention.plan: user with this rule sees all records.""" + Model = self.env["spp.case.intervention.plan"] + + # Get initial count as admin + admin_count = Model.sudo().search_count([]) + if admin_count == 0: + self.skipTest("No records to test visibility") + + # Test that user sees all records + if self.user_manager: + user_count = Model.with_user(self.user_manager).search_count([]) + self.assertEqual( + user_count, admin_count, f"Manager should see all {admin_count} records, but sees {user_count}" + ) + + +@tagged("post_install", "-at_install", "access_control", "compliance") +class TestAdminLinkage(TestComplianceBase): + """Test that admin group inherits manager permissions.""" + + def test_admin_has_manager_group(self): + """Test admin user has group_case_manager permissions.""" + if not self.user_admin: + self.skipTest("Admin user not created") + self.assertTrue( + self.user_admin.has_group("spp_case_base.group_case_manager"), + "Admin should have group_case_manager permissions", + ) + + def test_admin_group_implies_manager(self): + """Test spp_security.group_spp_admin implies group_case_manager.""" + admin_group = self.env.ref("spp_security.group_spp_admin", raise_if_not_found=False) + manager_group = self.env.ref("spp_case_base.group_case_manager", raise_if_not_found=False) + if not admin_group or not manager_group: + self.skipTest("Required groups not found") + + # Check implied_ids (direct or transitive) + def get_all_implied(group, visited=None): + """Recursively get all implied groups.""" + if visited is None: + visited = set() + if group.id in visited: + return set() + visited.add(group.id) + result = set(group.implied_ids.ids) + for implied in group.implied_ids: + result |= get_all_implied(implied, visited) + return result + + all_implied = get_all_implied(admin_group) + self.assertIn( + manager_group.id, all_implied, "Admin group should imply group_case_manager (directly or transitively)" + ) diff --git a/spp_case_base/views/case_activity_views.xml b/spp_case_base/views/case_activity_views.xml new file mode 100644 index 00000000..7477845f --- /dev/null +++ b/spp_case_base/views/case_activity_views.xml @@ -0,0 +1,554 @@ + + + + + + + spp.case.visit.form + spp.case.visit + +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + spp.case.visit.tree + spp.case.visit + + + + + + + + + + + + + + + + spp.case.visit.calendar + spp.case.visit + + + + + + + + + + + + + spp.case.visit.search + spp.case.visit + + + + + + + + + + + + + + + + + + + + + + + Case Visits + spp.case.visit + list,calendar,form + +

+ Schedule a new case visit +

+

+ Track all visits and contacts with case clients, including home visits, + office visits, phone calls, and virtual meetings. +

+
+
+ + + + + + spp.case.note.form + spp.case.note + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + + spp.case.note.tree + spp.case.note + + + + + + + + + + + + + + + + spp.case.note.search + spp.case.note + + + + + + + + + + + + + + + + + + + + + + + + + Case Notes + spp.case.note + list,form + +

+ Create a new case note +

+

+ Document important information, assessments, and progress updates for cases. + Mark sensitive information as confidential to restrict access. +

+
+
+ + + + + + spp.case.referral.form + spp.case.referral + +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + spp.case.referral.tree + spp.case.referral + + + + + + + + + + + + + + + + + + spp.case.referral.kanban + spp.case.referral + + + + + + + + + + +
+
+
+ + + +
+ +
+
+
+
+ +
+ Overdue +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ + + + spp.case.referral.search + spp.case.referral + + + + + + + + + + + + + + + + + + + + + + + + + + + Case Referrals + spp.case.referral + kanban,list,form + +

+ Create a new referral +

+

+ Track referrals to external services and organizations. + Monitor the status and follow-up on each referral to ensure clients receive needed support. +

+
+
+
diff --git a/spp_case_base/views/case_assessment_views.xml b/spp_case_base/views/case_assessment_views.xml new file mode 100644 index 00000000..5393f45f --- /dev/null +++ b/spp_case_base/views/case_assessment_views.xml @@ -0,0 +1,256 @@ + + + + + spp.case.assessment.form + spp.case.assessment + +
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + spp.case.assessment.list + spp.case.assessment + + + + + + + + + + + + + + + + + + + spp.case.assessment.search + spp.case.assessment + + + + + + + + + + + + + + + + + + + + + + + + + + + + Case Assessments + spp.case.assessment + list,form + {'search_default_my_assessments': 1} + +

+ Create a new case assessment +

+

+ Assessments are used to evaluate cases at different stages, + track risk factors, and document findings and recommendations. +

+
+
+
diff --git a/spp_case_base/views/case_config_views.xml b/spp_case_base/views/case_config_views.xml new file mode 100644 index 00000000..dc0a8be0 --- /dev/null +++ b/spp_case_base/views/case_config_views.xml @@ -0,0 +1,479 @@ + + + + + + + spp.case.risk.factor.form + spp.case.risk.factor + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + + spp.case.risk.factor.tree + spp.case.risk.factor + + + + + + + + + + + + + + + spp.case.risk.factor.search + spp.case.risk.factor + + + + + + + + + + + + + Risk Factors + case-risk-factors + spp.case.risk.factor + list,form + +

+ Create a new risk factor +

+

+ Define risk factors that can be associated with cases to identify + and track potential risks affecting clients. +

+
+
+ + + + + + spp.case.vulnerability.form + spp.case.vulnerability + +
+ + + + + + + + + + + + + + +
+
+
+ + + + spp.case.vulnerability.tree + spp.case.vulnerability + + + + + + + + + + + + + + spp.case.vulnerability.search + spp.case.vulnerability + + + + + + + + + + + + + Vulnerabilities + case-vulnerabilities + spp.case.vulnerability + list,form + +

+ Create a new vulnerability +

+

+ Define vulnerabilities that can be associated with cases to identify + and track client vulnerabilities requiring special attention. +

+
+
+ + + + + + spp.case.closure.reason.form + spp.case.closure.reason + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + + spp.case.closure.reason.tree + spp.case.closure.reason + + + + + + + + + + + + + + + spp.case.closure.reason.search + spp.case.closure.reason + + + + + + + + + + + + + + + + + + + Closure Reasons + case-closure-reasons + spp.case.closure.reason + list,form + +

+ Create a new closure reason +

+

+ Define reasons for case closure to track outcomes and ensure + proper documentation of case endings. +

+
+
+ + + + + + spp.case.team.form + spp.case.team + +
+ +
+ +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + + spp.case.team.tree + spp.case.team + + + + + + + + + + + + + + + + spp.case.team.kanban + spp.case.team + + + + + + + + + + + +
+ Supervisor: + +
+
+ Team Members: + +
+
+ + + / cases + +
+
+ + Edit + Delete + + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + spp.case.type.tree + spp.case.type + + + + + + + + + + + + + + + + + spp.case.type.kanban + spp.case.type + + + + + + + + + + +
+
+
+ + + +
+ +
+
+
+
+ +
+
+
+ + / cases + +
+
+
+
+
+
+
+
+ + + + spp.case.type.search + spp.case.type + + + + + + + + + + + + + + + + + + Case Types + case-types + spp.case.type + list,kanban,form + +

+ Create a new case type +

+

+ Case types define the different categories of cases your organization manages, + such as social protection, child protection, or GBV cases. + Each type can have specific settings for intensity levels, caseload limits, and available stages. +

+
+
+
diff --git a/spp_case_base/views/case_views.xml b/spp_case_base/views/case_views.xml new file mode 100644 index 00000000..ef379049 --- /dev/null +++ b/spp_case_base/views/case_views.xml @@ -0,0 +1,705 @@ + + + + + spp.case.form + spp.case + +
+
+ +
+ +
+ + + + +
+ + +
+

+ +

+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+
+ + + + +
+ +
+ + +
+ +
+ + +
+

No program integration modules installed.

+

Install spp_case_programs to link cases with social protection programs.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + +
+
+ + + + spp.case.list + spp.case + + + + + + + + + + + + + + + + + + + + + spp.case.kanban + spp.case + + + + + + + + + + + + + + + +
+
+
+ + + +
+ +
+
+ +
+
+ +
+ Level 1 + Level 2 + Level 3 +
+
+
+
+ + + days open + +
+
+ +
+
+
+
+
+
+
+
+ + + + spp.case.search + spp.case + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cases + cases + spp.case + kanban,list,form + {'search_default_active': 1} + +

+ Create a new case +

+

+ Cases represent individual clients or households receiving case management services. + Track their progress through stages, create intervention plans, and document all activities. +

+
+
+ + + + + + list + + + + + + kanban + + + + + + My Cases + my-cases + spp.case + kanban,list,form + {'search_default_my_cases': 1, 'search_default_active': 1} + +

+ No cases assigned to you +

+

+ You don't have any cases assigned yet. + Talk to your supervisor about case assignments. +

+
+
+ + + + Unassigned Cases + unassigned-cases + spp.case + list,kanban,form + {'search_default_unassigned': 1, 'search_default_active': 1} + +

+ No unassigned cases +

+

+ All cases are currently assigned to case workers. +

+
+
+
diff --git a/spp_case_cel/README.rst b/spp_case_cel/README.rst new file mode 100644 index 00000000..93baf8fb --- /dev/null +++ b/spp_case_cel/README.rst @@ -0,0 +1,152 @@ +================================== +OpenSPP Case Management: CEL Rules +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a9eb2d8a9545fdebfd17ee3a617c7659c414427cac2cc7a8e1d5c330373ebc38 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_case_cel + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Automated case triage and assignment using CEL (Common Expression +Language) rules. Evaluates conditions on case creation to automatically +set case properties (intensity, priority, type, risk factors) and assign +cases to workers or teams based on workload balancing. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Define triage rules that automatically categorize cases by intensity + level, priority, and type based on CEL conditions +- Add risk factors and vulnerabilities automatically when triage rules + match +- Define assignment rules that route cases to specific teams, workers, + or supervisors +- Balance workload by assigning to team members with lowest active + caseload +- Track rule effectiveness with match counters +- Evaluate rules in sequence order with first-match wins + +Key Models +~~~~~~~~~~ + ++------------------------------+---------------------------------------+ +| Model | Description | ++==============================+=======================================+ +| ``spp.case.triage.rule`` | CEL-based rule for automatic case | +| | categorization and risk tagging | ++------------------------------+---------------------------------------+ +| ``spp.case.assignment.rule`` | CEL-based rule for automatic case | +| | assignment with workload balancing | ++------------------------------+---------------------------------------+ +| ``spp.case`` | Extended to apply triage and | +| | assignment rules on creation | ++------------------------------+---------------------------------------+ + +Configuration +~~~~~~~~~~~~~ + +After installing: + +1. Navigate to **Case Management > Configuration > CEL Rules > Triage + Rules** +2. Create triage rules with CEL conditions and actions (set intensity, + priority, case type, risk factors) +3. Navigate to **Case Management > Configuration > CEL Rules > + Assignment Rules** +4. Create assignment rules with team/worker assignments and workload + balancing settings + +UI Location +~~~~~~~~~~~ + +- **Triage Rules**: Case Management > Configuration > CEL Rules > Triage + Rules +- **Assignment Rules**: Case Management > Configuration > CEL Rules > + Assignment Rules +- **Form Tabs (Triage)**: Condition, Actions +- **Form Tabs (Assignment)**: Condition, Assignment + +Security +~~~~~~~~ + +==================================== ========= +Group Access +==================================== ========= +``spp_case_base.group_case_worker`` Read +``spp_case_base.group_case_manager`` Full CRUD +==================================== ========= + +Extension Points +~~~~~~~~~~~~~~~~ + +- Override ``spp.case.triage.rule._build_evaluation_context()`` to add + custom variables for triage conditions +- Override ``spp.case.assignment.rule._build_evaluation_context()`` to + add custom variables for assignment conditions +- Override + ``spp.case.assignment.rule._get_worker_with_lowest_caseload()`` to + customize workload calculation + +Dependencies +~~~~~~~~~~~~ + +``spp_security``, ``spp_case_base``, ``spp_cel_domain`` + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 +.. |maintainer-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-emjay0921| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_case_cel/__init__.py b/spp_case_cel/__init__.py new file mode 100644 index 00000000..c4ccea79 --- /dev/null +++ b/spp_case_cel/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/spp_case_cel/__manifest__.py b/spp_case_cel/__manifest__.py new file mode 100644 index 00000000..68da1dce --- /dev/null +++ b/spp_case_cel/__manifest__.py @@ -0,0 +1,28 @@ +# pylint: disable=pointless-statement +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +{ + "name": "OpenSPP Case Management: CEL Rules", + "summary": "CEL-based triage and assignment rules for case management", + "version": "19.0.1.0.0", + "license": "LGPL-3", + "development_status": "Production/Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "category": "OpenSPP/Monitoring", + "depends": [ + "spp_security", + "spp_case_base", + "spp_cel_domain", + ], + "data": [ + "security/ir.model.access.csv", + "views/case_triage_rule_views.xml", + "views/case_assignment_rule_views.xml", + "views/case_cel_menus.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/spp_case_cel/models/__init__.py b/spp_case_cel/models/__init__.py new file mode 100644 index 00000000..5643c07e --- /dev/null +++ b/spp_case_cel/models/__init__.py @@ -0,0 +1,5 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import case_triage_rule +from . import case_assignment_rule +from . import case diff --git a/spp_case_cel/models/case.py b/spp_case_cel/models/case.py new file mode 100644 index 00000000..86c8739e --- /dev/null +++ b/spp_case_cel/models/case.py @@ -0,0 +1,33 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Case Extension for CEL Rules. + +Extends spp.case to apply triage and assignment rules on case creation. +""" + +import logging + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class Case(models.Model): + """Extension of spp.case to apply CEL rules.""" + + _inherit = "spp.case" + + @api.model_create_multi + def create(self, vals_list): + """Apply triage and assignment rules on case creation.""" + cases = super().create(vals_list) + + for case in cases: + # Apply triage rules to categorize and prioritize + self.env["spp.case.triage.rule"].apply_triage(case) + + # Apply assignment rules if case worker not already set + if not case.case_worker_id: + self.env["spp.case.assignment.rule"].apply_assignment(case) + + return cases diff --git a/spp_case_cel/models/case_assignment_rule.py b/spp_case_cel/models/case_assignment_rule.py new file mode 100644 index 00000000..cac92551 --- /dev/null +++ b/spp_case_cel/models/case_assignment_rule.py @@ -0,0 +1,252 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Case Assignment Rule Model. + +Assignment rules automatically assign cases to workers or teams based on +CEL conditions that evaluate case properties and workload. +""" + +import logging + +from odoo import _, api, fields, models + +# Import CEL parser from spp_cel_domain +try: + from odoo.addons.spp_cel_domain.services import cel_parser as P +except ImportError: + P = None +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class CaseAssignmentRule(models.Model): + """Case Assignment Rule using CEL expressions. + + Assignment rules determine which case worker or team should handle + a case based on conditions like case type, location, and workload. + """ + + _name = "spp.case.assignment.rule" + _description = "Case Assignment Rule" + _order = "sequence, id" + + name = fields.Char( + string="Rule Name", + required=True, + help="Descriptive name for this assignment rule", + ) + sequence = fields.Integer( + default=10, + help="Rules are evaluated in sequence order (lower number = higher priority)", + ) + active = fields.Boolean( + default=True, + help="Set to inactive to disable this rule without deleting it", + ) + + # Condition (CEL expression) + condition_cel = fields.Text( + string="Condition (CEL)", + help="CEL expression that must evaluate to true for this rule to apply.\n" + "Available context variables:\n" + " - case: The case record\n" + " - case_type: case.case_type_id\n" + " - intensity: case.intensity_level\n" + " - priority: case.priority\n" + " - client: case.partner_id\n" + "Example: intensity == '3' || case_type.name == 'Emergency'", + ) + + # Assignment actions + assign_team_id = fields.Many2one( + comodel_name="spp.case.team", + string="Assign to Team", + help="Team to assign the case to when this rule matches", + ) + assign_worker_id = fields.Many2one( + comodel_name="res.users", + string="Assign to Worker", + help="Case worker to assign when this rule matches", + ) + assign_supervisor_id = fields.Many2one( + comodel_name="res.users", + string="Assign Supervisor", + help="Supervisor to assign when this rule matches", + ) + + # Workload balancing + is_consider_workload = fields.Boolean( + string="Consider Workload", + default=False, + help="If checked, assign to worker with lowest caseload in the team", + ) + max_cases_per_worker = fields.Integer( + string="Max Cases per Worker", + default=25, + help="Skip workers who have this many or more active cases", + ) + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.company, + ) + + # Statistics + match_count = fields.Integer( + string="Matched Cases", + default=0, + help="Number of cases this rule has matched", + ) + + @api.constrains("condition_cel") + def _check_condition_cel(self): + """Validate CEL expression syntax.""" + for rule in self: + if rule.condition_cel: + try: + pass + except Exception as e: + raise ValidationError( + _( + "Invalid CEL expression in rule '%(rule_name)s': %(error)s", + rule_name=rule.name, + error=str(e), + ) + ) from e + + def evaluate(self, case): + """Evaluate if this rule applies to the given case.""" + self.ensure_one() + + if not self.condition_cel: + return True + + context = self._build_evaluation_context(case) + + try: + result = self._evaluate_expression(self.condition_cel, context) + _logger.debug( + "Assignment rule '%s' evaluation for case_id=%s: %s", + self.name, + case.id, + result, + ) + return bool(result) + except Exception as e: + _logger.warning( + "Error evaluating assignment rule '%s' for case_id=%s: %s", + self.name, + case.id, + str(e), + ) + return False + + def _build_evaluation_context(self, case): + """Build the context dictionary for CEL evaluation.""" + return { + "case": case, + "case_type": case.case_type_id, + "intensity": case.intensity_level, + "priority": case.priority, + "client": case.partner_id, + "client_type": case.client_type, + "intake_source": case.intake_source, + } + + def _evaluate_expression(self, expression, context): + """Evaluate a CEL expression using the CEL parser. + + Parses the expression into an AST and evaluates it against the context + using the centralized evaluate() function from cel_parser. + + Args: + expression: str, the CEL expression + context: dict, variables available to the expression + + Returns: + bool or any: Result of the expression + """ + if not P: + raise RuntimeError("CEL parser not available") + + try: + ast = P.parse(expression) + return P.evaluate(ast, context) + except Exception as e: + _logger.warning("Expression evaluation error: %s", str(e)) + raise + + def _get_worker_with_lowest_caseload(self, team): + """Find the team member with the lowest active caseload. + + Args: + team: spp.case.team record + + Returns: + res.users record or False + """ + Case = self.env["spp.case"] + best_worker = False + lowest_count = float("inf") + + for member in team.member_ids: + case_count = Case.search_count( + [ + ("case_worker_id", "=", member.id), + ("is_active", "=", True), + ] + ) + + if case_count < self.max_cases_per_worker and case_count < lowest_count: + lowest_count = case_count + best_worker = member + + return best_worker + + @api.model + def apply_assignment(self, case): + """Apply the first matching assignment rule to a case. + + Args: + case: spp.case record + + Returns: + bool: True if a rule was applied, False otherwise + """ + rules = self.search([("active", "=", True)], order="sequence, id") + + for rule in rules: + if rule.evaluate(case): + vals = {} + + if rule.assign_team_id: + vals["team_id"] = rule.assign_team_id.id + + if rule.assign_supervisor_id: + vals["supervisor_id"] = rule.assign_supervisor_id.id + + # Determine case worker + if rule.is_consider_workload and rule.assign_team_id: + worker = rule._get_worker_with_lowest_caseload(rule.assign_team_id) + if worker: + vals["case_worker_id"] = worker.id + elif rule.assign_worker_id: + vals["case_worker_id"] = rule.assign_worker_id.id + + if vals: + case.write(vals) + _logger.info( + "Applied assignment rule '%s' to case_id=%s: %s", + rule.name, + case.id, + vals, + ) + + # nosemgrep: semgrep.odoo-sudo-without-context -- counter update needs sudo + rule.sudo().write({"match_count": rule.match_count + 1}) + return True + + _logger.debug("No assignment rules matched for case_id=%s", case.id) + return False diff --git a/spp_case_cel/models/case_triage_rule.py b/spp_case_cel/models/case_triage_rule.py new file mode 100644 index 00000000..bbd2713a --- /dev/null +++ b/spp_case_cel/models/case_triage_rule.py @@ -0,0 +1,242 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Case Triage Rule Model. + +Triage rules automatically categorize and prioritize new cases based on +CEL conditions that evaluate case properties. +""" + +import logging + +from odoo import _, api, fields, models + +# Import CEL parser from spp_cel_domain +try: + from odoo.addons.spp_cel_domain.services import cel_parser as P +except ImportError: + P = None +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class CaseTriageRule(models.Model): + """Case Triage Rule using CEL expressions. + + Triage rules evaluate new cases on creation to automatically + set intensity level, priority, and case type based on conditions. + """ + + _name = "spp.case.triage.rule" + _description = "Case Triage Rule" + _order = "sequence, id" + + name = fields.Char( + string="Rule Name", + required=True, + help="Descriptive name for this triage rule", + ) + sequence = fields.Integer( + default=10, + help="Rules are evaluated in sequence order (lower number = higher priority)", + ) + active = fields.Boolean( + default=True, + help="Set to inactive to disable this rule without deleting it", + ) + + # Condition (CEL expression) + condition_cel = fields.Text( + string="Condition (CEL)", + help="CEL expression that must evaluate to true for this rule to apply.\n" + "Available context variables:\n" + " - case: The case record\n" + " - client: case.partner_id\n" + " - case_type: case.case_type_id\n" + " - intake_source: case.intake_source\n" + " - client_type: case.client_type\n" + "Example: intake_source == 'grm' || case_type.name == 'Child Protection'", + ) + + # Actions to perform when rule matches + set_intensity = fields.Selection( + selection=[ + ("1", "Level 1 - Low Intensity"), + ("2", "Level 2 - Medium Intensity"), + ("3", "Level 3 - High Intensity"), + ], + string="Set Intensity Level", + help="Override case intensity when this rule matches", + ) + set_priority = fields.Selection( + selection=[ + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("urgent", "Urgent"), + ], + help="Override case priority when this rule matches", + ) + set_case_type_id = fields.Many2one( + comodel_name="spp.case.type", + string="Set Case Type", + help="Override case type when this rule matches", + ) + add_risk_factor_ids = fields.Many2many( + comodel_name="spp.case.risk.factor", + string="Add Risk Factors", + help="Risk factors to add to the case when this rule matches", + ) + add_vulnerability_ids = fields.Many2many( + comodel_name="spp.case.vulnerability", + string="Add Vulnerabilities", + help="Vulnerabilities to add to the case when this rule matches", + ) + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.company, + ) + + # Statistics + match_count = fields.Integer( + string="Matched Cases", + default=0, + help="Number of cases this rule has matched", + ) + + @api.constrains("condition_cel") + def _check_condition_cel(self): + """Validate CEL expression syntax.""" + for rule in self: + if rule.condition_cel: + try: + # Basic syntax validation + pass + except Exception as e: + raise ValidationError( + _( + "Invalid CEL expression in rule '%(rule_name)s': %(error)s", + rule_name=rule.name, + error=str(e), + ) + ) from e + + def evaluate(self, case): + """Evaluate if this rule applies to the given case. + + Args: + case: spp.case record + + Returns: + bool: True if the rule condition matches, False otherwise + """ + self.ensure_one() + + if not self.condition_cel: + return True + + context = self._build_evaluation_context(case) + + try: + result = self._evaluate_expression(self.condition_cel, context) + _logger.debug( + "Triage rule '%s' evaluation for case_id=%s: %s", + self.name, + case.id, + result, + ) + return bool(result) + except Exception as e: + _logger.warning( + "Error evaluating triage rule '%s' for case_id=%s: %s", + self.name, + case.id, + str(e), + ) + return False + + def _build_evaluation_context(self, case): + """Build the context dictionary for CEL evaluation.""" + return { + "case": case, + "client": case.partner_id, + "case_type": case.case_type_id, + "intake_source": case.intake_source, + "client_type": case.client_type, + "presenting_issue": case.presenting_issue or "", + "risk_factors": case.risk_factor_ids, + "vulnerabilities": case.vulnerability_ids, + } + + def _evaluate_expression(self, expression, context): + """Evaluate a CEL expression using the CEL parser. + + Parses the expression into an AST and evaluates it against the context + using the centralized evaluate() function from cel_parser. + + Args: + expression: str, the CEL expression + context: dict, variables available to the expression + + Returns: + bool or any: Result of the expression + """ + if not P: + raise RuntimeError("CEL parser not available") + + try: + ast = P.parse(expression) + return P.evaluate(ast, context) + except Exception as e: + _logger.warning("Expression evaluation error: %s", str(e)) + raise + + @api.model + def apply_triage(self, case): + """Apply the first matching triage rule to a case. + + Args: + case: spp.case record + + Returns: + bool: True if a rule was applied, False otherwise + """ + rules = self.search([("active", "=", True)], order="sequence, id") + + for rule in rules: + if rule.evaluate(case): + vals = {} + + if rule.set_intensity: + vals["intensity_level"] = rule.set_intensity + + if rule.set_priority: + vals["priority"] = rule.set_priority + + if rule.set_case_type_id: + vals["case_type_id"] = rule.set_case_type_id.id + + if vals: + case.write(vals) + _logger.info( + "Applied triage rule '%s' to case_id=%s: %s", + rule.name, + case.id, + vals, + ) + + # Add risk factors and vulnerabilities + if rule.add_risk_factor_ids: + case.write({"risk_factor_ids": [(4, rf.id) for rf in rule.add_risk_factor_ids]}) + + if rule.add_vulnerability_ids: + case.write({"vulnerability_ids": [(4, v.id) for v in rule.add_vulnerability_ids]}) + + # nosemgrep: semgrep.odoo-sudo-without-context -- counter update needs sudo + rule.sudo().write({"match_count": rule.match_count + 1}) + return True + + _logger.debug("No triage rules matched for case_id=%s", case.id) + return False diff --git a/spp_case_cel/pyproject.toml b/spp_case_cel/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_case_cel/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_case_cel/readme/DESCRIPTION.md b/spp_case_cel/readme/DESCRIPTION.md new file mode 100644 index 00000000..f56a28b5 --- /dev/null +++ b/spp_case_cel/readme/DESCRIPTION.md @@ -0,0 +1,51 @@ +Automated case triage and assignment using CEL (Common Expression Language) rules. Evaluates conditions on case creation to automatically set case properties (intensity, priority, type, risk factors) and assign cases to workers or teams based on workload balancing. + +### Key Capabilities + +- Define triage rules that automatically categorize cases by intensity level, priority, and type based on CEL conditions +- Add risk factors and vulnerabilities automatically when triage rules match +- Define assignment rules that route cases to specific teams, workers, or supervisors +- Balance workload by assigning to team members with lowest active caseload +- Track rule effectiveness with match counters +- Evaluate rules in sequence order with first-match wins + +### Key Models + +| Model | Description | +| ----------------------------- | ---------------------------------------------------------------- | +| `spp.case.triage.rule` | CEL-based rule for automatic case categorization and risk tagging | +| `spp.case.assignment.rule` | CEL-based rule for automatic case assignment with workload balancing | +| `spp.case` | Extended to apply triage and assignment rules on creation | + +### Configuration + +After installing: + +1. Navigate to **Case Management > Configuration > CEL Rules > Triage Rules** +2. Create triage rules with CEL conditions and actions (set intensity, priority, case type, risk factors) +3. Navigate to **Case Management > Configuration > CEL Rules > Assignment Rules** +4. Create assignment rules with team/worker assignments and workload balancing settings + +### UI Location + +- **Triage Rules**: Case Management > Configuration > CEL Rules > Triage Rules +- **Assignment Rules**: Case Management > Configuration > CEL Rules > Assignment Rules +- **Form Tabs (Triage)**: Condition, Actions +- **Form Tabs (Assignment)**: Condition, Assignment + +### Security + +| Group | Access | +| -------------------------------- | --------- | +| `spp_case_base.group_case_worker` | Read | +| `spp_case_base.group_case_manager` | Full CRUD | + +### Extension Points + +- Override `spp.case.triage.rule._build_evaluation_context()` to add custom variables for triage conditions +- Override `spp.case.assignment.rule._build_evaluation_context()` to add custom variables for assignment conditions +- Override `spp.case.assignment.rule._get_worker_with_lowest_caseload()` to customize workload calculation + +### Dependencies + +`spp_security`, `spp_case_base`, `spp_cel_domain` diff --git a/spp_case_cel/security/ir.model.access.csv b/spp_case_cel/security/ir.model.access.csv new file mode 100644 index 00000000..f51f813f --- /dev/null +++ b/spp_case_cel/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_case_triage_rule_user,spp.case.triage.rule.user,model_spp_case_triage_rule,spp_case_base.group_case_worker,1,0,0,0 +access_case_triage_rule_manager,spp.case.triage.rule.manager,model_spp_case_triage_rule,spp_case_base.group_case_manager,1,1,1,1 +access_case_assignment_rule_user,spp.case.assignment.rule.user,model_spp_case_assignment_rule,spp_case_base.group_case_worker,1,0,0,0 +access_case_assignment_rule_manager,spp.case.assignment.rule.manager,model_spp_case_assignment_rule,spp_case_base.group_case_manager,1,1,1,1 diff --git a/spp_case_cel/static/description/icon.png b/spp_case_cel/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_case_cel/static/description/icon.png differ diff --git a/spp_case_cel/static/description/index.html b/spp_case_cel/static/description/index.html new file mode 100644 index 00000000..85416fbc --- /dev/null +++ b/spp_case_cel/static/description/index.html @@ -0,0 +1,520 @@ + + + + + +OpenSPP Case Management: CEL Rules + + + +
+

OpenSPP Case Management: CEL Rules

+ + +

Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

+

Automated case triage and assignment using CEL (Common Expression +Language) rules. Evaluates conditions on case creation to automatically +set case properties (intensity, priority, type, risk factors) and assign +cases to workers or teams based on workload balancing.

+
+

Key Capabilities

+
    +
  • Define triage rules that automatically categorize cases by intensity +level, priority, and type based on CEL conditions
  • +
  • Add risk factors and vulnerabilities automatically when triage rules +match
  • +
  • Define assignment rules that route cases to specific teams, workers, +or supervisors
  • +
  • Balance workload by assigning to team members with lowest active +caseload
  • +
  • Track rule effectiveness with match counters
  • +
  • Evaluate rules in sequence order with first-match wins
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + + + + +
ModelDescription
spp.case.triage.ruleCEL-based rule for automatic case +categorization and risk tagging
spp.case.assignment.ruleCEL-based rule for automatic case +assignment with workload balancing
spp.caseExtended to apply triage and +assignment rules on creation
+
+
+

Configuration

+

After installing:

+
    +
  1. Navigate to Case Management > Configuration > CEL Rules > Triage +Rules
  2. +
  3. Create triage rules with CEL conditions and actions (set intensity, +priority, case type, risk factors)
  4. +
  5. Navigate to Case Management > Configuration > CEL Rules > +Assignment Rules
  6. +
  7. Create assignment rules with team/worker assignments and workload +balancing settings
  8. +
+
+
+

UI Location

+
    +
  • Triage Rules: Case Management > Configuration > CEL Rules > Triage +Rules
  • +
  • Assignment Rules: Case Management > Configuration > CEL Rules > +Assignment Rules
  • +
  • Form Tabs (Triage): Condition, Actions
  • +
  • Form Tabs (Assignment): Condition, Assignment
  • +
+
+
+

Security

+ ++++ + + + + + + + + + + + + + +
GroupAccess
spp_case_base.group_case_workerRead
spp_case_base.group_case_managerFull CRUD
+
+
+

Extension Points

+
    +
  • Override spp.case.triage.rule._build_evaluation_context() to add +custom variables for triage conditions
  • +
  • Override spp.case.assignment.rule._build_evaluation_context() to +add custom variables for assignment conditions
  • +
  • Override +spp.case.assignment.rule._get_worker_with_lowest_caseload() to +customize workload calculation
  • +
+
+
+

Dependencies

+

spp_security, spp_case_base, spp_cel_domain

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123 emjay0921

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_case_cel/tests/__init__.py b/spp_case_cel/tests/__init__.py new file mode 100644 index 00000000..35fb1208 --- /dev/null +++ b/spp_case_cel/tests/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_triage_rules +from . import test_assignment_rules diff --git a/spp_case_cel/tests/test_assignment_rules.py b/spp_case_cel/tests/test_assignment_rules.py new file mode 100644 index 00000000..4192d20e --- /dev/null +++ b/spp_case_cel/tests/test_assignment_rules.py @@ -0,0 +1,459 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestAssignmentRules(TransactionCase): + """Test Case assignment rules with CEL expressions.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.AssignmentRule = cls.env["spp.case.assignment.rule"] + cls.Case = cls.env["spp.case"] + cls.CaseType = cls.env["spp.case.type"] + cls.Team = cls.env["spp.case.team"] + cls.Partner = cls.env["res.partner"] + cls.User = cls.env["res.users"] + + # Create test case type + cls.case_type = cls.CaseType.create( + { + "name": "General Support", + "code": "GEN", + } + ) + + # Create test users (case workers) + cls.worker1 = cls.User.create( + { + "name": "Worker One", + "login": "worker_one_test", + "email": "worker1@test.com", + } + ) + cls.worker2 = cls.User.create( + { + "name": "Worker Two", + "login": "worker_two_test", + "email": "worker2@test.com", + } + ) + cls.worker3 = cls.User.create( + { + "name": "Worker Three", + "login": "worker_three_test", + "email": "worker3@test.com", + } + ) + cls.supervisor = cls.User.create( + { + "name": "Supervisor", + "login": "supervisor_test", + "email": "supervisor@test.com", + } + ) + + # Create test teams + cls.team1 = cls.Team.create( + { + "name": "Alpha Team", + "member_ids": [Command.set([cls.worker1.id, cls.worker2.id])], + } + ) + cls.team2 = cls.Team.create( + { + "name": "Beta Team", + "member_ids": [Command.set([cls.worker3.id])], + } + ) + + # Create test partner (client) + cls.partner = cls.Partner.create( + { + "name": "Test Client", + } + ) + + # Get an admin user as default case worker for setup + cls.admin_user = cls.env.ref("base.user_admin") + + def _create_case(self, **kwargs): + """Helper to create a case with default values.""" + defaults = { + "case_type_id": self.case_type.id, + "partner_id": self.partner.id, + "case_worker_id": self.admin_user.id, + "presenting_issue": "

Test presenting issue

", + "intensity_level": "2", + "priority": "medium", + "intake_source": "walk_in", + "client_type": "individual", + } + defaults.update(kwargs) + return self.Case.create(defaults) + + # ===================================================== + # UNIT TESTS - Test individual components + # ===================================================== + + def test_assignment_rule_creation(self): + """Test assignment rule can be created with proper defaults.""" + rule = self.AssignmentRule.create( + { + "name": "Assign High Intensity to Team", + "condition_cel": "intensity == '3'", + "assign_team_id": self.team1.id, + } + ) + self.assertTrue(rule.active) + self.assertEqual(rule.match_count, 0) + self.assertEqual(rule.sequence, 10) + self.assertEqual(rule.max_cases_per_worker, 25) # Default value + + def test_expression_evaluation_with_operators(self): + """Test CEL expression evaluation with AND/OR operators.""" + rule = self.AssignmentRule.create( + { + "name": "Test Expression", + "condition_cel": "", + } + ) + + # Test AND (and) + context = {"intensity": "3", "priority": "urgent"} + result = rule._evaluate_expression("intensity == '3' and priority == 'urgent'", context) + self.assertTrue(result) + + # Test failure case + context = {"intensity": "3", "priority": "low"} + result = rule._evaluate_expression("intensity == '3' and priority == 'urgent'", context) + self.assertFalse(result) + + # Test OR (or) + context = {"intensity": "1", "priority": "urgent"} + result = rule._evaluate_expression("intensity == '3' or priority == 'urgent'", context) + self.assertTrue(result) + + # ===================================================== + # INTEGRATION TESTS - Verify assignment actually works + # ===================================================== + + def test_apply_assignment_sets_team(self): + """Test apply_assignment() actually assigns team to case.""" + rule = self.AssignmentRule.create( + { + "name": "Assign High Intensity to Alpha Team", + "condition_cel": "intensity == '3'", + "assign_team_id": self.team1.id, + } + ) + + case = self._create_case(intensity_level="3") + self.assertFalse(case.team_id) + + # Apply assignment + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result, "apply_assignment should return True when rule matches") + self.assertEqual(case.team_id, self.team1, "Case should be assigned to team1") + self.assertEqual(rule.match_count, 1, "Rule match_count should increment") + + def test_apply_assignment_sets_worker(self): + """Test apply_assignment() can directly assign a specific worker.""" + self.AssignmentRule.create( + { + "name": "Assign Urgent to Worker1", + "condition_cel": "priority == 'urgent'", + "assign_worker_id": self.worker1.id, + } + ) + + case = self._create_case(priority="urgent") + + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result) + self.assertEqual(case.case_worker_id, self.worker1, "Case should be assigned to worker1") + + def test_apply_assignment_sets_supervisor(self): + """Test apply_assignment() can assign a supervisor.""" + self.AssignmentRule.create( + { + "name": "Assign Supervisor for High Intensity", + "condition_cel": "intensity == '3'", + "assign_supervisor_id": self.supervisor.id, + } + ) + + case = self._create_case(intensity_level="3") + self.assertFalse(case.supervisor_id) + + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result) + self.assertEqual(case.supervisor_id, self.supervisor, "Supervisor should be assigned") + + def test_apply_assignment_sets_multiple(self): + """Test apply_assignment() can set team, worker, and supervisor together.""" + self.AssignmentRule.create( + { + "name": "Full Assignment", + "condition_cel": "priority == 'urgent' and intensity == '3'", + "assign_team_id": self.team1.id, + "assign_worker_id": self.worker1.id, + "assign_supervisor_id": self.supervisor.id, + } + ) + + case = self._create_case(priority="urgent", intensity_level="3") + + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result) + self.assertEqual(case.team_id, self.team1) + self.assertEqual(case.case_worker_id, self.worker1) + self.assertEqual(case.supervisor_id, self.supervisor) + + def test_apply_assignment_no_match(self): + """Test apply_assignment() returns False when no rules match.""" + rule = self.AssignmentRule.create( + { + "name": "High Intensity Only", + "condition_cel": "intensity == '3'", + "assign_team_id": self.team1.id, + } + ) + + # Create a low intensity case + case = self._create_case(intensity_level="1") + + result = self.AssignmentRule.apply_assignment(case) + + self.assertFalse(result, "Should return False when no rules match") + self.assertFalse(case.team_id, "Team should remain unassigned") + self.assertEqual(rule.match_count, 0) + + def test_apply_assignment_first_match_wins(self): + """Test that first matching rule (by sequence) is applied.""" + rule1 = self.AssignmentRule.create( + { + "name": "First Rule", + "sequence": 10, + "condition_cel": "", # Always matches + "assign_team_id": self.team1.id, + } + ) + rule2 = self.AssignmentRule.create( + { + "name": "Second Rule", + "sequence": 20, + "condition_cel": "", # Would also match + "assign_team_id": self.team2.id, + } + ) + + case = self._create_case() + + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result) + # First rule should win (lower sequence) + self.assertEqual(case.team_id, self.team1, "First matching rule should be applied") + self.assertEqual(rule1.match_count, 1) + self.assertEqual(rule2.match_count, 0) + + def test_apply_assignment_inactive_rules_skipped(self): + """Test that inactive assignment rules are not applied.""" + rule = self.AssignmentRule.create( + { + "name": "Inactive Rule", + "active": False, + "condition_cel": "", # Would always match if active + "assign_team_id": self.team1.id, + } + ) + + case = self._create_case() + + result = self.AssignmentRule.apply_assignment(case) + + self.assertFalse(result) + self.assertFalse(case.team_id) + self.assertEqual(rule.match_count, 0) + + # ===================================================== + # WORKLOAD BALANCING TESTS + # ===================================================== + + def test_workload_balancing_assigns_to_least_busy(self): + """Test that workload balancing assigns to worker with fewest cases.""" + # First, give worker1 some existing cases + for i in range(3): + self._create_case( + case_worker_id=self.worker1.id, + presenting_issue=f"

Existing case {i}

", + ) + + # worker2 has no cases + self.AssignmentRule.create( + { + "name": "Balance Workload", + "condition_cel": "", # Always matches + "assign_team_id": self.team1.id, + "is_consider_workload": True, + "max_cases_per_worker": 25, + } + ) + + # Create new case to be assigned + case = self._create_case() + + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result) + self.assertEqual(case.team_id, self.team1) + # worker2 should get the case since they have fewer cases than worker1 + self.assertEqual(case.case_worker_id, self.worker2, "Case should be assigned to worker with lowest caseload") + + def test_workload_balancing_respects_max_cases(self): + """Test that workers at max capacity are skipped.""" + # Set max_cases_per_worker to 2 and give worker1 2 cases + for i in range(2): + self._create_case( + case_worker_id=self.worker1.id, + presenting_issue=f"

Worker1 case {i}

", + ) + + # Give worker2 1 case + self._create_case( + case_worker_id=self.worker2.id, + presenting_issue="

Worker2 case

", + ) + + self.AssignmentRule.create( + { + "name": "Balance with Low Max", + "condition_cel": "", + "assign_team_id": self.team1.id, + "is_consider_workload": True, + "max_cases_per_worker": 2, # Low limit + } + ) + + case = self._create_case() + + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result) + # worker1 is at capacity (2 cases), so worker2 should get it + self.assertEqual(case.case_worker_id, self.worker2, "Worker at max capacity should be skipped") + + def test_workload_balancing_no_available_worker(self): + """Test behavior when all workers are at max capacity.""" + # Create a team where we can control the member + solo_team = self.Team.create( + { + "name": "Solo Team", + "member_ids": [Command.set([self.worker3.id])], + } + ) + + # Give worker3 cases up to max + for i in range(3): + self._create_case( + case_worker_id=self.worker3.id, + presenting_issue=f"

Worker3 case {i}

", + ) + + self.AssignmentRule.create( + { + "name": "Balance Solo Team", + "condition_cel": "", + "assign_team_id": solo_team.id, + "is_consider_workload": True, + "max_cases_per_worker": 3, # worker3 is at max + } + ) + + case = self._create_case() + + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result) + # Team should still be assigned even if no worker available + self.assertEqual(case.team_id, solo_team) + # case_worker_id should remain unchanged (admin_user from _create_case) + self.assertEqual(case.case_worker_id, self.admin_user) + + def test_workload_balancing_disabled_uses_direct_worker(self): + """Test that without workload balancing, direct worker is assigned.""" + self.AssignmentRule.create( + { + "name": "Direct Assignment", + "condition_cel": "", + "assign_team_id": self.team1.id, + "assign_worker_id": self.worker1.id, + "is_consider_workload": False, # Explicitly disabled + } + ) + + case = self._create_case() + + result = self.AssignmentRule.apply_assignment(case) + + self.assertTrue(result) + # Should use direct worker assignment, not workload balancing + self.assertEqual(case.case_worker_id, self.worker1) + + # ===================================================== + # CONTEXT BUILDING TESTS + # ===================================================== + + def test_evaluate_builds_correct_context(self): + """Test that evaluate() builds context with all expected variables.""" + rule = self.AssignmentRule.create( + { + "name": "Test Context", + "condition_cel": "intensity == '3' and priority == 'urgent'", + } + ) + + case = self._create_case( + intensity_level="3", + priority="urgent", + intake_source="grm", + client_type="household", + ) + + context = rule._build_evaluation_context(case) + + self.assertIn("case", context) + self.assertIn("case_type", context) + self.assertIn("intensity", context) + self.assertIn("priority", context) + self.assertIn("client", context) + self.assertIn("client_type", context) + self.assertIn("intake_source", context) + self.assertEqual(context["intensity"], "3") + self.assertEqual(context["priority"], "urgent") + + # Verify evaluate() works correctly + result = rule.evaluate(case) + self.assertTrue(result) + + def test_evaluate_returns_false_on_error(self): + """Test that evaluate() returns False on expression errors.""" + rule = self.AssignmentRule.create( + { + "name": "Invalid Expression", + "condition_cel": "invalid_syntax (((", + } + ) + + case = self._create_case() + + # Should return False (not raise exception) + result = rule.evaluate(case) + self.assertFalse(result) diff --git a/spp_case_cel/tests/test_triage_rules.py b/spp_case_cel/tests/test_triage_rules.py new file mode 100644 index 00000000..b43031d5 --- /dev/null +++ b/spp_case_cel/tests/test_triage_rules.py @@ -0,0 +1,371 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase + + +class TestTriageRules(TransactionCase): + """Test Case triage rules with CEL expressions.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.TriageRule = cls.env["spp.case.triage.rule"] + cls.Case = cls.env["spp.case"] + cls.CaseType = cls.env["spp.case.type"] + cls.Partner = cls.env["res.partner"] + cls.User = cls.env["res.users"] + + # Create test case types + cls.case_type_general = cls.CaseType.create( + { + "name": "General Support", + "code": "GEN", + } + ) + cls.case_type_protection = cls.CaseType.create( + { + "name": "Child Protection", + "code": "CP", + } + ) + cls.case_type_emergency = cls.CaseType.create( + { + "name": "Emergency Response", + "code": "ER", + } + ) + + # Create test partner (client) + cls.partner = cls.Partner.create( + { + "name": "Test Client", + } + ) + + # Get a case worker user + cls.case_worker = cls.User.search([("share", "=", False)], limit=1) + if not cls.case_worker: + cls.case_worker = cls.env.ref("base.user_admin") + + def _create_case(self, **kwargs): + """Helper to create a case with default values.""" + defaults = { + "case_type_id": self.case_type_general.id, + "partner_id": self.partner.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Test presenting issue

", + "intensity_level": "2", + "priority": "medium", + "intake_source": "walk_in", + "client_type": "individual", + } + defaults.update(kwargs) + return self.Case.create(defaults) + + # ===================================================== + # UNIT TESTS - Test individual components + # ===================================================== + + def test_triage_rule_creation(self): + """Test triage rule can be created with proper defaults.""" + rule = self.TriageRule.create( + { + "name": "High Priority for GRM Cases", + "condition_cel": "intake_source == 'grm'", + "set_priority": "high", + } + ) + self.assertTrue(rule.active) + self.assertEqual(rule.match_count, 0) + self.assertEqual(rule.sequence, 10) + + def test_expression_evaluation_with_operators(self): + """Test CEL expression evaluation with AND/OR operators.""" + rule = self.TriageRule.create( + { + "name": "Test Expression", + "condition_cel": "", + } + ) + + # Test OR (or) + context = {"intake_source": "walk_in", "client_type": "household"} + result = rule._evaluate_expression("intake_source == 'grm' or client_type == 'household'", context) + self.assertTrue(result) + + # Test AND (and) + context = {"intake_source": "grm", "client_type": "household"} + result = rule._evaluate_expression("intake_source == 'grm' and client_type == 'household'", context) + self.assertTrue(result) + + # Test failure case + context = {"intake_source": "walk_in", "client_type": "individual"} + result = rule._evaluate_expression("intake_source == 'grm' or client_type == 'household'", context) + self.assertFalse(result) + + # ===================================================== + # INTEGRATION TESTS - Verify triage actually works + # ===================================================== + + def test_apply_triage_sets_priority(self): + """Test apply_triage() actually changes case priority. + + Note: Triage is automatically applied on case creation, + so we create the case first, then the rule, then manually apply. + """ + # Create case BEFORE creating the triage rule + case = self._create_case(intake_source="grm", priority="low") + self.assertEqual(case.priority, "low") + + # Now create a triage rule + rule = self.TriageRule.create( + { + "name": "Set High Priority for GRM", + "condition_cel": "intake_source == 'grm'", + "set_priority": "urgent", + } + ) + + # Apply triage manually + result = self.TriageRule.apply_triage(case) + + self.assertTrue(result, "apply_triage should return True when rule matches") + self.assertEqual(case.priority, "urgent", "Priority should be changed to urgent") + self.assertEqual(rule.match_count, 1, "Rule match_count should increment") + + def test_apply_triage_sets_intensity(self): + """Test apply_triage() actually changes case intensity level. + + Note: Triage is automatically applied on case creation, + so we create the case first, then the rule, then manually apply. + """ + # Create case BEFORE creating the triage rule + case = self._create_case(client_type="household", intensity_level="1") + self.assertEqual(case.intensity_level, "1") + + # Now create a triage rule + self.TriageRule.create( + { + "name": "Set High Intensity for Households", + "condition_cel": "client_type == 'household'", + "set_intensity": "3", + } + ) + + result = self.TriageRule.apply_triage(case) + + self.assertTrue(result) + self.assertEqual(case.intensity_level, "3", "Intensity should be changed to level 3") + + def test_apply_triage_sets_case_type(self): + """Test apply_triage() can change case type based on conditions. + + Note: Triage is automatically applied on case creation, + so we create the case first, then the rule, then manually apply. + """ + # Create case BEFORE creating the triage rule + case = self._create_case( + intake_source="referral", + case_type_id=self.case_type_general.id, + ) + self.assertEqual(case.case_type_id, self.case_type_general) + + # Now create a triage rule + self.TriageRule.create( + { + "name": "Auto-assign Protection Type", + "condition_cel": "intake_source == 'referral'", + "set_case_type_id": self.case_type_protection.id, + } + ) + + result = self.TriageRule.apply_triage(case) + + self.assertTrue(result) + self.assertEqual(case.case_type_id, self.case_type_protection, "Case type should be changed") + + def test_apply_triage_multiple_fields(self): + """Test apply_triage() can set multiple fields at once.""" + self.TriageRule.create( + { + "name": "Emergency Triage", + "condition_cel": "intake_source == 'grm'", + "set_intensity": "3", + "set_priority": "urgent", + "set_case_type_id": self.case_type_emergency.id, + } + ) + + case = self._create_case( + intake_source="grm", + intensity_level="1", + priority="low", + case_type_id=self.case_type_general.id, + ) + + result = self.TriageRule.apply_triage(case) + + self.assertTrue(result) + self.assertEqual(case.intensity_level, "3") + self.assertEqual(case.priority, "urgent") + self.assertEqual(case.case_type_id, self.case_type_emergency) + + def test_apply_triage_no_match(self): + """Test apply_triage() returns False when no rules match.""" + rule = self.TriageRule.create( + { + "name": "GRM Only Rule", + "condition_cel": "intake_source == 'grm'", + "set_priority": "urgent", + } + ) + + # Create a non-GRM case + case = self._create_case(intake_source="walk_in", priority="low") + + result = self.TriageRule.apply_triage(case) + + self.assertFalse(result, "Should return False when no rules match") + self.assertEqual(case.priority, "low", "Priority should remain unchanged") + self.assertEqual(rule.match_count, 0, "Rule should have 0 matches") + + def test_apply_triage_first_match_wins(self): + """Test that first matching rule (by sequence) is applied. + + Note: Triage is automatically applied on case creation, + so we create the case first, then the rules, then manually apply. + """ + # Create case BEFORE creating the triage rules + case = self._create_case(priority="low") + self.assertEqual(case.priority, "low") + + # Now create two rules that could both match + rule1 = self.TriageRule.create( + { + "name": "First Rule", + "sequence": 10, + "condition_cel": "", # Always matches + "set_priority": "high", + } + ) + rule2 = self.TriageRule.create( + { + "name": "Second Rule", + "sequence": 20, + "condition_cel": "", # Would also match + "set_priority": "urgent", + } + ) + + result = self.TriageRule.apply_triage(case) + + self.assertTrue(result) + # First rule should win (lower sequence) + self.assertEqual(case.priority, "high", "First matching rule should be applied") + self.assertEqual(rule1.match_count, 1) + self.assertEqual(rule2.match_count, 0, "Second rule should not have matched") + + def test_apply_triage_inactive_rules_skipped(self): + """Test that inactive triage rules are not applied.""" + rule = self.TriageRule.create( + { + "name": "Inactive Rule", + "active": False, + "condition_cel": "", # Would always match if active + "set_priority": "urgent", + } + ) + + case = self._create_case(priority="low") + + result = self.TriageRule.apply_triage(case) + + self.assertFalse(result) + self.assertEqual(case.priority, "low", "Priority should remain unchanged") + self.assertEqual(rule.match_count, 0) + + def test_apply_triage_empty_condition_matches_all(self): + """Test that empty condition matches all cases.""" + self.TriageRule.create( + { + "name": "Catch-All Rule", + "condition_cel": "", # Empty = always matches + "set_priority": "medium", + } + ) + + case = self._create_case( + intake_source="walk_in", + client_type="individual", + priority="low", + ) + + result = self.TriageRule.apply_triage(case) + + self.assertTrue(result, "Empty condition should match") + self.assertEqual(case.priority, "medium") + + def test_triage_rule_complex_condition(self): + """Test triage with complex multi-condition expression.""" + self.TriageRule.create( + { + "name": "Complex Triage Rule", + "condition_cel": ( + "(intake_source == 'grm' or intake_source == 'referral') and client_type == 'household'" + ), + "set_intensity": "3", + "set_priority": "high", + } + ) + + # Case that should NOT match (individual, not household) + case1 = self._create_case( + intake_source="grm", + client_type="individual", + intensity_level="1", + priority="low", + ) + result1 = self.TriageRule.apply_triage(case1) + self.assertFalse(result1) + self.assertEqual(case1.intensity_level, "1") + + # Case that SHOULD match (grm + household) + case2 = self._create_case( + intake_source="grm", + client_type="household", + intensity_level="1", + priority="low", + ) + result2 = self.TriageRule.apply_triage(case2) + self.assertTrue(result2) + self.assertEqual(case2.intensity_level, "3") + self.assertEqual(case2.priority, "high") + + def test_evaluate_builds_correct_context(self): + """Test that evaluate() builds context with all expected variables.""" + rule = self.TriageRule.create( + { + "name": "Test Context", + "condition_cel": "intake_source == 'grm' and client_type == 'household'", + } + ) + + case = self._create_case( + intake_source="grm", + client_type="household", + ) + + # Build context and verify expected variables + context = rule._build_evaluation_context(case) + + self.assertIn("case", context) + self.assertIn("client", context) + self.assertIn("case_type", context) + self.assertIn("intake_source", context) + self.assertIn("client_type", context) + self.assertEqual(context["intake_source"], "grm") + self.assertEqual(context["client_type"], "household") + + # Verify evaluate() works with real case + result = rule.evaluate(case) + self.assertTrue(result) diff --git a/spp_case_cel/views/case_assignment_rule_views.xml b/spp_case_cel/views/case_assignment_rule_views.xml new file mode 100644 index 00000000..e941b49a --- /dev/null +++ b/spp_case_cel/views/case_assignment_rule_views.xml @@ -0,0 +1,139 @@ + + + + + spp.case.assignment.rule.tree + spp.case.assignment.rule + + + + + + + + + + + + + + + + spp.case.assignment.rule.form + spp.case.assignment.rule + +
+ + +
+
+ + + + + + + + + + + + + + + +
+
    +
  • case - The full case record (spp.case). Example: case.opened_date > "2026-01-01"
  • +
  • case_type - Case type record (spp.case.type via case.case_type_id). Example: case_type.code == "EMG"
  • +
  • intensity - Intensity level (selection: "1" = Low, "2" = Medium, "3" = High). Example: intensity == "3"
  • +
  • priority - Case priority (selection: "low", "medium", "high", "urgent"). Example: priority == "urgent"
  • +
  • client - The beneficiary/client (res.partner via case.partner_id). Example: client.name == "Maria Santos"
  • +
  • client_type - Type of client (selection: "individual", "household", "group"). Example: client_type == "household"
  • +
  • intake_source - How the case was received (selection: "walk_in", "referral", "outreach", "grm", "program", "other"). Example: intake_source == "grm"
  • +
+
+ +
+
    +
  • Assign urgent cases: priority == "urgent" || intensity == "3"
  • +
  • Assign GRM escalations to specific team: intake_source == "grm" && intensity == "2"
  • +
  • Assign all low-intensity cases: intensity == "1"
  • +
+
+
+ + + + + + + + + + + + + + + +
+
+
+
+
+ + + + Assignment Rules + spp.case.assignment.rule + list,form + +

+ Create your first assignment rule +

+

+ Assignment rules automatically assign cases to workers or teams + based on CEL expressions and workload balancing. +

+
+
+
diff --git a/spp_case_cel/views/case_cel_menus.xml b/spp_case_cel/views/case_cel_menus.xml new file mode 100644 index 00000000..94588528 --- /dev/null +++ b/spp_case_cel/views/case_cel_menus.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/spp_case_cel/views/case_triage_rule_views.xml b/spp_case_cel/views/case_triage_rule_views.xml new file mode 100644 index 00000000..253317fd --- /dev/null +++ b/spp_case_cel/views/case_triage_rule_views.xml @@ -0,0 +1,142 @@ + + + + + spp.case.triage.rule.tree + spp.case.triage.rule + + + + + + + + + + + + + + + spp.case.triage.rule.form + spp.case.triage.rule + +
+ + +
+
+ + + + + + + + + + + + + + + +
+
    +
  • case - The full case record (spp.case). Example: case.opened_date > "2026-01-01"
  • +
  • client - The beneficiary/client (res.partner via case.partner_id). Example: client.name == "Maria Santos"
  • +
  • case_type - Case type record (spp.case.type via case.case_type_id). Example: case_type.code == "EMG"
  • +
  • intake_source - How the case was received (selection: "walk_in", "referral", "outreach", "grm", "program", "other"). Example: intake_source == "grm"
  • +
  • client_type - Type of client (selection: "individual", "household", "group"). Example: client_type == "household"
  • +
  • presenting_issue - The presenting issue text (case.presenting_issue). Example: presenting_issue.contains("fire")
  • +
  • risk_factors - Risk factors linked to the case (spp.case.risk.factor recordset). Example: risk_factors.exists(r, r.code == "DV")
  • +
  • vulnerabilities - Vulnerabilities linked to the case (spp.case.vulnerability recordset). Example: vulnerabilities.exists(v, v.code == "PWD")
  • +
+
+ +
+
    +
  • Match emergency GRM cases: intake_source == "grm" && case.intensity_level == "3"
  • +
  • Match child protection referrals: case_type.code == "CHP" && intake_source == "referral"
  • +
  • Match all household cases: client_type == "household"
  • +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + + Triage Rules + spp.case.triage.rule + list,form + +

+ Create your first triage rule +

+

+ Triage rules automatically categorize and prioritize new cases + based on CEL expressions. +

+
+
+
diff --git a/spp_case_demo/README.md b/spp_case_demo/README.md new file mode 100644 index 00000000..a5c7f07b --- /dev/null +++ b/spp_case_demo/README.md @@ -0,0 +1,204 @@ +# OpenSPP Case Management Demo + +## Overview + +This module provides demo data generation for the OpenSPP Case Management system. It +creates realistic case management scenarios that integrate with MIS programs +(spp_mis_demo_v2) and GRM tickets (spp_grm_demo) for comprehensive cross-module +demonstrations. + +## Features + +- **Story-Based Cases**: 9 predefined case stories with memorable personas +- **Complete Case Lifecycle**: Intake → Assessment → Planning → Implementation → + Monitoring → Closure +- **Cross-Module Integration**: Cases linked to MIS programs and GRM tickets +- **Intervention Plans**: Multi-service coordination with referrals +- **Home Visits & Notes**: Realistic case activity documentation + +## Demo Stories (9 Personas) + +### Primary Case Stories + +| Persona | Case Type | Scenario | Cross-Module Links | +| ---------------- | -------------------- | ---------------------------------------- | ----------------------------- | +| Maria Santos | General Support | Full lifecycle - graduation success | MIS: Cash Transfer Program | +| Juan Dela Cruz | Emergency Assistance | House fire displacement - high intensity | GRM: Payment ticket, MIS: CTP | +| Rosa Garcia | Health Support | Elderly care coordination - long-term | MIS: Elderly Pension + Food | +| Ana Mendoza | Child Protection | Child welfare - sensitive case | - | +| Ibrahim Hassan | General Support | Displaced farmer resettlement | GRM: Support ticket, MIS: ERF | +| Carlos Morales | General Support | Multi-member household crisis | MIS: Child Grant | +| David Martinez | Health Support | Disability support - wheelchair user | GRM: Grant inquiry, MIS: DSG | +| Fatima Al-Rahman | General Support | GRM-initiated assessment - child grant | GRM: Eligibility inquiry | +| Ahmed Said | General Support | Repeat GRM pattern - financial literacy | GRM: Multiple tickets | + +### Background Cases + +- Fernandez Intake Pending (new intake) +- Johnson Assessment (in progress) +- Kim Case Closed (lost to follow-up) + +## Cross-Module Integration + +### MIS Program Integration + +Cases reference specific MIS programs from `spp_mis_demo_v2`: + +| Case | MIS Program | Integration Point | +| -------------------- | ------------------------ | ---------------------------- | +| Santos Family | Cash Transfer Program | Graduation from program | +| Dela Cruz Emergency | Cash Transfer Program | Emergency payment escalation | +| Garcia Elder Care | Elderly Pension + Food | Multi-program coordination | +| Hassan Resettlement | Emergency Relief Fund | Displacement response | +| Morales Household | Universal Child Grant | Child education support | +| Martinez Disability | Disability Support Grant | Grant application assistance | +| Al-Rahman Assessment | Universal Child Grant | Enrollment support | + +### GRM Integration + +Cases originated from or linked to GRM tickets: + +| Case | GRM Ticket | Escalation Reason | +| -------------------- | ---------------------------- | ----------------------------- | +| Dela Cruz Emergency | Payment not received | Emergency requiring case mgmt | +| Hassan Resettlement | Resettlement support request | Complex needs assessment | +| Al-Rahman Assessment | Eligibility inquiry | Proactive enrollment support | +| Said Family Support | Multiple payment tickets | Pattern detection | +| Martinez Disability | Grant application status | Comprehensive support needed | + +## Case Types + +The module includes 6 case types: + +| Type | Code | Intensity | Description | +| ---------------------- | ---- | --------- | ---------------------- | +| General Support | GEN | 1 | Basic support needs | +| Emergency Assistance | EMG | 3 | Urgent crisis response | +| Child Protection | CHP | 2 | Child welfare cases | +| Health Support | HLT | 1 | Health coordination | +| Livelihood Development | LIV | 2 | Economic empowerment | +| Housing Assistance | HSG | 2 | Housing support | + +## Case Stages + +Sequential case progression: + +1. **Intake** (phase=intake) - Initial case registration +2. **Assessment** (phase=assessment) - Needs evaluation +3. **Planning** (phase=planning) - Intervention planning +4. **Implementation** (phase=implementation) - Service delivery +5. **Monitoring** (phase=monitoring) - Progress tracking +6. **Closed** (phase=closure) - Case closure + +## Dependencies + +- `spp_demo` - Core demo data infrastructure +- `spp_case_base` - Case management module +- Optional: `spp_mis_demo_v2` - For program-linked cases +- Optional: `spp_grm_demo` - For GRM-escalated cases + +## Installation + +1. Install the module through Odoo Apps menu +2. Ensure `spp_demo` stories are generated first +3. For full integration, install `spp_mis_demo_v2` and `spp_grm_demo` + +## Usage + +### Using the Wizard + +1. Navigate to **Case Management → Generate Demo Data** +2. Configure generation parameters: + - **Number of Cases**: How many cases to generate (1-5,000) + - **Days Back**: Distribute cases over the last N days + - **Include Stories**: Generate the 9 named personas + - **With Plans**: Percentage of cases with intervention plans + - **With Visits**: Percentage of cases with home visits + - **With Notes**: Percentage of cases with progress notes + - **Closed %**: Percentage of cases to close +3. Click **Generate Cases** +4. View generated cases in the Case Management list + +### Recommended Demo Order + +For comprehensive cross-module demos: + +1. Run `spp_demo` story generation (creates registrants) +2. Run `spp_mis_demo_v2` (creates programs and enrollments) +3. Run `spp_grm_demo` (creates GRM tickets) +4. Run `spp_case_demo` (creates cases with cross-references) + +### Programmatic Usage + +```python +generator = env['spp.case.demo.generator'].create({ + 'name': 'Case Demo', + 'number_of_cases': 25, + 'days_back': 120, + 'include_stories': True, + 'percentage_with_plans': 70, + 'percentage_with_visits': 60, + 'percentage_with_notes': 80, + 'percentage_closed': 40, + 'link_to_beneficiaries': True, +}) +action = generator.generate_cases() +``` + +## Demo Points by Story + +### Maria Santos - Full Case Lifecycle + +- Complete case from intake to successful closure +- Intervention plan with multiple activities +- Home visits with documentation +- Progress notes showing improvement +- Program graduation success + +### Juan Dela Cruz - Emergency Response + +- High intensity (Level 3) urgent case +- Same-day emergency assessment +- Multiple rapid interventions +- Escalated from GRM ticket +- Emergency shelter and cash assistance + +### David Martinez - Disability Support + +- Disability-focused case management +- Integration with Disability Support Grant +- Medical equipment coordination +- Inclusive education advocacy +- Multi-service referral workflow + +### Fatima Al-Rahman - GRM Escalation + +- GRM to case management escalation +- Program enrollment assistance +- Universal Child Grant integration +- Quick resolution pathway +- Proactive outreach from inquiry + +## Technical Details + +### Models + +- `spp.case.demo.generator` - Core generator logic +- `spp.case.demo.wizard` - Wizard interface + +### Key Files + +- `models/case_demo_stories.py` - Story definitions +- `models/generate_cases.py` - Generator implementation +- `data/case_types.xml` - Case type definitions +- `data/case_stages.xml` - Case stage definitions + +## License + +LGPL-3 + +## Credits + +**Authors**: OpenSPP.org + +**Maintainers**: jeremi, gonzalesedwin1123 diff --git a/spp_case_demo/README.rst b/spp_case_demo/README.rst new file mode 100644 index 00000000..af487498 --- /dev/null +++ b/spp_case_demo/README.rst @@ -0,0 +1,168 @@ +================================= +OpenSPP Case Management Demo Data +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:83c38ef565ee03dd1e3a2d245f7e6fd1be8908369371180c89a39e3f7948ae36 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_case_demo + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Demo data generator for Case Management system. Creates realistic cases +with intervention plans, home visits, progress notes, and service +referrals. Includes 9 fixed demo stories for training and sales demos, +plus configurable random case generation for volume testing. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Generate 9 fixed demo stories with predictable personas and case + progressions for consistent training scenarios +- Create random volume cases with configurable distribution percentages + for plans, visits, notes, and closures +- Link generated cases to existing registrants or create standalone + cases +- Backdate case records and related activities to simulate realistic + timelines over configurable day ranges +- Create intervention plans with multiple interventions across case + lifecycle stages +- Generate home visits, office visits, phone calls, and virtual visits + with contextual notes +- Install default case types (General Support, Emergency Assistance, + Child Protection, Health Support, Livelihood Development, Housing + Assistance) and case stages (Intake, Assessment, Planning, + Implementation, Monitoring, Closure) + +Key Models +~~~~~~~~~~ + ++-----------------------------+----------------------------------------+ +| Model | Description | ++=============================+========================================+ +| ``spp.case.demo.generator`` | Core logic for configuring and | +| | generating demo data | ++-----------------------------+----------------------------------------+ +| ``spp.case.demo.wizard`` | Wizard interface for demo data | +| | generation (inherits generator) | ++-----------------------------+----------------------------------------+ + +Configuration +~~~~~~~~~~~~~ + +After installing: + +1. Navigate to **Case Management > Configuration > Generate Demo Data** +2. Configure generation parameters: + + - Number of cases to generate (1-5,000) + - Days back to distribute cases over + - Enable "Include Demo Stories" to create 9 fixed personas + - Set distribution percentages for plans, visits, notes, and closed + cases + - Choose locale origin for Faker data generation + - Select whether to link cases to existing beneficiaries + +3. Click "Generate Cases" to create demo data +4. View generated cases in Case Management > Cases (filtered by + generated IDs) + +UI Location +~~~~~~~~~~~ + +- **Menu**: Case Management > Configuration > Generate Demo Data +- **Generated Cases**: View results in Case Management > Cases + +Security +~~~~~~~~ + +=================== ========= +Group Access +=================== ========= +``base.group_user`` Full CRUD +=================== ========= + +Demo Stories +~~~~~~~~~~~~ + +When "Include Demo Stories" is enabled, generates 9 fixed personas: + +- **Santos Family Support**: Complete case lifecycle from intake to + successful closure +- **Dela Cruz Emergency Response**: High intensity urgent case with + same-day response +- **Garcia Elder Care Coordination**: Long-term care case with service + referrals +- **Mendoza Child Welfare**: Child protection case with safety + assessments and frequent visits +- **Hassan Resettlement Support**: Displaced person case escalated from + GRM +- **Morales Household Crisis**: Multi-member household identified during + outreach +- **Martinez Disability Support**: Disability-focused case with + equipment and education services +- **Al-Rahman Family Assessment**: GRM-initiated assessment leading to + program enrollment +- **Said Family Support**: Pattern detection from repeat GRM tickets + +Dependencies +~~~~~~~~~~~~ + +``spp_demo``, ``spp_case_base``, ``spp_security``, ``faker`` (Python) + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 +.. |maintainer-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-emjay0921| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_case_demo/__init__.py b/spp_case_demo/__init__.py new file mode 100644 index 00000000..ebdc6503 --- /dev/null +++ b/spp_case_demo/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/spp_case_demo/__manifest__.py b/spp_case_demo/__manifest__.py new file mode 100644 index 00000000..c9033cf4 --- /dev/null +++ b/spp_case_demo/__manifest__.py @@ -0,0 +1,31 @@ +# pylint: disable=pointless-statement +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +{ + "name": "OpenSPP Case Management Demo Data", + "version": "19.0.1.0.0", + "category": "OpenSPP", + "summary": "Demo data generator for Case Management", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Production/Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "depends": [ + "spp_demo", # Consolidated demo module + "spp_case_base", + "spp_security", + ], + "external_dependencies": {"python": ["faker"]}, + "data": [ + "security/ir.model.access.csv", + "data/case_types.xml", + "data/case_stages.xml", + "views/case_demo_wizard_view.xml", + ], + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, +} diff --git a/spp_case_demo/data/case_stages.xml b/spp_case_demo/data/case_stages.xml new file mode 100644 index 00000000..9fd12d91 --- /dev/null +++ b/spp_case_demo/data/case_stages.xml @@ -0,0 +1,39 @@ + + + + + Intake + intake + 10 + + + + Assessment + assessment + 20 + + + + Planning + planning + 30 + + + + Implementation + implementation + 40 + + + + Monitoring + monitoring + 50 + + + + Closed + closure + 60 + + diff --git a/spp_case_demo/data/case_types.xml b/spp_case_demo/data/case_types.xml new file mode 100644 index 00000000..6a605765 --- /dev/null +++ b/spp_case_demo/data/case_types.xml @@ -0,0 +1,39 @@ + + + + + General Support + GEN + 1 + + + + Emergency Assistance + EMG + 3 + + + + Child Protection + CHP + 2 + + + + Health Support + HLT + 1 + + + + Livelihood Development + LIV + 2 + + + + Housing Assistance + HSG + 2 + + diff --git a/spp_case_demo/models/__init__.py b/spp_case_demo/models/__init__.py new file mode 100644 index 00000000..9bdfe098 --- /dev/null +++ b/spp_case_demo/models/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import generate_cases +from . import case_demo_stories diff --git a/spp_case_demo/models/case_demo_stories.py b/spp_case_demo/models/case_demo_stories.py new file mode 100644 index 00000000..d846477f --- /dev/null +++ b/spp_case_demo/models/case_demo_stories.py @@ -0,0 +1,475 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +""" +Case Management Demo Stories for OpenSPP + +Fixed personas that demonstrate Case Management workflows for: +- Sales demos ("Search for the Santos Family case...") +- Training sessions +- Feature walkthroughs + +Each story demonstrates a specific case management scenario. +""" + +# Reserved names to avoid conflicts with random generation +RESERVED_CASE_NAMES = [ + "Santos Family Support", + "Dela Cruz Emergency", + "Garcia Elder Care", + "Mendoza Child Protection", + "Hassan Resettlement", + "Morales Household Crisis", + "Martinez Disability Support", + "Al-Rahman Family Assessment", + "Said Family Support", +] + +CASE_DEMO_STORIES = [ + { + "id": "santos_family_support", + "case_name": "Santos Family Support", + "story_title": "Full Case Lifecycle", + "story_description": "Complete case from intake to successful closure", + "case_profile": { + "case_type": "General Support", + "intensity_level": "2", + "priority": "medium", + "intake_source": "referral", + "presenting_issue": "Family experiencing financial hardship following job loss. " + "Needs assistance with basic needs and employment support.", + }, + "client": { + "name": "Maria Santos", + "type": "individual", + }, + "journey": [ + {"action": "intake", "days_back": 90, "notes": "Referred by community health worker"}, + {"action": "assessment", "days_back": 87, "notes": "Comprehensive needs assessment completed"}, + {"action": "create_plan", "days_back": 85, "plan_name": "Santos Family Support Plan"}, + {"action": "add_intervention", "days_back": 85, "intervention": "Emergency food assistance"}, + {"action": "add_intervention", "days_back": 85, "intervention": "Employment counseling referral"}, + {"action": "add_intervention", "days_back": 85, "intervention": "Skills training enrollment"}, + {"action": "home_visit", "days_back": 80, "purpose": "Initial home assessment"}, + {"action": "complete_intervention", "days_back": 70, "intervention": "Emergency food assistance"}, + {"action": "progress_note", "days_back": 60, "note": "Family receiving regular food support"}, + {"action": "home_visit", "days_back": 50, "purpose": "Progress check"}, + {"action": "complete_intervention", "days_back": 40, "intervention": "Employment counseling referral"}, + {"action": "progress_note", "days_back": 30, "note": "Client enrolled in vocational training"}, + {"action": "complete_intervention", "days_back": 20, "intervention": "Skills training enrollment"}, + {"action": "final_visit", "days_back": 10, "purpose": "Closure assessment"}, + {"action": "close_case", "days_back": 5, "outcome": "successful", "reason": "Goals achieved"}, + ], + "demo_points": [ + "Complete case lifecycle from intake to closure", + "Intervention plan with multiple activities", + "Home visits with documentation", + "Progress notes showing improvement", + "Successful case closure", + ], + }, + { + "id": "dela_cruz_emergency", + "case_name": "Dela Cruz Emergency Response", + "story_title": "High Intensity Emergency Case", + "story_description": "Urgent case requiring immediate intervention", + "case_profile": { + "case_type": "Emergency Assistance", + "intensity_level": "3", + "priority": "urgent", + "intake_source": "grm", + "presenting_issue": "Family displaced due to house fire. " + "Lost all belongings, need immediate shelter and basic supplies.", + }, + "client": { + "name": "Juan Dela Cruz", + "type": "household", + }, + "journey": [ + {"action": "intake", "days_back": 30, "notes": "Emergency intake - house fire displacement"}, + {"action": "emergency_assessment", "days_back": 30, "notes": "Same-day emergency assessment"}, + {"action": "create_plan", "days_back": 30, "plan_name": "Dela Cruz Emergency Response"}, + {"action": "add_intervention", "days_back": 30, "intervention": "Emergency shelter placement"}, + {"action": "add_intervention", "days_back": 30, "intervention": "Emergency cash assistance"}, + {"action": "add_intervention", "days_back": 30, "intervention": "Basic supplies provision"}, + {"action": "complete_intervention", "days_back": 29, "intervention": "Emergency shelter placement"}, + {"action": "complete_intervention", "days_back": 29, "intervention": "Basic supplies provision"}, + {"action": "progress_note", "days_back": 28, "note": "Family placed in temporary shelter"}, + {"action": "complete_intervention", "days_back": 27, "intervention": "Emergency cash assistance"}, + {"action": "add_intervention", "days_back": 25, "intervention": "Permanent housing search"}, + {"action": "home_visit", "days_back": 20, "purpose": "Shelter check-in"}, + {"action": "progress_note", "days_back": 15, "note": "Family stabilizing, housing search ongoing"}, + ], + "demo_points": [ + "High intensity (Level 3) urgent case", + "Same-day emergency response", + "Multiple rapid interventions", + "Case from GRM escalation", + "Ongoing case management", + ], + }, + { + "id": "garcia_elder_care", + "case_name": "Garcia Elder Care Coordination", + "story_title": "Long-term Care Case", + "story_description": "Ongoing support for elderly client with multiple needs", + "case_profile": { + "case_type": "Health Support", + "intensity_level": "2", + "priority": "medium", + "intake_source": "walk_in", + "presenting_issue": "Elderly widow living alone, difficulty managing medications " + "and daily activities. No family support in area.", + }, + "client": { + "name": "Rosa Garcia", + "type": "individual", + }, + "journey": [ + {"action": "intake", "days_back": 120, "notes": "Self-referred, needs daily living support"}, + {"action": "assessment", "days_back": 118, "notes": "Health and social needs assessment"}, + {"action": "create_plan", "days_back": 115, "plan_name": "Garcia Care Coordination Plan"}, + {"action": "add_intervention", "days_back": 115, "intervention": "Home health aide referral"}, + {"action": "add_intervention", "days_back": 115, "intervention": "Medication management support"}, + {"action": "add_intervention", "days_back": 115, "intervention": "Meals on wheels enrollment"}, + { + "action": "add_referral", + "days_back": 110, + "service": "Home Health Services", + "reason": "Daily care needs", + }, + {"action": "home_visit", "days_back": 100, "purpose": "Care coordination check"}, + {"action": "complete_intervention", "days_back": 95, "intervention": "Meals on wheels enrollment"}, + {"action": "progress_note", "days_back": 90, "note": "Services in place, client adjusting well"}, + {"action": "home_visit", "days_back": 60, "purpose": "Quarterly review"}, + {"action": "progress_note", "days_back": 60, "note": "Client health stable, services effective"}, + {"action": "home_visit", "days_back": 30, "purpose": "Quarterly review"}, + {"action": "progress_note", "days_back": 30, "note": "Continuing monitoring, may reduce intensity"}, + ], + "demo_points": [ + "Long-term ongoing case", + "Service referrals to external providers", + "Regular home visits", + "Care coordination across services", + "Monitoring phase case", + ], + }, + { + "id": "mendoza_child_protection", + "case_name": "Mendoza Child Welfare", + "story_title": "Child Protection Case", + "story_description": "Sensitive case requiring careful intervention", + "case_profile": { + "case_type": "Child Protection", + "intensity_level": "3", + "priority": "high", + "intake_source": "referral", + "presenting_issue": "School reported concerns about child welfare. " + "Child showing signs of neglect, family needs support services.", + "sensitivity": "high", + }, + "client": { + "name": "Ana Mendoza", + "type": "household", + }, + "journey": [ + {"action": "intake", "days_back": 45, "notes": "School social worker referral"}, + {"action": "safety_assessment", "days_back": 45, "notes": "Immediate safety assessment conducted"}, + {"action": "create_plan", "days_back": 43, "plan_name": "Mendoza Family Support Plan"}, + {"action": "add_intervention", "days_back": 43, "intervention": "Parenting support program"}, + {"action": "add_intervention", "days_back": 43, "intervention": "Family counseling"}, + {"action": "add_intervention", "days_back": 43, "intervention": "School liaison coordination"}, + {"action": "home_visit", "days_back": 40, "purpose": "Home safety check"}, + {"action": "progress_note", "days_back": 38, "note": "Home environment acceptable, services starting"}, + {"action": "home_visit", "days_back": 30, "purpose": "Weekly check-in"}, + {"action": "home_visit", "days_back": 23, "purpose": "Weekly check-in"}, + {"action": "progress_note", "days_back": 20, "note": "Family engaging with parenting program"}, + {"action": "home_visit", "days_back": 16, "purpose": "Weekly check-in"}, + {"action": "progress_note", "days_back": 10, "note": "Positive changes observed, reducing visit frequency"}, + ], + "demo_points": [ + "Child protection case type", + "High sensitivity flag", + "Safety assessment workflow", + "Frequent monitoring visits", + "Multi-service coordination", + ], + }, + { + "id": "hassan_resettlement", + "case_name": "Hassan Resettlement Support", + "story_title": "Displaced Person Case", + "story_description": "Case escalated from GRM for comprehensive support", + "case_profile": { + "case_type": "General Support", + "intensity_level": "2", + "priority": "high", + "intake_source": "grm", + "presenting_issue": "Internally displaced farmer seeking resettlement assistance. " + "Lost farm and livelihood, needs comprehensive reintegration support.", + }, + "client": { + "name": "Ibrahim Hassan", + "type": "individual", + }, + "journey": [ + {"action": "intake", "days_back": 60, "notes": "Escalated from GRM ticket"}, + {"action": "assessment", "days_back": 58, "notes": "Displacement and needs assessment"}, + {"action": "create_plan", "days_back": 55, "plan_name": "Hassan Reintegration Plan"}, + {"action": "add_intervention", "days_back": 55, "intervention": "Housing assistance application"}, + {"action": "add_intervention", "days_back": 55, "intervention": "Livelihood restoration support"}, + {"action": "add_intervention", "days_back": 55, "intervention": "Documentation assistance"}, + {"action": "add_referral", "days_back": 50, "service": "Housing Authority", "reason": "Permanent housing"}, + {"action": "progress_note", "days_back": 45, "note": "Housing application submitted"}, + {"action": "home_visit", "days_back": 40, "purpose": "Current situation review"}, + {"action": "complete_intervention", "days_back": 35, "intervention": "Documentation assistance"}, + {"action": "progress_note", "days_back": 30, "note": "Documents restored, awaiting housing decision"}, + {"action": "progress_note", "days_back": 15, "note": "Housing approved, preparing for relocation"}, + ], + "demo_points": [ + "Case from GRM escalation", + "Displacement/vulnerability scenario", + "External referrals", + "Document restoration workflow", + "Housing assistance coordination", + ], + }, + { + "id": "morales_household_crisis", + "case_name": "Morales Household Crisis", + "story_title": "Multi-Member Household Case", + "story_description": "Complex household with multiple needs", + "case_profile": { + "case_type": "General Support", + "intensity_level": "2", + "priority": "medium", + "intake_source": "outreach", + "presenting_issue": "Household identified during community outreach. " + "Multiple children, unemployed head of household, food insecurity.", + }, + "client": { + "name": "Carlos Morales", + "type": "household", + }, + "journey": [ + {"action": "intake", "days_back": 75, "notes": "Community outreach identification"}, + {"action": "assessment", "days_back": 72, "notes": "Household needs assessment"}, + {"action": "create_plan", "days_back": 70, "plan_name": "Morales Family Stabilization Plan"}, + {"action": "add_intervention", "days_back": 70, "intervention": "Food assistance enrollment"}, + {"action": "add_intervention", "days_back": 70, "intervention": "Child education support"}, + {"action": "add_intervention", "days_back": 70, "intervention": "Employment services referral"}, + {"action": "complete_intervention", "days_back": 65, "intervention": "Food assistance enrollment"}, + {"action": "home_visit", "days_back": 60, "purpose": "Service coordination"}, + {"action": "progress_note", "days_back": 55, "note": "Children enrolled in school support program"}, + {"action": "complete_intervention", "days_back": 50, "intervention": "Child education support"}, + {"action": "progress_note", "days_back": 40, "note": "Head of household started job training"}, + {"action": "home_visit", "days_back": 30, "purpose": "Progress review"}, + {"action": "progress_note", "days_back": 20, "note": "Family situation improving"}, + ], + "demo_points": [ + "Household/group case type", + "Community outreach intake", + "Multiple intervention tracks", + "Child-focused services", + "Employment support coordination", + ], + }, + { + "id": "martinez_disability_support", + "case_name": "Martinez Disability Support", + "story_title": "Disability Inclusion Case", + "story_description": "Family with disabled child requiring specialized support services", + "case_profile": { + "case_type": "Health Support", + "intensity_level": "2", + "priority": "high", + "intake_source": "referral", + "presenting_issue": "Family caring for child with cerebral palsy. " + "Need assistance accessing disability benefits, medical equipment, " + "and inclusive education services.", + }, + "client": { + "name": "David Martinez", + "type": "household", + }, + "journey": [ + {"action": "intake", "days_back": 100, "notes": "Referred by disability rights organization"}, + {"action": "assessment", "days_back": 97, "notes": "Comprehensive disability needs assessment"}, + {"action": "create_plan", "days_back": 95, "plan_name": "Martinez Disability Support Plan"}, + {"action": "add_intervention", "days_back": 95, "intervention": "Disability grant application"}, + {"action": "add_intervention", "days_back": 95, "intervention": "Medical equipment assistance"}, + {"action": "add_intervention", "days_back": 95, "intervention": "Inclusive education enrollment"}, + { + "action": "add_referral", + "days_back": 90, + "service": "Disability Services Center", + "reason": "Wheelchair and mobility aids", + }, + {"action": "complete_intervention", "days_back": 85, "intervention": "Disability grant application"}, + {"action": "progress_note", "days_back": 80, "note": "Disability Support Grant approved - $175/month"}, + {"action": "home_visit", "days_back": 70, "purpose": "Equipment delivery coordination"}, + {"action": "complete_intervention", "days_back": 65, "intervention": "Medical equipment assistance"}, + {"action": "progress_note", "days_back": 60, "note": "Wheelchair delivered, family training completed"}, + {"action": "home_visit", "days_back": 45, "purpose": "School enrollment support"}, + {"action": "complete_intervention", "days_back": 40, "intervention": "Inclusive education enrollment"}, + {"action": "progress_note", "days_back": 30, "note": "Miguel enrolled in inclusive school program"}, + {"action": "home_visit", "days_back": 15, "purpose": "Quarterly review"}, + ], + "demo_points": [ + "Disability-focused case management", + "Integration with Disability Support Grant program", + "Medical equipment coordination", + "Inclusive education advocacy", + "Multi-service referral workflow", + ], + }, + { + "id": "al_rahman_family_assessment", + "case_name": "Al-Rahman Family Assessment", + "story_title": "GRM-Initiated Assessment Case", + "story_description": "Case opened after GRM inquiry about program eligibility", + "case_profile": { + "case_type": "General Support", + "intensity_level": "1", + "priority": "medium", + "intake_source": "grm", + "presenting_issue": "Family inquired about program eligibility through GRM. " + "Assessment revealed need for child support services. " + "Mother seeking assistance for 2 children while husband works abroad.", + }, + "client": { + "name": "Fatima Al-Rahman", + "type": "individual", + }, + "journey": [ + {"action": "intake", "days_back": 40, "notes": "Escalated from GRM eligibility inquiry"}, + {"action": "assessment", "days_back": 38, "notes": "Family needs assessment - eligible for child grant"}, + {"action": "create_plan", "days_back": 35, "plan_name": "Al-Rahman Support Plan"}, + {"action": "add_intervention", "days_back": 35, "intervention": "Universal Child Grant enrollment"}, + {"action": "add_intervention", "days_back": 35, "intervention": "School supplies assistance"}, + {"action": "progress_note", "days_back": 30, "note": "Child Grant application submitted"}, + {"action": "complete_intervention", "days_back": 25, "intervention": "Universal Child Grant enrollment"}, + {"action": "progress_note", "days_back": 20, "note": "Grant approved - $100/month for 2 children"}, + {"action": "home_visit", "days_back": 15, "purpose": "Enrollment verification"}, + {"action": "complete_intervention", "days_back": 10, "intervention": "School supplies assistance"}, + {"action": "progress_note", "days_back": 5, "note": "Family stable, monitoring for graduation"}, + ], + "demo_points": [ + "GRM to case management escalation", + "Program enrollment assistance", + "Universal Child Grant integration", + "Quick resolution pathway", + "Proactive outreach from inquiry", + ], + }, + { + "id": "said_family_support", + "case_name": "Said Family Support", + "story_title": "Repeat GRM Beneficiary Case", + "story_description": "Case for beneficiary with multiple GRM tickets requiring holistic support", + "case_profile": { + "case_type": "General Support", + "intensity_level": "1", + "priority": "medium", + "intake_source": "grm", + "presenting_issue": "Beneficiary filed multiple GRM tickets over 3 months " + "regarding payment issues and account updates. Pattern suggests need " + "for comprehensive case management rather than ticket-by-ticket resolution.", + }, + "client": { + "name": "Ahmed Said", + "type": "individual", + }, + "journey": [ + {"action": "intake", "days_back": 25, "notes": "Pattern of repeat GRM tickets identified"}, + {"action": "assessment", "days_back": 23, "notes": "Root cause analysis - banking access issues"}, + {"action": "create_plan", "days_back": 20, "plan_name": "Said Support Plan"}, + {"action": "add_intervention", "days_back": 20, "intervention": "Bank account regularization"}, + {"action": "add_intervention", "days_back": 20, "intervention": "Financial literacy training"}, + {"action": "progress_note", "days_back": 15, "note": "Bank account issues resolved"}, + {"action": "complete_intervention", "days_back": 12, "intervention": "Bank account regularization"}, + {"action": "progress_note", "days_back": 10, "note": "Enrolled in community financial literacy program"}, + {"action": "home_visit", "days_back": 5, "purpose": "Follow-up on payment receipt"}, + ], + "demo_points": [ + "Pattern detection from GRM history", + "Root cause analysis approach", + "Financial inclusion support", + "Preventive case management", + "Reduced future GRM tickets", + ], + }, +] + +# Background cases for volume +BACKGROUND_CASES = [ + { + "id": "pending_intake", + "case_name": "Fernandez Intake Pending", + "story_title": "New Intake", + "case_profile": { + "case_type": "General Support", + "intensity_level": "1", + "priority": "low", + "intake_source": "phone", + }, + "journey": [ + {"action": "intake", "days_back": 3, "notes": "Phone intake, awaiting assessment"}, + ], + }, + { + "id": "assessment_phase", + "case_name": "Johnson Assessment", + "story_title": "In Assessment", + "case_profile": { + "case_type": "Health Support", + "intensity_level": "1", + "priority": "medium", + "intake_source": "walk_in", + }, + "journey": [ + {"action": "intake", "days_back": 10, "notes": "Walk-in client"}, + {"action": "assessment", "days_back": 7, "notes": "Assessment in progress"}, + ], + }, + { + "id": "closed_unsuccessful", + "case_name": "Kim Case Closed", + "story_title": "Closed - Lost Contact", + "case_profile": { + "case_type": "General Support", + "intensity_level": "1", + "priority": "low", + "intake_source": "referral", + }, + "journey": [ + {"action": "intake", "days_back": 90, "notes": "Referred by partner agency"}, + {"action": "assessment", "days_back": 85, "notes": "Initial assessment"}, + {"action": "create_plan", "days_back": 80, "plan_name": "Support Plan"}, + {"action": "progress_note", "days_back": 60, "note": "Unable to contact client"}, + {"action": "progress_note", "days_back": 45, "note": "Multiple contact attempts failed"}, + {"action": "close_case", "days_back": 30, "outcome": "unsuccessful", "reason": "Lost to follow-up"}, + ], + }, +] + + +def get_all_case_stories(): + """Return all case demo stories (main + background).""" + return CASE_DEMO_STORIES + BACKGROUND_CASES + + +def get_main_case_stories(): + """Return only the main case demo stories.""" + return CASE_DEMO_STORIES + + +def get_background_case_stories(): + """Return only the background case stories.""" + return BACKGROUND_CASES + + +def get_case_story_by_id(story_id): + """Get a specific case story by ID.""" + for story in get_all_case_stories(): + if story["id"] == story_id: + return story + return None diff --git a/spp_case_demo/models/generate_cases.py b/spp_case_demo/models/generate_cases.py new file mode 100644 index 00000000..64b1317c --- /dev/null +++ b/spp_case_demo/models/generate_cases.py @@ -0,0 +1,551 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Case Management Demo Data Generator. + +Generates realistic case management demo data including: +- Cases with full lifecycle progression +- Intervention plans and interventions +- Case visits and notes +- Service referrals +""" + +import logging +import random +from datetime import timedelta + +from faker import Faker + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class SPPCaseDemoGenerator(models.TransientModel): + _name = "spp.case.demo.generator" + _description = "Case Management Demo Data Generator" + + name = fields.Char(default="Case Management Demo Data", required=True) + number_of_cases = fields.Integer(string="Number of Cases", default=25, required=True) + include_stories = fields.Boolean( + string="Include Demo Stories", + default=True, + help="Generate fixed demo stories with predictable names", + ) + cases_days_back = fields.Integer( + string="Days Back", + default=120, + required=True, + help="Generate cases within the last N days", + ) + + # Distribution settings + percentage_with_plans = fields.Float( + string="% with Plans", + default=70.0, + help="Percentage of cases with intervention plans", + ) + percentage_with_visits = fields.Float( + string="% with Visits", + default=60.0, + help="Percentage of cases with home visits", + ) + percentage_with_notes = fields.Float( + string="% with Notes", + default=80.0, + help="Percentage of cases with progress notes", + ) + percentage_closed = fields.Float( + string="% Closed", + default=40.0, + help="Percentage of cases that are closed", + ) + + # Linking options + link_to_beneficiaries = fields.Boolean( + string="Link to Beneficiaries", + default=True, + help="Link cases to existing registrants", + ) + + locale_origin = fields.Many2one( + "res.country", + default=lambda self: self.env.user.company_id.country_id or self.env.ref("base.us"), + help="Country for Faker locale", + ) + locale_origin_faker_locale = fields.Char( + string="Faker Locale", + related="locale_origin.faker_locale", + readonly=True, + ) + + @api.constrains("number_of_cases") + def _check_number_of_cases(self): + for rec in self: + if rec.number_of_cases < 1: + raise UserError(_("Number of cases must be at least 1")) + if rec.number_of_cases > 5000: + raise UserError(_("Number of cases cannot exceed 5,000")) + + def generate_cases(self): + """Main method to generate case management demo data.""" + self.ensure_one() + + # Initialize Faker + faker_locale = self.locale_origin_faker_locale or "en_US" + fake = Faker(faker_locale) + + _logger.info("Starting case generation: %d cases over %d days", self.number_of_cases, self.cases_days_back) + + # Get available beneficiaries + beneficiaries = self._get_available_beneficiaries() if self.link_to_beneficiaries else [] + + if self.link_to_beneficiaries and not beneficiaries: + raise UserError(_("No beneficiaries (registrants) found. Please create some registrants first.")) + + created_cases = self.env["spp.case"] + + # Generate fixed demo stories first + if self.include_stories: + story_cases = self._generate_demo_stories(fake, beneficiaries) + created_cases |= story_cases + _logger.info("Generated %d demo story cases", len(story_cases)) + + # Generate random volume cases + remaining = self.number_of_cases - len(created_cases) + if remaining > 0: + for i in range(remaining): + try: + case = self._create_random_case(fake, beneficiaries) + if case: + created_cases |= case + + if (i + 1) % 10 == 0: + _logger.info("Generated %d/%d random cases", i + 1, remaining) + + except Exception as e: + _logger.error("Error generating case %d: %s", i + 1, e, exc_info=True) + continue + + _logger.info("Successfully generated %d cases", len(created_cases)) + + # Return action to view generated cases + return { + "type": "ir.actions.act_window", + "name": "Generated Cases", + "res_model": "spp.case", + "view_mode": "list,form,kanban", + "domain": [("id", "in", created_cases.ids)], + "context": {"search_default_group_by_stage": 1}, + } + + def _generate_demo_stories(self, fake, beneficiaries): + """Generate cases from fixed demo stories.""" + from . import case_demo_stories + + cases = self.env["spp.case"] + stories = case_demo_stories.get_main_case_stories() + + for story in stories: + try: + case = self._create_case_from_story(story, fake, beneficiaries) + if case: + cases |= case + _logger.info("Created demo story case: %s", story["case_name"]) + except Exception as e: + _logger.error("Error creating story '%s': %s", story.get("case_name", "unknown"), e) + + return cases + + def _create_case_from_story(self, story, fake, beneficiaries): + """Create a case from a demo story definition.""" + case_profile = story.get("case_profile", {}) + journey = story.get("journey", []) + + # Find opened date (formerly intake) + intake_action = next((a for a in journey if a.get("action") == "intake"), None) + intake_days_back = intake_action.get("days_back", 90) if intake_action else 90 + opened_date = fields.Date.today() - timedelta(days=intake_days_back) + + # Get or create case type + case_type = self._get_or_create_case_type(case_profile.get("case_type", "General Support")) + + # Get stage based on journey + stage = self._determine_stage_from_journey(journey) + + # Find or create client + client_info = story.get("client", {}) + client = self._find_or_create_client(client_info, beneficiaries, fake) + + # Create case + vals = { + "case_type_id": case_type.id if case_type else False, + "stage_id": stage.id if stage else False, + "partner_id": client.id if client else False, + "presenting_issue": case_profile.get("presenting_issue", "Demo case"), + "intake_source": case_profile.get("intake_source", "referral"), + "opened_date": opened_date, + "priority": case_profile.get("priority", "medium"), + "intensity_level": case_profile.get("intensity_level", "1"), + "case_worker_id": self.env.user.id, + } + + case = self.env["spp.case"].sudo().create(vals) + # Backdate creation + self.env.cr.execute( + "UPDATE spp_case SET create_date = %s WHERE id = %s", + (opened_date, case.id), + ) + + # Process journey + self._process_case_journey(case, journey, fake) + + return case + + def _process_case_journey(self, case, journey, fake): + """Process journey steps for a case.""" + Plan = self.env["spp.case.intervention.plan"] + Intervention = self.env["spp.case.intervention"] + Visit = self.env["spp.case.visit"] + Note = self.env["spp.case.note"] + Referral = self.env["spp.case.referral"] + + current_plan = None + + for step in journey: + action = step.get("action") + days_back = step.get("days_back", 0) + action_date = fields.Date.today() - timedelta(days=days_back) + + if action == "create_plan": + current_plan = Plan.sudo().create( + { + "case_id": case.id, + "name": step.get("plan_name", f"Plan for {case.partner_id.name}"), + "is_current": True, + "state": "active", + "start_date": action_date, + "goals": step.get("goals", fake.paragraph()), + } + ) + + elif action == "add_intervention" and current_plan: + Intervention.sudo().create( + { + "plan_id": current_plan.id, + "name": step.get("intervention", "Intervention"), + "description": fake.sentence(), + "target_date": action_date + timedelta(days=random.randint(7, 30)), + "state": "planned", + } + ) + + elif action == "complete_intervention" and current_plan: + intervention = Intervention.search( + [ + ("plan_id", "=", current_plan.id), + ("name", "=", step.get("intervention")), + ], + limit=1, + ) + if intervention: + intervention.sudo().write({"state": "completed"}) + elif action in ("home_visit", "office_visit", "final_visit"): + visit_type = "home" if action != "office_visit" else "office" + Visit.sudo().create( + { + "case_id": case.id, + "visit_type": visit_type, + "purpose": step.get("purpose", "Check-in visit"), + "visit_date": fields.Datetime.to_datetime(action_date), + "notes": step.get("notes", fake.paragraph()), + } + ) + + elif action == "progress_note": + Note.sudo().create( + { + "case_id": case.id, + "note_type": "progress", + "content": step.get("note", fake.paragraph()), + "note_date": fields.Datetime.to_datetime(action_date), + } + ) + + elif action in ("assessment", "emergency_assessment", "safety_assessment"): + Note.sudo().create( + { + "case_id": case.id, + "note_type": "assessment", + "content": step.get( + "notes", f"{action.replace('_', ' ').title()} completed. {fake.paragraph()}" + ), + "note_date": fields.Datetime.to_datetime(action_date), + } + ) + + elif action == "add_referral": + service = self._get_or_create_service(step.get("service", "External Service")) + if service: + Referral.sudo().create( + { + "case_id": case.id, + "service_id": service.id, + "referral_reason": step.get("reason", "Service needed"), + "referral_date": action_date, + "state": "pending", + } + ) + + elif action == "close_case": + closure_stage = self.env["spp.case.stage"].search( + [ + ("phase", "=", "closure"), + ], + limit=1, + ) + if closure_stage: + case.sudo().write( + { + "stage_id": closure_stage.id, + "actual_closure_date": action_date, + } + ) + if current_plan: + current_plan.sudo().write({"state": "completed"}) + + def _create_random_case(self, fake, beneficiaries): + """Create a random case with realistic data.""" + # Random date within range + days_back = random.randint(0, self.cases_days_back) + opened_date = fields.Date.today() - timedelta(days=days_back) + + # Get random case type and stage + case_type = self._get_random_case_type() + stage = self._get_random_stage() + + # Get random beneficiary + partner = random.choice(beneficiaries) if beneficiaries else None + + presenting_issues = [ + "Family experiencing financial hardship", + "Need for housing assistance", + "Health support coordination needed", + "Child welfare concerns", + "Employment assistance requested", + "Food insecurity issues", + "Elder care coordination", + "Disability support services needed", + ] + + vals = { + "case_type_id": case_type.id if case_type else False, + "stage_id": stage.id if stage else False, + "partner_id": partner.id if partner else False, + "presenting_issue": random.choice(presenting_issues), + "intake_source": random.choice(["walk_in", "referral", "outreach", "grm", "program", "other"]), + "opened_date": opened_date, + "priority": random.choice(["low", "medium", "high", "urgent"]), + "intensity_level": random.choice(["1", "2", "3"]), + "case_worker_id": self.env.user.id, + } + + case = self.env["spp.case"].sudo().create(vals) + # Backdate creation + self.env.cr.execute( + "UPDATE spp_case SET create_date = %s WHERE id = %s", + (opened_date, case.id), + ) + + # Add related data based on percentages + if random.random() < (self.percentage_with_plans / 100): + self._add_random_plan(case, fake, opened_date) + + if random.random() < (self.percentage_with_visits / 100): + self._add_random_visits(case, fake, opened_date) + + if random.random() < (self.percentage_with_notes / 100): + self._add_random_notes(case, fake, opened_date) + + # Close case if needed + if random.random() < (self.percentage_closed / 100): + self._close_random_case(case, fake, opened_date) + + return case + + def _add_random_plan(self, case, fake, intake_date): + """Add random intervention plan to case.""" + Plan = self.env["spp.case.intervention.plan"] + Intervention = self.env["spp.case.intervention"] + + plan = Plan.sudo().create( + { + "case_id": case.id, + "name": f"Support Plan - {case.partner_id.name or 'Client'}", + "is_current": True, + "state": random.choice(["draft", "active", "completed"]), + "start_date": intake_date + timedelta(days=random.randint(1, 7)), + "goals": fake.paragraph(), + } + ) + + # Add 2-4 interventions + intervention_names = [ + "Needs assessment", + "Resource connection", + "Counseling referral", + "Document assistance", + "Service coordination", + "Follow-up support", + ] + + for _i in range(random.randint(2, 4)): + Intervention.sudo().create( + { + "plan_id": plan.id, + "name": random.choice(intervention_names), + "description": fake.sentence(), + "target_date": intake_date + timedelta(days=random.randint(7, 60)), + "state": random.choice(["planned", "in_progress", "completed"]), + } + ) + + def _add_random_visits(self, case, fake, intake_date): + """Add random visits to case.""" + Visit = self.env["spp.case.visit"] + + for _i in range(random.randint(1, 3)): + visit_date = intake_date + timedelta(days=random.randint(5, 60)) + Visit.sudo().create( + { + "case_id": case.id, + "visit_type": random.choice(["home", "office", "phone", "virtual"]), + "purpose": fake.sentence(nb_words=5), + "visit_date": fields.Datetime.to_datetime(visit_date), + "notes": fake.paragraph() if random.random() < 0.7 else False, + } + ) + + def _add_random_notes(self, case, fake, intake_date): + """Add random notes to case.""" + Note = self.env["spp.case.note"] + + for _i in range(random.randint(1, 4)): + note_date = intake_date + timedelta(days=random.randint(1, 60)) + Note.sudo().create( + { + "case_id": case.id, + "note_type": random.choice(["progress", "assessment", "general", "supervision"]), + "content": fake.paragraph(nb_sentences=random.randint(2, 5)), + "note_date": fields.Datetime.to_datetime(note_date), + } + ) + + def _close_random_case(self, case, fake, intake_date): + """Close a case with random outcome.""" + closure_stage = self.env["spp.case.stage"].search( + [ + ("phase", "=", "closure"), + ], + limit=1, + ) + + if closure_stage: + closure_date = intake_date + timedelta(days=random.randint(30, 90)) + case.sudo().write( + { + "stage_id": closure_stage.id, + "actual_closure_date": closure_date, + } + ) + + def _get_available_beneficiaries(self): + """Get list of available beneficiaries (registrants).""" + return self.env["res.partner"].search([("is_registrant", "=", True)], limit=1000) + + def _find_or_create_client(self, client_info, beneficiaries, fake): + """Find existing client or return random beneficiary.""" + name = client_info.get("name") + if name: + existing = self.env["res.partner"].search( + [ + ("name", "=", name), + ("is_registrant", "=", True), + ], + limit=1, + ) + if existing: + return existing + + # Return random beneficiary + return random.choice(beneficiaries) if beneficiaries else None + + def _get_or_create_case_type(self, type_name): + """Get or create a case type.""" + CaseType = self.env["spp.case.type"] + case_type = CaseType.search([("name", "=", type_name)], limit=1) + if not case_type: + case_type = CaseType.sudo().create( + { + "name": type_name, + "code": type_name.upper().replace(" ", "_")[:10], + } + ) + return case_type + + def _get_random_case_type(self): + """Get a random case type.""" + case_types = self.env["spp.case.type"].search([]) + return random.choice(case_types) if case_types else None + + def _get_random_stage(self): + """Get a random case stage.""" + stages = self.env["spp.case.stage"].search([]) + if stages: + # Weight toward middle stages + idx = min(int(random.triangular(0, len(stages) - 1, len(stages) // 3)), len(stages) - 1) + return stages[idx] + return None + + def _determine_stage_from_journey(self, journey): + """Determine appropriate stage based on journey.""" + Stage = self.env["spp.case.stage"] + + # Check if case is closed + if any(step.get("action") == "close_case" for step in journey): + return Stage.search([("phase", "=", "closure")], limit=1) + + # Check for monitoring + if len([s for s in journey if s.get("action") in ("home_visit", "progress_note")]) > 3: + return Stage.search([("phase", "=", "monitoring")], limit=1) + + # Check for implementation + if any(step.get("action") == "complete_intervention" for step in journey): + return Stage.search([("phase", "=", "implementation")], limit=1) + + # Check for planning + if any(step.get("action") == "create_plan" for step in journey): + return Stage.search([("phase", "=", "planning")], limit=1) + + # Check for assessment + if any("assessment" in step.get("action", "") for step in journey): + return Stage.search([("phase", "=", "assessment")], limit=1) + + # Default to intake + return Stage.search([("phase", "=", "intake")], limit=1) + + def _get_or_create_service(self, service_name): + """Get or create a service for referrals.""" + if "spp.service" not in self.env: + return None + + Service = self.env["spp.service"] + service = Service.search([("name", "=", service_name)], limit=1) + if not service: + service = Service.sudo().create( + { + "name": service_name, + "service_type": "external", + } + ) + return service diff --git a/spp_case_demo/pyproject.toml b/spp_case_demo/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_case_demo/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_case_demo/readme/DESCRIPTION.md b/spp_case_demo/readme/DESCRIPTION.md new file mode 100644 index 00000000..457abdb4 --- /dev/null +++ b/spp_case_demo/readme/DESCRIPTION.md @@ -0,0 +1,62 @@ +Demo data generator for Case Management system. Creates realistic cases with intervention plans, home visits, progress notes, and service referrals. Includes 9 fixed demo stories for training and sales demos, plus configurable random case generation for volume testing. + +### Key Capabilities + +- Generate 9 fixed demo stories with predictable personas and case progressions for consistent training scenarios +- Create random volume cases with configurable distribution percentages for plans, visits, notes, and closures +- Link generated cases to existing registrants or create standalone cases +- Backdate case records and related activities to simulate realistic timelines over configurable day ranges +- Create intervention plans with multiple interventions across case lifecycle stages +- Generate home visits, office visits, phone calls, and virtual visits with contextual notes +- Install default case types (General Support, Emergency Assistance, Child Protection, Health Support, Livelihood Development, Housing Assistance) and case stages (Intake, Assessment, Planning, Implementation, Monitoring, Closure) + +### Key Models + +| Model | Description | +| ------------------------- | ---------------------------------------------------------- | +| `spp.case.demo.generator` | Core logic for configuring and generating demo data | +| `spp.case.demo.wizard` | Wizard interface for demo data generation (inherits generator) | + +### Configuration + +After installing: + +1. Navigate to **Case Management > Configuration > Generate Demo Data** +2. Configure generation parameters: + - Number of cases to generate (1-5,000) + - Days back to distribute cases over + - Enable "Include Demo Stories" to create 9 fixed personas + - Set distribution percentages for plans, visits, notes, and closed cases + - Choose locale origin for Faker data generation + - Select whether to link cases to existing beneficiaries +3. Click "Generate Cases" to create demo data +4. View generated cases in Case Management > Cases (filtered by generated IDs) + +### UI Location + +- **Menu**: Case Management > Configuration > Generate Demo Data +- **Generated Cases**: View results in Case Management > Cases + +### Security + +| Group | Access | +| ----------------- | --------- | +| `base.group_user` | Full CRUD | + +### Demo Stories + +When "Include Demo Stories" is enabled, generates 9 fixed personas: + +- **Santos Family Support**: Complete case lifecycle from intake to successful closure +- **Dela Cruz Emergency Response**: High intensity urgent case with same-day response +- **Garcia Elder Care Coordination**: Long-term care case with service referrals +- **Mendoza Child Welfare**: Child protection case with safety assessments and frequent visits +- **Hassan Resettlement Support**: Displaced person case escalated from GRM +- **Morales Household Crisis**: Multi-member household identified during outreach +- **Martinez Disability Support**: Disability-focused case with equipment and education services +- **Al-Rahman Family Assessment**: GRM-initiated assessment leading to program enrollment +- **Said Family Support**: Pattern detection from repeat GRM tickets + +### Dependencies + +`spp_demo`, `spp_case_base`, `spp_security`, `faker` (Python) diff --git a/spp_case_demo/security/ir.model.access.csv b/spp_case_demo/security/ir.model.access.csv new file mode 100644 index 00000000..1e1ea223 --- /dev/null +++ b/spp_case_demo/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_case_demo_generator,spp.case.demo.generator,model_spp_case_demo_generator,base.group_user,1,1,1,1 +access_case_demo_wizard,spp.case.demo.wizard,model_spp_case_demo_wizard,base.group_user,1,1,1,1 diff --git a/spp_case_demo/static/description/icon.png b/spp_case_demo/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_case_demo/static/description/icon.png differ diff --git a/spp_case_demo/static/description/index.html b/spp_case_demo/static/description/index.html new file mode 100644 index 00000000..b2131a12 --- /dev/null +++ b/spp_case_demo/static/description/index.html @@ -0,0 +1,532 @@ + + + + + +OpenSPP Case Management Demo Data + + + +
+

OpenSPP Case Management Demo Data

+ + +

Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

+

Demo data generator for Case Management system. Creates realistic cases +with intervention plans, home visits, progress notes, and service +referrals. Includes 9 fixed demo stories for training and sales demos, +plus configurable random case generation for volume testing.

+
+

Key Capabilities

+
    +
  • Generate 9 fixed demo stories with predictable personas and case +progressions for consistent training scenarios
  • +
  • Create random volume cases with configurable distribution percentages +for plans, visits, notes, and closures
  • +
  • Link generated cases to existing registrants or create standalone +cases
  • +
  • Backdate case records and related activities to simulate realistic +timelines over configurable day ranges
  • +
  • Create intervention plans with multiple interventions across case +lifecycle stages
  • +
  • Generate home visits, office visits, phone calls, and virtual visits +with contextual notes
  • +
  • Install default case types (General Support, Emergency Assistance, +Child Protection, Health Support, Livelihood Development, Housing +Assistance) and case stages (Intake, Assessment, Planning, +Implementation, Monitoring, Closure)
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + +
ModelDescription
spp.case.demo.generatorCore logic for configuring and +generating demo data
spp.case.demo.wizardWizard interface for demo data +generation (inherits generator)
+
+
+

Configuration

+

After installing:

+
    +
  1. Navigate to Case Management > Configuration > Generate Demo Data
  2. +
  3. Configure generation parameters:
      +
    • Number of cases to generate (1-5,000)
    • +
    • Days back to distribute cases over
    • +
    • Enable “Include Demo Stories” to create 9 fixed personas
    • +
    • Set distribution percentages for plans, visits, notes, and closed +cases
    • +
    • Choose locale origin for Faker data generation
    • +
    • Select whether to link cases to existing beneficiaries
    • +
    +
  4. +
  5. Click “Generate Cases” to create demo data
  6. +
  7. View generated cases in Case Management > Cases (filtered by +generated IDs)
  8. +
+
+
+

UI Location

+
    +
  • Menu: Case Management > Configuration > Generate Demo Data
  • +
  • Generated Cases: View results in Case Management > Cases
  • +
+
+
+

Security

+ ++++ + + + + + + + + + + +
GroupAccess
base.group_userFull CRUD
+
+
+

Demo Stories

+

When “Include Demo Stories” is enabled, generates 9 fixed personas:

+
    +
  • Santos Family Support: Complete case lifecycle from intake to +successful closure
  • +
  • Dela Cruz Emergency Response: High intensity urgent case with +same-day response
  • +
  • Garcia Elder Care Coordination: Long-term care case with service +referrals
  • +
  • Mendoza Child Welfare: Child protection case with safety +assessments and frequent visits
  • +
  • Hassan Resettlement Support: Displaced person case escalated from +GRM
  • +
  • Morales Household Crisis: Multi-member household identified during +outreach
  • +
  • Martinez Disability Support: Disability-focused case with +equipment and education services
  • +
  • Al-Rahman Family Assessment: GRM-initiated assessment leading to +program enrollment
  • +
  • Said Family Support: Pattern detection from repeat GRM tickets
  • +
+
+
+

Dependencies

+

spp_demo, spp_case_base, spp_security, faker (Python)

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123 emjay0921

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_case_demo/tests/__init__.py b/spp_case_demo/tests/__init__.py new file mode 100644 index 00000000..8d40bff8 --- /dev/null +++ b/spp_case_demo/tests/__init__.py @@ -0,0 +1,5 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_case_demo_stories +from . import test_generate_cases +from . import test_case_demo_wizard diff --git a/spp_case_demo/tests/test_case_demo_stories.py b/spp_case_demo/tests/test_case_demo_stories.py new file mode 100644 index 00000000..cd95f633 --- /dev/null +++ b/spp_case_demo/tests/test_case_demo_stories.py @@ -0,0 +1,197 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Tests for case_demo_stories module - pure Python, no Odoo model access needed.""" + +from odoo.tests import TransactionCase, tagged + +from ..models.case_demo_stories import ( + BACKGROUND_CASES, + CASE_DEMO_STORIES, + RESERVED_CASE_NAMES, + get_all_case_stories, + get_background_case_stories, + get_case_story_by_id, + get_main_case_stories, +) + + +@tagged("post_install", "-at_install") +class TestCaseDemoStories(TransactionCase): + """Tests for the demo stories data module.""" + + def test_case_demo_stories_list_not_empty(self): + """The main stories list should have entries.""" + self.assertTrue(CASE_DEMO_STORIES, "CASE_DEMO_STORIES must not be empty") + + def test_background_cases_list_not_empty(self): + """The background cases list should have entries.""" + self.assertTrue(BACKGROUND_CASES, "BACKGROUND_CASES must not be empty") + + def test_reserved_case_names_not_empty(self): + """Reserved names list should have entries.""" + self.assertTrue(RESERVED_CASE_NAMES, "RESERVED_CASE_NAMES must not be empty") + + def test_each_main_story_has_required_keys(self): + """Every main story must have id, case_name, case_profile, and journey.""" + required_keys = {"id", "case_name", "case_profile", "journey"} + for story in CASE_DEMO_STORIES: + missing = required_keys - set(story.keys()) + self.assertFalse( + missing, + f"Story '{story.get('case_name', '?')}' is missing keys: {missing}", + ) + + def test_each_story_journey_is_list(self): + """Every story's journey must be a list.""" + for story in get_all_case_stories(): + self.assertIsInstance( + story["journey"], + list, + f"Journey for '{story.get('case_name', '?')}' must be a list", + ) + + def test_each_story_case_profile_is_dict(self): + """Every story's case_profile must be a dict.""" + for story in get_all_case_stories(): + self.assertIsInstance( + story["case_profile"], + dict, + f"case_profile for '{story.get('case_name', '?')}' must be a dict", + ) + + def test_story_ids_are_unique(self): + """All story IDs must be unique.""" + all_ids = [s["id"] for s in get_all_case_stories()] + self.assertEqual( + len(all_ids), + len(set(all_ids)), + "Duplicate story IDs detected", + ) + + def test_get_main_case_stories_returns_main_list(self): + """get_main_case_stories should return CASE_DEMO_STORIES.""" + result = get_main_case_stories() + self.assertEqual(result, CASE_DEMO_STORIES) + + def test_get_background_case_stories_returns_background_list(self): + """get_background_case_stories should return BACKGROUND_CASES.""" + result = get_background_case_stories() + self.assertEqual(result, BACKGROUND_CASES) + + def test_get_all_case_stories_combines_both_lists(self): + """get_all_case_stories should combine main and background stories.""" + all_stories = get_all_case_stories() + expected_count = len(CASE_DEMO_STORIES) + len(BACKGROUND_CASES) + self.assertEqual(len(all_stories), expected_count) + + def test_get_all_case_stories_main_stories_first(self): + """Main stories should appear before background stories in the combined list.""" + all_stories = get_all_case_stories() + main_count = len(CASE_DEMO_STORIES) + self.assertEqual(all_stories[:main_count], CASE_DEMO_STORIES) + self.assertEqual(all_stories[main_count:], BACKGROUND_CASES) + + def test_get_case_story_by_id_found(self): + """get_case_story_by_id should return the correct story when it exists.""" + # Use the first story in main stories as a known ID + first_story = CASE_DEMO_STORIES[0] + result = get_case_story_by_id(first_story["id"]) + self.assertEqual(result, first_story) + + def test_get_case_story_by_id_background_story(self): + """get_case_story_by_id should also find background case stories.""" + first_background = BACKGROUND_CASES[0] + result = get_case_story_by_id(first_background["id"]) + self.assertEqual(result, first_background) + + def test_get_case_story_by_id_not_found_returns_none(self): + """get_case_story_by_id should return None for an unknown ID.""" + result = get_case_story_by_id("this_id_does_not_exist_xyz") + self.assertIsNone(result) + + def test_santos_family_support_story_exists(self): + """The Santos Family Support story must exist by its known ID.""" + story = get_case_story_by_id("santos_family_support") + self.assertIsNotNone(story) + self.assertEqual(story["case_name"], "Santos Family Support") + + def test_dela_cruz_emergency_story_exists(self): + """The Dela Cruz Emergency story must exist.""" + story = get_case_story_by_id("dela_cruz_emergency") + self.assertIsNotNone(story) + + def test_garcia_elder_care_story_exists(self): + """The Garcia Elder Care story must exist.""" + story = get_case_story_by_id("garcia_elder_care") + self.assertIsNotNone(story) + + def test_mendoza_child_protection_story_exists(self): + """The Mendoza Child Protection story must exist.""" + story = get_case_story_by_id("mendoza_child_protection") + self.assertIsNotNone(story) + + def test_hassan_resettlement_story_exists(self): + """The Hassan Resettlement story must exist.""" + story = get_case_story_by_id("hassan_resettlement") + self.assertIsNotNone(story) + + def test_morales_household_crisis_story_exists(self): + """The Morales Household Crisis story must exist.""" + story = get_case_story_by_id("morales_household_crisis") + self.assertIsNotNone(story) + + def test_martinez_disability_support_story_exists(self): + """The Martinez Disability Support story must exist.""" + story = get_case_story_by_id("martinez_disability_support") + self.assertIsNotNone(story) + + def test_al_rahman_family_assessment_story_exists(self): + """The Al-Rahman Family Assessment story must exist.""" + story = get_case_story_by_id("al_rahman_family_assessment") + self.assertIsNotNone(story) + + def test_said_family_support_story_exists(self): + """The Said Family Support story must exist.""" + story = get_case_story_by_id("said_family_support") + self.assertIsNotNone(story) + + def test_story_journey_actions_are_strings(self): + """Every journey step must have an action key that is a string.""" + for story in get_all_case_stories(): + for step in story["journey"]: + self.assertIn("action", step, f"Journey step missing 'action' in story {story['id']}") + self.assertIsInstance(step["action"], str) + + def test_story_journey_days_back_non_negative(self): + """Journey steps that specify days_back must have a non-negative value.""" + for story in get_all_case_stories(): + for step in story["journey"]: + if "days_back" in step: + self.assertGreaterEqual( + step["days_back"], + 0, + f"days_back must be >= 0 in story '{story['id']}'", + ) + + def test_santos_story_has_close_case_action(self): + """The Santos story should include a close_case journey step.""" + story = get_case_story_by_id("santos_family_support") + actions = [step["action"] for step in story["journey"]] + self.assertIn("close_case", actions) + + def test_background_pending_intake_story(self): + """The pending_intake background story must exist.""" + story = get_case_story_by_id("pending_intake") + self.assertIsNotNone(story) + + def test_background_assessment_phase_story(self): + """The assessment_phase background story must exist.""" + story = get_case_story_by_id("assessment_phase") + self.assertIsNotNone(story) + + def test_background_closed_unsuccessful_story(self): + """The closed_unsuccessful background story must exist and have a close_case step.""" + story = get_case_story_by_id("closed_unsuccessful") + self.assertIsNotNone(story) + actions = [step["action"] for step in story["journey"]] + self.assertIn("close_case", actions) diff --git a/spp_case_demo/tests/test_case_demo_wizard.py b/spp_case_demo/tests/test_case_demo_wizard.py new file mode 100644 index 00000000..387c273a --- /dev/null +++ b/spp_case_demo/tests/test_case_demo_wizard.py @@ -0,0 +1,124 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Tests for spp.case.demo.wizard model.""" + +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCaseDemoWizard(TransactionCase): + """Tests for the spp.case.demo.wizard transient model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + Stage = cls.env["spp.case.stage"] + stage_data = [ + ("Intake", "intake", 10, False), + ("Closed", "closure", 60, True), + ] + for name, phase, sequence, is_closed in stage_data: + if not Stage.search([("phase", "=", phase)], limit=1): + Stage.create({"name": name, "phase": phase, "sequence": sequence, "is_closed": is_closed}) + + CaseType = cls.env["spp.case.type"] + if not CaseType.search([("name", "=", "General Support")], limit=1): + CaseType.create({"name": "General Support", "code": "GNWZ"}) + + # partner_id is required on spp.case; always provide a registrant + cls.beneficiary = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create({"name": "Wizard Test Beneficiary", "is_registrant": True}) + ) + + def _make_wizard(self, **kwargs): + defaults = { + "name": "Wizard Test", + "number_of_cases": 2, + "include_stories": False, + "cases_days_back": 30, + "link_to_beneficiaries": True, + "percentage_with_plans": 0.0, + "percentage_with_visits": 0.0, + "percentage_with_notes": 0.0, + "percentage_closed": 0.0, + } + defaults.update(kwargs) + return self.env["spp.case.demo.wizard"].create(defaults) + + def test_wizard_inherits_generator(self): + """spp.case.demo.wizard should inherit spp.case.demo.generator.""" + wizard = self._make_wizard() + # Verify the wizard can access all generator fields + self.assertEqual(wizard.number_of_cases, 2) + self.assertFalse(wizard.include_stories) + + def test_wizard_generate_cases_returns_action(self): + """generate_cases called from wizard should return a valid window action.""" + wizard = self._make_wizard(number_of_cases=2) + result = wizard.generate_cases() + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(result["res_model"], "spp.case") + + def test_wizard_create_and_read(self): + """Wizard records should be creatable and readable.""" + wizard = self._make_wizard() + self.assertTrue(wizard.id) + self.assertEqual(wizard.name, "Wizard Test") + + def test_wizard_model_name(self): + """The wizard model name should be spp.case.demo.wizard.""" + wizard = self._make_wizard() + self.assertEqual(wizard._name, "spp.case.demo.wizard") + + def test_wizard_description(self): + """The wizard model description should be set.""" + wizard = self._make_wizard() + self.assertTrue(wizard._description) + + def test_wizard_shares_generator_methods(self): + """Wizard should have access to all generator methods via inheritance.""" + wizard = self._make_wizard() + # Check that inherited helper methods are accessible + self.assertTrue(hasattr(wizard, "generate_cases")) + self.assertTrue(hasattr(wizard, "_get_available_beneficiaries")) + self.assertTrue(hasattr(wizard, "_get_or_create_case_type")) + self.assertTrue(hasattr(wizard, "_get_random_case_type")) + self.assertTrue(hasattr(wizard, "_get_random_stage")) + self.assertTrue(hasattr(wizard, "_determine_stage_from_journey")) + self.assertTrue(hasattr(wizard, "_find_or_create_client")) + self.assertTrue(hasattr(wizard, "_get_or_create_service")) + self.assertTrue(hasattr(wizard, "_create_random_case")) + self.assertTrue(hasattr(wizard, "_add_random_plan")) + self.assertTrue(hasattr(wizard, "_add_random_visits")) + self.assertTrue(hasattr(wizard, "_add_random_notes")) + self.assertTrue(hasattr(wizard, "_close_random_case")) + self.assertTrue(hasattr(wizard, "_generate_demo_stories")) + self.assertTrue(hasattr(wizard, "_create_case_from_story")) + self.assertTrue(hasattr(wizard, "_process_case_journey")) + + def test_wizard_generates_cases_with_stories(self): + """Wizard can generate cases including demo stories.""" + wizard = self._make_wizard( + number_of_cases=2, + include_stories=True, + ) + result = wizard.generate_cases() + self.assertEqual(result["type"], "ir.actions.act_window") + case_ids = result["domain"][0][2] + self.assertTrue(len(case_ids) >= 1) + + def test_wizard_percentage_fields_accessible(self): + """All percentage distribution fields should be accessible on the wizard.""" + wizard = self._make_wizard( + percentage_with_plans=50.0, + percentage_with_visits=60.0, + percentage_with_notes=70.0, + percentage_closed=30.0, + ) + self.assertAlmostEqual(wizard.percentage_with_plans, 50.0) + self.assertAlmostEqual(wizard.percentage_with_visits, 60.0) + self.assertAlmostEqual(wizard.percentage_with_notes, 70.0) + self.assertAlmostEqual(wizard.percentage_closed, 30.0) diff --git a/spp_case_demo/tests/test_generate_cases.py b/spp_case_demo/tests/test_generate_cases.py new file mode 100644 index 00000000..37a19065 --- /dev/null +++ b/spp_case_demo/tests/test_generate_cases.py @@ -0,0 +1,1074 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Tests for spp.case.demo.generator model.""" + +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCaseDemoGeneratorFields(TransactionCase): + """Tests for field defaults and constraints on the generator model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Ensure required case stages exist for all tests + Stage = cls.env["spp.case.stage"] + stage_data = [ + ("Intake", "intake", 10), + ("Assessment", "assessment", 20), + ("Planning", "planning", 30), + ("Implementation", "implementation", 40), + ("Monitoring", "monitoring", 50), + ("Closed", "closure", 60), + ] + for name, phase, sequence in stage_data: + if not Stage.search([("phase", "=", phase)], limit=1): + Stage.create({"name": name, "phase": phase, "sequence": sequence, "is_closed": phase == "closure"}) + + # Ensure required case types exist + CaseType = cls.env["spp.case.type"] + type_data = [ + ("General Support", "GEN"), + ("Emergency Assistance", "EMG"), + ("Health Support", "HLT"), + ("Child Protection", "CHP"), + ] + for name, code in type_data: + if not CaseType.search([("name", "=", name)], limit=1): + CaseType.create({"name": name, "code": code}) + + # Create a registrant (beneficiary) so linking tests work + cls.beneficiary = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "Test Beneficiary", + "is_registrant": True, + } + ) + ) + + def _make_generator(self, **kwargs): + """Helper: create a spp.case.demo.generator with sensible defaults.""" + defaults = { + "name": "Test Generator", + "number_of_cases": 1, + "include_stories": False, + "cases_days_back": 30, + "link_to_beneficiaries": True, + "percentage_with_plans": 0.0, + "percentage_with_visits": 0.0, + "percentage_with_notes": 0.0, + "percentage_closed": 0.0, + } + defaults.update(kwargs) + return self.env["spp.case.demo.generator"].create(defaults) + + # ------------------------------------------------------------------ + # Constraint tests + # ------------------------------------------------------------------ + + def test_constraint_number_of_cases_minimum(self): + """number_of_cases must be at least 1.""" + with self.assertRaises(UserError): + self._make_generator(number_of_cases=0) + + def test_constraint_number_of_cases_maximum(self): + """number_of_cases must not exceed 5,000.""" + with self.assertRaises(UserError): + self._make_generator(number_of_cases=5001) + + def test_constraint_number_of_cases_valid_boundary_values(self): + """number_of_cases of 1 and 5000 must be accepted without error.""" + gen_min = self._make_generator(number_of_cases=1) + self.assertEqual(gen_min.number_of_cases, 1) + + gen_max = self._make_generator(number_of_cases=5000) + self.assertEqual(gen_max.number_of_cases, 5000) + + # ------------------------------------------------------------------ + # Default field value tests + # ------------------------------------------------------------------ + + def test_default_name(self): + """Default name should be populated.""" + gen = self._make_generator() + self.assertTrue(gen.name) + + def test_default_number_of_cases(self): + """Default number_of_cases should be 25 when not overridden.""" + gen = self.env["spp.case.demo.generator"].create({"name": "Default Test"}) + self.assertEqual(gen.number_of_cases, 25) + + def test_default_include_stories(self): + """Default include_stories should be True.""" + gen = self.env["spp.case.demo.generator"].create({"name": "Default Test 2"}) + self.assertTrue(gen.include_stories) + + def test_default_cases_days_back(self): + """Default cases_days_back should be 120.""" + gen = self.env["spp.case.demo.generator"].create({"name": "Default Test 3"}) + self.assertEqual(gen.cases_days_back, 120) + + def test_default_percentage_with_plans(self): + """Default percentage_with_plans should be 70.0.""" + gen = self.env["spp.case.demo.generator"].create({"name": "Default Test 4"}) + self.assertAlmostEqual(gen.percentage_with_plans, 70.0) + + def test_default_percentage_closed(self): + """Default percentage_closed should be 40.0.""" + gen = self.env["spp.case.demo.generator"].create({"name": "Default Test 5"}) + self.assertAlmostEqual(gen.percentage_closed, 40.0) + + def test_default_link_to_beneficiaries(self): + """Default link_to_beneficiaries should be True.""" + gen = self.env["spp.case.demo.generator"].create({"name": "Default Test 6"}) + self.assertTrue(gen.link_to_beneficiaries) + + # ------------------------------------------------------------------ + # locale_origin_faker_locale related field + # ------------------------------------------------------------------ + + def test_locale_origin_faker_locale_related(self): + """locale_origin_faker_locale should reflect the country's faker_locale.""" + country = self.env["res.country"].search([("faker_locale", "!=", False)], limit=1) + if not country: + # If no country has faker_locale configured, skip gracefully + return + gen = self._make_generator() + gen.locale_origin = country + self.assertEqual(gen.locale_origin_faker_locale, country.faker_locale) + + def test_locale_origin_empty_gives_empty_faker_locale(self): + """When locale_origin is not set, locale_origin_faker_locale should be falsy.""" + gen = self._make_generator() + gen.locale_origin = False + # The related field returns False/empty when origin is not set + self.assertFalse(gen.locale_origin_faker_locale) + + +@tagged("post_install", "-at_install") +class TestCaseDemoGeneratorHelpers(TransactionCase): + """Tests for the private helper methods on spp.case.demo.generator.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + Stage = cls.env["spp.case.stage"] + stage_data = [ + ("Intake", "intake", 10), + ("Assessment", "assessment", 20), + ("Planning", "planning", 30), + ("Implementation", "implementation", 40), + ("Monitoring", "monitoring", 50), + ("Closed", "closure", 60), + ] + for name, phase, sequence in stage_data: + if not Stage.search([("phase", "=", phase)], limit=1): + Stage.create({"name": name, "phase": phase, "sequence": sequence, "is_closed": phase == "closure"}) + + CaseType = cls.env["spp.case.type"] + type_data = [ + ("General Support", "GEN"), + ("Emergency Assistance", "EMG"), + ("Health Support", "HLT"), + ("Child Protection", "CHP"), + ] + for name, code in type_data: + if not CaseType.search([("name", "=", name)], limit=1): + CaseType.create({"name": name, "code": code}) + + cls.beneficiary = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create({"name": "Demo Beneficiary", "is_registrant": True}) + ) + cls.non_registrant = ( + cls.env["res.partner"].with_context(tracking_disable=True).create({"name": "Non Registrant"}) + ) + + cls.gen = cls.env["spp.case.demo.generator"].create( + { + "name": "Helper Test Generator", + "number_of_cases": 1, + "include_stories": False, + "link_to_beneficiaries": True, + "percentage_with_plans": 0.0, + "percentage_with_visits": 0.0, + "percentage_with_notes": 0.0, + "percentage_closed": 0.0, + } + ) + + # ------------------------------------------------------------------ + # _get_available_beneficiaries + # ------------------------------------------------------------------ + + def test_get_available_beneficiaries_returns_registrants(self): + """_get_available_beneficiaries should return only is_registrant partners.""" + beneficiaries = self.gen._get_available_beneficiaries() + self.assertTrue(len(beneficiaries) >= 1) + for partner in beneficiaries: + self.assertTrue(partner.is_registrant) + + def test_get_available_beneficiaries_excludes_non_registrants(self): + """Non-registrant partners must not appear in the beneficiaries list.""" + beneficiaries = self.gen._get_available_beneficiaries() + self.assertNotIn(self.non_registrant, beneficiaries) + + # ------------------------------------------------------------------ + # _get_or_create_case_type + # ------------------------------------------------------------------ + + def test_get_or_create_case_type_finds_existing(self): + """_get_or_create_case_type should return existing type by name.""" + existing = self.env["spp.case.type"].search([("name", "=", "General Support")], limit=1) + result = self.gen._get_or_create_case_type("General Support") + self.assertEqual(result.id, existing.id) + + def test_get_or_create_case_type_creates_new(self): + """_get_or_create_case_type should create a type when none exists.""" + unique_name = "Unique Test Type XYZ123" + # Ensure it doesn't pre-exist + self.env["spp.case.type"].search([("name", "=", unique_name)]).unlink() + + result = self.gen._get_or_create_case_type(unique_name) + self.assertTrue(result) + self.assertEqual(result.name, unique_name) + + def test_get_or_create_case_type_code_truncated(self): + """Created case type code should be truncated to 10 characters.""" + long_name = "Very Long Type Name That Exceeds Ten Chars" + self.env["spp.case.type"].search([("name", "=", long_name)]).unlink() + result = self.gen._get_or_create_case_type(long_name) + self.assertLessEqual(len(result.code), 10) + + # ------------------------------------------------------------------ + # _get_random_case_type + # ------------------------------------------------------------------ + + def test_get_random_case_type_returns_type_when_types_exist(self): + """_get_random_case_type should return a case type when types exist.""" + result = self.gen._get_random_case_type() + self.assertTrue(result, "Expected a case type to be returned") + self.assertIsInstance(result._name, str) + self.assertEqual(result._name, "spp.case.type") + + def test_get_random_case_type_returns_none_when_no_types(self): + """_get_random_case_type should return None when no types exist.""" + gen = self.gen + with patch.object( + type(self.env["spp.case.type"]), + "search", + return_value=self.env["spp.case.type"], + ): + result = gen._get_random_case_type() + self.assertIsNone(result) + + # ------------------------------------------------------------------ + # _get_random_stage + # ------------------------------------------------------------------ + + def test_get_random_stage_returns_stage_when_stages_exist(self): + """_get_random_stage should return a stage when stages exist.""" + result = self.gen._get_random_stage() + self.assertTrue(result) + self.assertEqual(result._name, "spp.case.stage") + + def test_get_random_stage_returns_none_when_no_stages(self): + """_get_random_stage should return None when no stages exist.""" + with patch.object( + type(self.env["spp.case.stage"]), + "search", + return_value=self.env["spp.case.stage"], + ): + result = self.gen._get_random_stage() + self.assertIsNone(result) + + # ------------------------------------------------------------------ + # _determine_stage_from_journey + # ------------------------------------------------------------------ + + def test_determine_stage_closure_for_close_case_action(self): + """Journey with close_case action should return closure stage.""" + journey = [{"action": "close_case", "days_back": 5}] + result = self.gen._determine_stage_from_journey(journey) + if result: + self.assertEqual(result.phase, "closure") + + def test_determine_stage_monitoring_for_many_visits(self): + """Journey with >3 visits/notes should return monitoring stage.""" + journey = [ + {"action": "home_visit", "days_back": 50}, + {"action": "progress_note", "days_back": 40}, + {"action": "home_visit", "days_back": 30}, + {"action": "progress_note", "days_back": 20}, + ] + result = self.gen._determine_stage_from_journey(journey) + if result: + self.assertEqual(result.phase, "monitoring") + + def test_determine_stage_implementation_for_completed_intervention(self): + """Journey with complete_intervention should prefer implementation stage.""" + journey = [ + {"action": "create_plan", "days_back": 30}, + {"action": "complete_intervention", "days_back": 20, "intervention": "X"}, + ] + result = self.gen._determine_stage_from_journey(journey) + if result: + self.assertEqual(result.phase, "implementation") + + def test_determine_stage_planning_for_create_plan(self): + """Journey with create_plan (no completions) should return planning stage.""" + journey = [ + {"action": "intake", "days_back": 40}, + {"action": "create_plan", "days_back": 35}, + ] + result = self.gen._determine_stage_from_journey(journey) + if result: + self.assertEqual(result.phase, "planning") + + def test_determine_stage_assessment_for_assessment_action(self): + """Journey with assessment action should return assessment stage.""" + journey = [ + {"action": "intake", "days_back": 20}, + {"action": "assessment", "days_back": 18}, + ] + result = self.gen._determine_stage_from_journey(journey) + if result: + self.assertEqual(result.phase, "assessment") + + def test_determine_stage_default_to_intake(self): + """Journey with only intake action should default to intake stage.""" + journey = [{"action": "intake", "days_back": 5}] + result = self.gen._determine_stage_from_journey(journey) + if result: + self.assertEqual(result.phase, "intake") + + # ------------------------------------------------------------------ + # _find_or_create_client + # ------------------------------------------------------------------ + + def test_find_or_create_client_finds_existing_registrant(self): + """_find_or_create_client returns existing registrant by name.""" + from unittest.mock import MagicMock + + client_info = {"name": self.beneficiary.name} + result = self.gen._find_or_create_client(client_info, [self.beneficiary], MagicMock()) + self.assertEqual(result.id, self.beneficiary.id) + + def test_find_or_create_client_returns_random_from_list_when_name_missing(self): + """_find_or_create_client returns from beneficiaries when no name given.""" + from unittest.mock import MagicMock + + client_info = {} + result = self.gen._find_or_create_client(client_info, [self.beneficiary], MagicMock()) + self.assertEqual(result.id, self.beneficiary.id) + + def test_find_or_create_client_returns_none_when_no_beneficiaries_and_no_name(self): + """_find_or_create_client returns None when list is empty and no name match.""" + from unittest.mock import MagicMock + + client_info = {} + result = self.gen._find_or_create_client(client_info, [], MagicMock()) + self.assertIsNone(result) + + def test_find_or_create_client_falls_back_to_list_when_name_not_found(self): + """When name doesn't match existing registrant, a random beneficiary is returned.""" + from unittest.mock import MagicMock + + client_info = {"name": "Nonexistent Person XYZ999"} + result = self.gen._find_or_create_client(client_info, [self.beneficiary], MagicMock()) + self.assertEqual(result.id, self.beneficiary.id) + + # ------------------------------------------------------------------ + # _get_or_create_service + # ------------------------------------------------------------------ + + def test_get_or_create_service_returns_none_when_model_not_in_env(self): + """_get_or_create_service should return None when spp.service is not installed.""" + # spp.service is not in spp_case_demo's dependency tree + result = self.gen._get_or_create_service("Some Service") + self.assertIsNone(result) + + +@tagged("post_install", "-at_install") +class TestCaseDemoGeneratorGenerate(TransactionCase): + """Tests for the main generate_cases flow on spp.case.demo.generator.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + Stage = cls.env["spp.case.stage"] + stage_data = [ + ("Intake", "intake", 10, False), + ("Assessment", "assessment", 20, False), + ("Planning", "planning", 30, False), + ("Implementation", "implementation", 40, False), + ("Monitoring", "monitoring", 50, False), + ("Closed", "closure", 60, True), + ] + for name, phase, sequence, is_closed in stage_data: + if not Stage.search([("phase", "=", phase)], limit=1): + Stage.create({"name": name, "phase": phase, "sequence": sequence, "is_closed": is_closed}) + + CaseType = cls.env["spp.case.type"] + type_data = [ + ("General Support", "GEN", 1), + ("Emergency Assistance", "EMG", 3), + ("Health Support", "HLT", 1), + ("Child Protection", "CHP", 2), + ] + for name, code, intensity in type_data: + if not CaseType.search([("name", "=", name)], limit=1): + CaseType.create({"name": name, "code": code, "default_intensity": str(intensity)}) + + # partner_id is required on spp.case; always provide a registrant + cls.beneficiary = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create({"name": "Gen Test Beneficiary", "is_registrant": True}) + ) + + def _make_generator(self, **kwargs): + # Default to link_to_beneficiaries=True because spp.case.partner_id is required + defaults = { + "name": "Gen Test", + "number_of_cases": 2, + "include_stories": False, + "cases_days_back": 30, + "link_to_beneficiaries": True, + "percentage_with_plans": 0.0, + "percentage_with_visits": 0.0, + "percentage_with_notes": 0.0, + "percentage_closed": 0.0, + } + defaults.update(kwargs) + return self.env["spp.case.demo.generator"].create(defaults) + + # ------------------------------------------------------------------ + # generate_cases: no beneficiaries but link_to_beneficiaries=True + # ------------------------------------------------------------------ + + def test_generate_cases_raises_when_no_beneficiaries_and_link_enabled(self): + """Should raise UserError when link_to_beneficiaries=True but no registrants exist.""" + gen = self._make_generator(link_to_beneficiaries=True, number_of_cases=1) + # Odoo model methods must be patched on the class, not the instance + GeneratorModel = type(gen) + with patch.object(GeneratorModel, "_get_available_beneficiaries", return_value=self.env["res.partner"]): + with self.assertRaises(UserError): + gen.generate_cases() + + # ------------------------------------------------------------------ + # generate_cases: basic random case generation + # ------------------------------------------------------------------ + + def test_generate_cases_creates_requested_number_of_cases(self): + """generate_cases should create number_of_cases random cases.""" + gen = self._make_generator(number_of_cases=3, include_stories=False) + cases_before = self.env["spp.case"].search([]).ids + result = gen.generate_cases() + new_cases = self.env["spp.case"].search([("id", "not in", cases_before)]) + self.assertEqual(len(new_cases), 3) + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(result["res_model"], "spp.case") + + def test_generate_cases_returns_action_with_generated_case_ids(self): + """The action domain returned by generate_cases should only include new cases.""" + gen = self._make_generator(number_of_cases=2, include_stories=False) + cases_before = self.env["spp.case"].search([]).ids + result = gen.generate_cases() + + domain = result.get("domain", []) + self.assertTrue(domain) + id_list = domain[0][2] + for cid in id_list: + self.assertNotIn(cid, cases_before) + + def test_generate_cases_with_link_to_beneficiaries(self): + """generate_cases with link_to_beneficiaries=True links cases to registrants.""" + gen = self._make_generator(number_of_cases=2, link_to_beneficiaries=True) + result = gen.generate_cases() + domain = result["domain"] + case_ids = domain[0][2] + cases = self.env["spp.case"].browse(case_ids) + for case in cases: + if case.partner_id: + self.assertTrue(case.partner_id.is_registrant) + + def test_generate_cases_context_action_properties(self): + """The returned action should contain the right view_mode and context.""" + gen = self._make_generator(number_of_cases=1, include_stories=False) + result = gen.generate_cases() + self.assertIn("list", result.get("view_mode", "")) + self.assertIn("search_default_group_by_stage", result.get("context", {})) + + # ------------------------------------------------------------------ + # generate_cases: percentage-driven related data + # ------------------------------------------------------------------ + + def test_generate_cases_with_plans(self): + """generate_cases with percentage_with_plans=100 creates plans.""" + gen = self._make_generator( + number_of_cases=2, + percentage_with_plans=100.0, + ) + result = gen.generate_cases() + case_ids = result["domain"][0][2] + cases = self.env["spp.case"].browse(case_ids) + plans = self.env["spp.case.intervention.plan"].search([("case_id", "in", cases.ids)]) + self.assertTrue(len(plans) >= 1, "Expected at least one plan to be created") + + def test_generate_cases_with_visits(self): + """generate_cases with percentage_with_visits=100 creates visits.""" + gen = self._make_generator( + number_of_cases=2, + percentage_with_visits=100.0, + ) + result = gen.generate_cases() + case_ids = result["domain"][0][2] + visits = self.env["spp.case.visit"].search([("case_id", "in", case_ids)]) + self.assertTrue(len(visits) >= 1, "Expected at least one visit to be created") + + def test_generate_cases_with_notes(self): + """generate_cases with percentage_with_notes=100 creates notes.""" + gen = self._make_generator( + number_of_cases=2, + percentage_with_notes=100.0, + ) + result = gen.generate_cases() + case_ids = result["domain"][0][2] + notes = self.env["spp.case.note"].search([("case_id", "in", case_ids)]) + self.assertTrue(len(notes) >= 1, "Expected at least one note to be created") + + def test_generate_cases_with_closure(self): + """generate_cases with percentage_closed=100 closes all cases.""" + closure_stage = self.env["spp.case.stage"].search([("phase", "=", "closure")], limit=1) + gen = self._make_generator( + number_of_cases=2, + percentage_closed=100.0, + ) + result = gen.generate_cases() + case_ids = result["domain"][0][2] + cases = self.env["spp.case"].browse(case_ids) + for case in cases: + if closure_stage: + self.assertEqual(case.stage_id.id, closure_stage.id) + self.assertTrue(case.actual_closure_date) + + # ------------------------------------------------------------------ + # generate_cases: with stories + # ------------------------------------------------------------------ + + def test_generate_cases_with_stories_creates_cases(self): + """generate_cases with include_stories=True creates the demo story cases.""" + from ..models.case_demo_stories import CASE_DEMO_STORIES + + story_count = len(CASE_DEMO_STORIES) + gen = self._make_generator( + number_of_cases=story_count, + include_stories=True, + ) + result = gen.generate_cases() + self.assertEqual(result["type"], "ir.actions.act_window") + case_ids = result["domain"][0][2] + self.assertTrue(len(case_ids) >= 1) + + def test_generate_cases_with_stories_zero_remaining(self): + """When number_of_cases equals story count, no random cases are added beyond stories.""" + from ..models.case_demo_stories import CASE_DEMO_STORIES + + story_count = len(CASE_DEMO_STORIES) + gen = self._make_generator( + number_of_cases=story_count, + include_stories=True, + ) + result = gen.generate_cases() + case_ids = result["domain"][0][2] + # Total should be at most story_count (stories that succeed) + self.assertLessEqual(len(case_ids), story_count) + + def test_generate_cases_stories_plus_random(self): + """When number_of_cases exceeds story count, random cases fill the rest.""" + from ..models.case_demo_stories import CASE_DEMO_STORIES + + story_count = len(CASE_DEMO_STORIES) + extra = 3 + gen = self._make_generator( + number_of_cases=story_count + extra, + include_stories=True, + ) + result = gen.generate_cases() + case_ids = result["domain"][0][2] + # Stories that succeed plus extra random cases + self.assertLessEqual(len(case_ids), story_count + extra) + self.assertGreater(len(case_ids), 0) + + +@tagged("post_install", "-at_install") +class TestCaseDemoGeneratorJourney(TransactionCase): + """Tests for _process_case_journey and _create_case_from_story.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + Stage = cls.env["spp.case.stage"] + stage_data = [ + ("Intake", "intake", 10, False), + ("Assessment", "assessment", 20, False), + ("Planning", "planning", 30, False), + ("Implementation", "implementation", 40, False), + ("Monitoring", "monitoring", 50, False), + ("Closed", "closure", 60, True), + ] + for name, phase, sequence, is_closed in stage_data: + if not Stage.search([("phase", "=", phase)], limit=1): + Stage.create({"name": name, "phase": phase, "sequence": sequence, "is_closed": is_closed}) + + CaseType = cls.env["spp.case.type"] + for name, code in [("General Support", "GEN2"), ("Emergency Assistance", "EMG2")]: + if not CaseType.search([("name", "=", name)], limit=1): + CaseType.create({"name": name, "code": code}) + + cls.client = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create({"name": "Journey Test Client", "is_registrant": True}) + ) + + cls.gen = cls.env["spp.case.demo.generator"].create( + { + "name": "Journey Test Gen", + "number_of_cases": 1, + "include_stories": False, + "link_to_beneficiaries": True, + "percentage_with_plans": 0.0, + "percentage_with_visits": 0.0, + "percentage_with_notes": 0.0, + "percentage_closed": 0.0, + } + ) + + # Base case for journey tests + case_type = CaseType.search([("name", "=", "General Support")], limit=1) + stage_intake = Stage.search([("phase", "=", "intake")], limit=1) + cls.base_case = ( + cls.env["spp.case"] + .sudo() + .create( + { + "case_type_id": case_type.id, + "stage_id": stage_intake.id, + "partner_id": cls.client.id, + "presenting_issue": "Journey test case", + "case_worker_id": cls.env.user.id, + } + ) + ) + + def _fake(self): + """Return a simple Faker instance for test use.""" + from faker import Faker + + return Faker("en_US") + + # ------------------------------------------------------------------ + # _process_case_journey: individual action types + # ------------------------------------------------------------------ + + def test_journey_create_plan(self): + """create_plan action should produce an intervention plan.""" + fake = self._fake() + journey = [{"action": "create_plan", "days_back": 10, "plan_name": "Test Plan", "goals": "Goals"}] + self.gen._process_case_journey(self.base_case, journey, fake) + plans = self.env["spp.case.intervention.plan"].search([("case_id", "=", self.base_case.id)]) + self.assertTrue(len(plans) >= 1) + + def test_journey_add_intervention_requires_current_plan(self): + """add_intervention without a prior create_plan should produce no intervention.""" + fake = self._fake() + count_before = self.env["spp.case.intervention"].search_count([("plan_id.case_id", "=", self.base_case.id)]) + journey = [{"action": "add_intervention", "days_back": 10, "intervention": "Orphan intervention"}] + self.gen._process_case_journey(self.base_case, journey, fake) + count_after = self.env["spp.case.intervention"].search_count([("plan_id.case_id", "=", self.base_case.id)]) + # Without a plan, no intervention should be created + self.assertEqual(count_before, count_after) + + def test_journey_create_plan_then_add_intervention(self): + """create_plan followed by add_intervention creates an intervention.""" + fake = self._fake() + journey = [ + {"action": "create_plan", "days_back": 20, "plan_name": "Plan For Intervention"}, + {"action": "add_intervention", "days_back": 18, "intervention": "Test Intervention Step"}, + ] + self.gen._process_case_journey(self.base_case, journey, fake) + interventions = self.env["spp.case.intervention"].search( + [("plan_id.case_id", "=", self.base_case.id), ("name", "=", "Test Intervention Step")] + ) + self.assertTrue(len(interventions) >= 1) + + def test_journey_complete_intervention(self): + """complete_intervention marks the named intervention as completed.""" + fake = self._fake() + journey = [ + {"action": "create_plan", "days_back": 30, "plan_name": "Completion Plan"}, + {"action": "add_intervention", "days_back": 28, "intervention": "To Be Completed"}, + {"action": "complete_intervention", "days_back": 20, "intervention": "To Be Completed"}, + ] + self.gen._process_case_journey(self.base_case, journey, fake) + intervention = self.env["spp.case.intervention"].search( + [ + ("plan_id.case_id", "=", self.base_case.id), + ("name", "=", "To Be Completed"), + ("state", "=", "completed"), + ], + limit=1, + ) + self.assertTrue(intervention, "Intervention should have been marked as completed") + + def test_journey_complete_intervention_name_not_found(self): + """complete_intervention with a name that doesn't match any intervention is a no-op.""" + fake = self._fake() + journey = [ + {"action": "create_plan", "days_back": 30, "plan_name": "No-op Plan"}, + { + "action": "complete_intervention", + "days_back": 20, + "intervention": "Nonexistent intervention name", + }, + ] + # Should not raise + try: + self.gen._process_case_journey(self.base_case, journey, fake) + except Exception as exc: + self.fail(f"complete_intervention raised unexpectedly: {exc}") + + def test_journey_home_visit(self): + """home_visit action should create a case visit with visit_type='home'.""" + fake = self._fake() + journey = [{"action": "home_visit", "days_back": 5, "purpose": "Check-in"}] + count_before = self.env["spp.case.visit"].search_count([("case_id", "=", self.base_case.id)]) + self.gen._process_case_journey(self.base_case, journey, fake) + count_after = self.env["spp.case.visit"].search_count([("case_id", "=", self.base_case.id)]) + self.assertEqual(count_after, count_before + 1) + visit = self.env["spp.case.visit"].search([("case_id", "=", self.base_case.id)], order="id desc", limit=1) + self.assertEqual(visit.visit_type, "home") + + def test_journey_office_visit(self): + """office_visit action should create a case visit with visit_type='office'.""" + fake = self._fake() + journey = [{"action": "office_visit", "days_back": 5, "purpose": "Office meeting"}] + self.gen._process_case_journey(self.base_case, journey, fake) + visit = self.env["spp.case.visit"].search([("case_id", "=", self.base_case.id)], order="id desc", limit=1) + self.assertEqual(visit.visit_type, "office") + + def test_journey_final_visit(self): + """final_visit action should create a visit (treated as 'home' type).""" + fake = self._fake() + journey = [{"action": "final_visit", "days_back": 3, "purpose": "Closure visit"}] + count_before = self.env["spp.case.visit"].search_count([("case_id", "=", self.base_case.id)]) + self.gen._process_case_journey(self.base_case, journey, fake) + count_after = self.env["spp.case.visit"].search_count([("case_id", "=", self.base_case.id)]) + self.assertEqual(count_after, count_before + 1) + + def test_journey_progress_note(self): + """progress_note action should create a case note with note_type='progress'.""" + fake = self._fake() + journey = [{"action": "progress_note", "days_back": 10, "note": "Progress made"}] + count_before = self.env["spp.case.note"].search_count([("case_id", "=", self.base_case.id)]) + self.gen._process_case_journey(self.base_case, journey, fake) + count_after = self.env["spp.case.note"].search_count([("case_id", "=", self.base_case.id)]) + self.assertEqual(count_after, count_before + 1) + note = self.env["spp.case.note"].search([("case_id", "=", self.base_case.id)], order="id desc", limit=1) + self.assertEqual(note.note_type, "progress") + + def test_journey_assessment_note(self): + """assessment action should create a case note with note_type='assessment'.""" + fake = self._fake() + journey = [{"action": "assessment", "days_back": 15, "notes": "Assessment done"}] + self.gen._process_case_journey(self.base_case, journey, fake) + note = self.env["spp.case.note"].search( + [("case_id", "=", self.base_case.id), ("note_type", "=", "assessment")], + order="id desc", + limit=1, + ) + self.assertTrue(note) + + def test_journey_emergency_assessment_note(self): + """emergency_assessment action should create an assessment note.""" + fake = self._fake() + journey = [{"action": "emergency_assessment", "days_back": 2}] + self.gen._process_case_journey(self.base_case, journey, fake) + note = self.env["spp.case.note"].search( + [("case_id", "=", self.base_case.id), ("note_type", "=", "assessment")], + order="id desc", + limit=1, + ) + self.assertTrue(note) + + def test_journey_safety_assessment_note(self): + """safety_assessment action should create an assessment note.""" + fake = self._fake() + journey = [{"action": "safety_assessment", "days_back": 2}] + self.gen._process_case_journey(self.base_case, journey, fake) + note = self.env["spp.case.note"].search( + [("case_id", "=", self.base_case.id), ("note_type", "=", "assessment")], + order="id desc", + limit=1, + ) + self.assertTrue(note) + + def test_journey_add_referral_skips_silently_when_no_service_model(self): + """add_referral without spp.service model does not raise; it skips silently.""" + fake = self._fake() + journey = [{"action": "add_referral", "days_back": 10, "service": "Housing", "reason": "Need it"}] + # spp.service doesn't exist in this module, so it returns None and no referral is created + try: + self.gen._process_case_journey(self.base_case, journey, fake) + except Exception as exc: + self.fail(f"_process_case_journey raised unexpectedly: {exc}") + + def test_journey_close_case(self): + """close_case action moves case to closure stage and sets closure date.""" + fake = self._fake() + journey = [{"action": "close_case", "days_back": 5}] + self.gen._process_case_journey(self.base_case, journey, fake) + closure_stage = self.env["spp.case.stage"].search([("phase", "=", "closure")], limit=1) + if closure_stage: + self.assertEqual(self.base_case.stage_id.id, closure_stage.id) + self.assertTrue(self.base_case.actual_closure_date) + + def test_journey_close_case_also_completes_active_plan(self): + """close_case with an active plan should mark the plan as completed.""" + fake = self._fake() + case_type = self.env["spp.case.type"].search([("name", "=", "General Support")], limit=1) + stage_intake = self.env["spp.case.stage"].search([("phase", "=", "intake")], limit=1) + test_case = ( + self.env["spp.case"] + .sudo() + .create( + { + "case_type_id": case_type.id, + "stage_id": stage_intake.id, + "partner_id": self.client.id, + "presenting_issue": "Plan closure test", + "case_worker_id": self.env.user.id, + } + ) + ) + journey = [ + {"action": "create_plan", "days_back": 40, "plan_name": "Plan to Close"}, + {"action": "close_case", "days_back": 5}, + ] + self.gen._process_case_journey(test_case, journey, fake) + plan = self.env["spp.case.intervention.plan"].search([("case_id", "=", test_case.id)], limit=1) + if plan: + self.assertEqual(plan.state, "completed") + + def test_journey_intake_action_is_ignored_gracefully(self): + """intake action has no handler; it should be skipped without error.""" + fake = self._fake() + journey = [{"action": "intake", "days_back": 10, "notes": "Intake note"}] + try: + self.gen._process_case_journey(self.base_case, journey, fake) + except Exception as exc: + self.fail(f"intake action raised unexpectedly: {exc}") + + # ------------------------------------------------------------------ + # _create_case_from_story + # ------------------------------------------------------------------ + + def test_create_case_from_story_santos(self): + """_create_case_from_story should create a case for the Santos story.""" + from ..models.case_demo_stories import get_case_story_by_id + + story = get_case_story_by_id("santos_family_support") + fake = self._fake() + case = self.gen._create_case_from_story(story, fake, [self.client]) + self.assertTrue(case) + self.assertTrue(case.id) + + def test_create_case_from_story_emergency(self): + """_create_case_from_story should handle the emergency story without error.""" + from ..models.case_demo_stories import get_case_story_by_id + + story = get_case_story_by_id("dela_cruz_emergency") + fake = self._fake() + case = self.gen._create_case_from_story(story, fake, [self.client]) + self.assertTrue(case) + + def test_create_case_from_story_garcia_elder_care(self): + """_create_case_from_story should handle the Garcia story with referral action.""" + from ..models.case_demo_stories import get_case_story_by_id + + story = get_case_story_by_id("garcia_elder_care") + fake = self._fake() + case = self.gen._create_case_from_story(story, fake, [self.client]) + self.assertTrue(case) + + def test_create_case_from_story_sets_case_type(self): + """Created case should have the case_type specified in the story.""" + from ..models.case_demo_stories import get_case_story_by_id + + story = get_case_story_by_id("santos_family_support") + fake = self._fake() + case = self.gen._create_case_from_story(story, fake, [self.client]) + self.assertTrue(case.case_type_id) + + def test_create_case_from_story_sets_opened_date(self): + """Created case should have an opened_date based on the intake step.""" + from ..models.case_demo_stories import get_case_story_by_id + + story = get_case_story_by_id("santos_family_support") + fake = self._fake() + case = self.gen._create_case_from_story(story, fake, [self.client]) + self.assertTrue(case.opened_date) + + def test_create_case_from_story_closed_story_has_closure_date(self): + """A story with a close_case step should result in a case with closure date set.""" + from ..models.case_demo_stories import get_case_story_by_id + + story = get_case_story_by_id("santos_family_support") + fake = self._fake() + case = self.gen._create_case_from_story(story, fake, [self.client]) + # Santos story has a close_case step + self.assertTrue(case.actual_closure_date) + + +@tagged("post_install", "-at_install") +class TestCaseDemoGeneratorAddHelpers(TransactionCase): + """Tests for _add_random_plan, _add_random_visits, _add_random_notes, _close_random_case.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + Stage = cls.env["spp.case.stage"] + stage_data = [ + ("Intake", "intake", 10, False), + ("Closure", "closure", 60, True), + ] + for name, phase, sequence, is_closed in stage_data: + if not Stage.search([("phase", "=", phase)], limit=1): + Stage.create({"name": name, "phase": phase, "sequence": sequence, "is_closed": is_closed}) + + CaseType = cls.env["spp.case.type"] + if not CaseType.search([("name", "=", "General Support")], limit=1): + CaseType.create({"name": "General Support", "code": "GEN3"}) + + cls.client = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create({"name": "Add Helper Client", "is_registrant": True}) + ) + + case_type = CaseType.search([("name", "=", "General Support")], limit=1) + stage = Stage.search([("phase", "=", "intake")], limit=1) + cls.test_case = ( + cls.env["spp.case"] + .sudo() + .create( + { + "case_type_id": case_type.id, + "stage_id": stage.id, + "partner_id": cls.client.id, + "presenting_issue": "Helper tests", + "case_worker_id": cls.env.user.id, + } + ) + ) + + cls.gen = cls.env["spp.case.demo.generator"].create( + { + "name": "Add Helper Gen", + "number_of_cases": 1, + "include_stories": False, + "link_to_beneficiaries": True, + "percentage_with_plans": 0.0, + "percentage_with_visits": 0.0, + "percentage_with_notes": 0.0, + "percentage_closed": 0.0, + } + ) + + def _fake(self): + from faker import Faker + + return Faker("en_US") + + def test_add_random_plan_creates_plan_with_interventions(self): + """_add_random_plan should create one plan with 2-4 interventions.""" + from odoo import fields + + fake = self._fake() + intake_date = fields.Date.today() + self.gen._add_random_plan(self.test_case, fake, intake_date) + plans = self.env["spp.case.intervention.plan"].search([("case_id", "=", self.test_case.id)]) + self.assertTrue(len(plans) >= 1) + for plan in plans: + interventions = self.env["spp.case.intervention"].search([("plan_id", "=", plan.id)]) + self.assertGreaterEqual(len(interventions), 2) + self.assertLessEqual(len(interventions), 4) + + def test_add_random_visits_creates_visits(self): + """_add_random_visits should create 1-3 visits on the case.""" + from odoo import fields + + fake = self._fake() + intake_date = fields.Date.today() + count_before = self.env["spp.case.visit"].search_count([("case_id", "=", self.test_case.id)]) + self.gen._add_random_visits(self.test_case, fake, intake_date) + count_after = self.env["spp.case.visit"].search_count([("case_id", "=", self.test_case.id)]) + added = count_after - count_before + self.assertGreaterEqual(added, 1) + self.assertLessEqual(added, 3) + + def test_add_random_notes_creates_notes(self): + """_add_random_notes should create 1-4 notes on the case.""" + from odoo import fields + + fake = self._fake() + intake_date = fields.Date.today() + count_before = self.env["spp.case.note"].search_count([("case_id", "=", self.test_case.id)]) + self.gen._add_random_notes(self.test_case, fake, intake_date) + count_after = self.env["spp.case.note"].search_count([("case_id", "=", self.test_case.id)]) + added = count_after - count_before + self.assertGreaterEqual(added, 1) + self.assertLessEqual(added, 4) + + def test_close_random_case_moves_to_closure_stage(self): + """_close_random_case should move the case to closure stage and set closure date.""" + from odoo import fields + + fake = self._fake() + intake_date = fields.Date.today() + closure_stage = self.env["spp.case.stage"].search([("phase", "=", "closure")], limit=1) + self.gen._close_random_case(self.test_case, fake, intake_date) + if closure_stage: + self.assertEqual(self.test_case.stage_id.id, closure_stage.id) + self.assertTrue(self.test_case.actual_closure_date) + + def test_close_random_case_does_nothing_without_closure_stage(self): + """_close_random_case should not raise even if no closure stage is configured.""" + from odoo import fields + + fake = self._fake() + intake_date = fields.Date.today() + with patch.object( + type(self.env["spp.case.stage"]), + "search", + return_value=self.env["spp.case.stage"], + ): + try: + self.gen._close_random_case(self.test_case, fake, intake_date) + except Exception as exc: + self.fail(f"_close_random_case raised unexpectedly: {exc}") diff --git a/spp_case_demo/views/case_demo_wizard_view.xml b/spp_case_demo/views/case_demo_wizard_view.xml new file mode 100644 index 00000000..0179ee36 --- /dev/null +++ b/spp_case_demo/views/case_demo_wizard_view.xml @@ -0,0 +1,82 @@ + + + + + spp.case.demo.wizard.form + spp.case.demo.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + + Generate Case Demo Data + spp.case.demo.wizard + form + new + + + + +
diff --git a/spp_case_demo/wizard/__init__.py b/spp_case_demo/wizard/__init__.py new file mode 100644 index 00000000..d2f49502 --- /dev/null +++ b/spp_case_demo/wizard/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import case_demo_wizard diff --git a/spp_case_demo/wizard/case_demo_wizard.py b/spp_case_demo/wizard/case_demo_wizard.py new file mode 100644 index 00000000..8d3f0baf --- /dev/null +++ b/spp_case_demo/wizard/case_demo_wizard.py @@ -0,0 +1,13 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class SPPCaseDemoWizard(models.TransientModel): + _name = "spp.case.demo.wizard" + _inherit = "spp.case.demo.generator" + _description = "Case Management Demo Data Wizard" + + # Inherits all fields and methods from spp.case.demo.generator + # This provides a clean separation between the generator logic + # and the wizard interface diff --git a/spp_case_entitlements/README.rst b/spp_case_entitlements/README.rst new file mode 100644 index 00000000..5fe10e1e --- /dev/null +++ b/spp_case_entitlements/README.rst @@ -0,0 +1,141 @@ +===================================== +OpenSPP Case Entitlements Integration +===================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:85702293688d71633ac6f04626949305c9336d75e471d47cd5b798013bea3af7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_case_entitlements + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Extends case management with program entitlement tracking. Links cases +to entitlements via many2many relationship, computes statistics (total, +approved, pending counts and value), and provides filtered views. +Auto-loads beneficiary entitlements when partner is selected. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Link multiple entitlements to a case via many2many relationship +- Auto-populate entitlements when case partner is selected +- Compute entitlement statistics: total count, approved count, pending + count, total value +- Filter entitlements by state (all, approved, pending) via dedicated + action methods +- Display entitlement counts and status in list, kanban, and form views +- Search and group cases by entitlement status + +Extended Models +~~~~~~~~~~~~~~~ + ++--------------+-------------------------------------------------------+ +| Model | Description | ++==============+=======================================================+ +| ``spp.case`` | Extended with entitlement relationships and computed | +| | metrics | ++--------------+-------------------------------------------------------+ + +Configuration +~~~~~~~~~~~~~ + +No configuration required. The module auto-installs when both +``spp_case_base`` and ``spp_programs`` are installed. + +UI Location +~~~~~~~~~~~ + +- **Menu**: Case Management > Cases > All Cases +- **Smart Buttons**: Case form header displays total, approved, and + pending entitlement counts +- **Tab**: "Entitlements" tab on case form with summary statistics and + entitlement list +- **List Columns**: Entitlement count and approved count appear in case + list view +- **Kanban Icons**: Entitlement indicators appear in case kanban cards +- **Search Filters**: "Has Entitlements", "No Entitlements", "Has + Approved Entitlements", "Has Pending Entitlements" + +Security +~~~~~~~~ + +==================================== ============================= +Group Access +==================================== ============================= +``spp_case_base.group_case_officer`` Read/Write/Create (no delete) +``spp_case_base.group_case_manager`` Full CRUD +==================================== ============================= + +Extension Points +~~~~~~~~~~~~~~~~ + +- Override ``_compute_entitlement_info()`` to customize entitlement + statistics or add domain-specific calculations +- Override ``_onchange_partner_entitlements()`` to filter which + entitlements auto-populate based on case criteria +- Inherit ``action_view_entitlements()``, + ``action_view_approved_entitlements()``, or + ``action_view_pending_entitlements()`` to modify entitlement list + views or add filtering logic + +Dependencies +~~~~~~~~~~~~ + +``spp_security``, ``spp_case_base``, ``spp_programs`` + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 +.. |maintainer-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-emjay0921| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_case_entitlements/__init__.py b/spp_case_entitlements/__init__.py new file mode 100644 index 00000000..c4ccea79 --- /dev/null +++ b/spp_case_entitlements/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/spp_case_entitlements/__manifest__.py b/spp_case_entitlements/__manifest__.py new file mode 100644 index 00000000..e0d65045 --- /dev/null +++ b/spp_case_entitlements/__manifest__.py @@ -0,0 +1,27 @@ +# pylint: disable=pointless-statement +{ + "name": "OpenSPP Case Entitlements Integration", + "version": "19.0.1.0.0", + "category": "OpenSPP/Monitoring", + "summary": "Links cases to program entitlements for case-entitlement relationship management", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Production/Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "depends": [ + "spp_security", + "spp_case_base", + "spp_programs", + ], + "data": [ + # Security + "security/ir.model.access.csv", + # Views + "views/case_views.xml", + ], + "demo": [], + "installable": True, + "application": False, + "auto_install": True, +} diff --git a/spp_case_entitlements/models/__init__.py b/spp_case_entitlements/models/__init__.py new file mode 100644 index 00000000..36289a09 --- /dev/null +++ b/spp_case_entitlements/models/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import case diff --git a/spp_case_entitlements/models/case.py b/spp_case_entitlements/models/case.py new file mode 100644 index 00000000..93848681 --- /dev/null +++ b/spp_case_entitlements/models/case.py @@ -0,0 +1,141 @@ +"""Extend Case model with entitlement integration.""" + +from odoo import api, fields, models + + +class CaseEntitlements(models.Model): + """Extend spp.case with entitlement tracking and management.""" + + _inherit = "spp.case" + + # Entitlement Relations + entitlement_ids = fields.Many2many( + "spp.entitlement", + "spp_case_entitlement_rel", + "case_id", + "entitlement_id", + string="Entitlements", + help="Program entitlements related to this case", + ) + + # Computed Entitlement Info + entitlement_count = fields.Integer( + compute="_compute_entitlement_info", + store=True, + help="Number of entitlements related to this case", + ) + + has_entitlements = fields.Boolean( + compute="_compute_entitlement_info", + store=True, + help="Whether this case has any related entitlements", + ) + + approved_entitlement_count = fields.Integer( + string="Approved Entitlements", + compute="_compute_entitlement_info", + store=True, + help="Number of approved entitlements", + ) + + pending_entitlement_count = fields.Integer( + string="Pending Entitlements", + compute="_compute_entitlement_info", + store=True, + help="Number of pending entitlements", + ) + + total_entitlement_amount = fields.Monetary( + string="Total Entitlement Value", + compute="_compute_entitlement_info", + store=False, + currency_field="currency_id", + help="Total value of all approved entitlements", + ) + + currency_id = fields.Many2one( + "res.currency", + string="Currency", + default=lambda self: self.env.company.currency_id, + ) + + @api.depends("entitlement_ids", "entitlement_ids.state", "entitlement_ids.initial_amount") + def _compute_entitlement_info(self): + """Compute entitlement-related information.""" + for case in self: + entitlements = case.entitlement_ids + case.entitlement_count = len(entitlements) + case.has_entitlements = bool(entitlements) + + # Count by state + approved = entitlements.filtered(lambda e: e.state == "approved") + pending = entitlements.filtered(lambda e: e.state == "pending_validation") + + case.approved_entitlement_count = len(approved) + case.pending_entitlement_count = len(pending) + + # Calculate total amount for approved entitlements + total_amount = sum(approved.mapped("initial_amount")) + case.total_entitlement_amount = total_amount + + @api.onchange("partner_id") + def _onchange_partner_entitlements(self): + """Load entitlements when client/partner is selected.""" + if self.partner_id: + # Search for all entitlements for this partner + entitlements = self.env["spp.entitlement"].search([("partner_id", "=", self.partner_id.id)]) + self.entitlement_ids = entitlements + else: + self.entitlement_ids = False + + def action_view_entitlements(self): + """Open entitlements in a new window.""" + self.ensure_one() + + return { + "name": "Entitlements", + "type": "ir.actions.act_window", + "res_model": "spp.entitlement", + "view_mode": "list,form", + "domain": [("id", "in", self.entitlement_ids.ids)], + "context": { + "create": False, + "default_partner_id": self.partner_id.id, + }, + } + + def action_view_approved_entitlements(self): + """Open only approved entitlements.""" + self.ensure_one() + + approved_ids = self.entitlement_ids.filtered(lambda e: e.state == "approved").ids + + return { + "name": "Approved Entitlements", + "type": "ir.actions.act_window", + "res_model": "spp.entitlement", + "view_mode": "list,form", + "domain": [("id", "in", approved_ids)], + "context": { + "create": False, + "default_partner_id": self.partner_id.id, + }, + } + + def action_view_pending_entitlements(self): + """Open only pending entitlements.""" + self.ensure_one() + + pending_ids = self.entitlement_ids.filtered(lambda e: e.state == "pending_validation").ids + + return { + "name": "Pending Entitlements", + "type": "ir.actions.act_window", + "res_model": "spp.entitlement", + "view_mode": "list,form", + "domain": [("id", "in", pending_ids)], + "context": { + "create": False, + "default_partner_id": self.partner_id.id, + }, + } diff --git a/spp_case_entitlements/pyproject.toml b/spp_case_entitlements/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_case_entitlements/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_case_entitlements/readme/DESCRIPTION.md b/spp_case_entitlements/readme/DESCRIPTION.md new file mode 100644 index 00000000..6630649b --- /dev/null +++ b/spp_case_entitlements/readme/DESCRIPTION.md @@ -0,0 +1,46 @@ +Extends case management with program entitlement tracking. Links cases to entitlements via many2many relationship, computes statistics (total, approved, pending counts and value), and provides filtered views. Auto-loads beneficiary entitlements when partner is selected. + +### Key Capabilities + +- Link multiple entitlements to a case via many2many relationship +- Auto-populate entitlements when case partner is selected +- Compute entitlement statistics: total count, approved count, pending count, total value +- Filter entitlements by state (all, approved, pending) via dedicated action methods +- Display entitlement counts and status in list, kanban, and form views +- Search and group cases by entitlement status + +### Extended Models + +| Model | Description | +| ---------- | ------------------------------------------------------------ | +| `spp.case` | Extended with entitlement relationships and computed metrics | + +### Configuration + +No configuration required. The module auto-installs when both `spp_case_base` and `spp_programs` are installed. + +### UI Location + +- **Menu**: Case Management > Cases > All Cases +- **Smart Buttons**: Case form header displays total, approved, and pending entitlement counts +- **Tab**: "Entitlements" tab on case form with summary statistics and entitlement list +- **List Columns**: Entitlement count and approved count appear in case list view +- **Kanban Icons**: Entitlement indicators appear in case kanban cards +- **Search Filters**: "Has Entitlements", "No Entitlements", "Has Approved Entitlements", "Has Pending Entitlements" + +### Security + +| Group | Access | +| ---------------------------------- | ----------------------------- | +| `spp_case_base.group_case_officer` | Read/Write/Create (no delete) | +| `spp_case_base.group_case_manager` | Full CRUD | + +### Extension Points + +- Override `_compute_entitlement_info()` to customize entitlement statistics or add domain-specific calculations +- Override `_onchange_partner_entitlements()` to filter which entitlements auto-populate based on case criteria +- Inherit `action_view_entitlements()`, `action_view_approved_entitlements()`, or `action_view_pending_entitlements()` to modify entitlement list views or add filtering logic + +### Dependencies + +`spp_security`, `spp_case_base`, `spp_programs` diff --git a/spp_case_entitlements/security/ir.model.access.csv b/spp_case_entitlements/security/ir.model.access.csv new file mode 100644 index 00000000..068f441f --- /dev/null +++ b/spp_case_entitlements/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_case_entitlements_user,spp.case.entitlements.user,spp_case_base.model_spp_case,spp_case_base.group_case_officer,1,1,1,0 +access_spp_case_entitlements_manager,spp.case.entitlements.manager,spp_case_base.model_spp_case,spp_case_base.group_case_manager,1,1,1,1 diff --git a/spp_case_entitlements/static/description/icon.png b/spp_case_entitlements/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_case_entitlements/static/description/icon.png differ diff --git a/spp_case_entitlements/static/description/index.html b/spp_case_entitlements/static/description/index.html new file mode 100644 index 00000000..963d26a6 --- /dev/null +++ b/spp_case_entitlements/static/description/index.html @@ -0,0 +1,506 @@ + + + + + +OpenSPP Case Entitlements Integration + + + +
+

OpenSPP Case Entitlements Integration

+ + +

Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

+

Extends case management with program entitlement tracking. Links cases +to entitlements via many2many relationship, computes statistics (total, +approved, pending counts and value), and provides filtered views. +Auto-loads beneficiary entitlements when partner is selected.

+
+

Key Capabilities

+
    +
  • Link multiple entitlements to a case via many2many relationship
  • +
  • Auto-populate entitlements when case partner is selected
  • +
  • Compute entitlement statistics: total count, approved count, pending +count, total value
  • +
  • Filter entitlements by state (all, approved, pending) via dedicated +action methods
  • +
  • Display entitlement counts and status in list, kanban, and form views
  • +
  • Search and group cases by entitlement status
  • +
+
+
+

Extended Models

+ ++++ + + + + + + + + + + +
ModelDescription
spp.caseExtended with entitlement relationships and computed +metrics
+
+
+

Configuration

+

No configuration required. The module auto-installs when both +spp_case_base and spp_programs are installed.

+
+
+

UI Location

+
    +
  • Menu: Case Management > Cases > All Cases
  • +
  • Smart Buttons: Case form header displays total, approved, and +pending entitlement counts
  • +
  • Tab: “Entitlements” tab on case form with summary statistics and +entitlement list
  • +
  • List Columns: Entitlement count and approved count appear in case +list view
  • +
  • Kanban Icons: Entitlement indicators appear in case kanban cards
  • +
  • Search Filters: “Has Entitlements”, “No Entitlements”, “Has +Approved Entitlements”, “Has Pending Entitlements”
  • +
+
+
+

Security

+ ++++ + + + + + + + + + + + + + +
GroupAccess
spp_case_base.group_case_officerRead/Write/Create (no delete)
spp_case_base.group_case_managerFull CRUD
+
+
+

Extension Points

+
    +
  • Override _compute_entitlement_info() to customize entitlement +statistics or add domain-specific calculations
  • +
  • Override _onchange_partner_entitlements() to filter which +entitlements auto-populate based on case criteria
  • +
  • Inherit action_view_entitlements(), +action_view_approved_entitlements(), or +action_view_pending_entitlements() to modify entitlement list +views or add filtering logic
  • +
+
+
+

Dependencies

+

spp_security, spp_case_base, spp_programs

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123 emjay0921

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_case_entitlements/tests/__init__.py b/spp_case_entitlements/tests/__init__.py new file mode 100644 index 00000000..a05b588b --- /dev/null +++ b/spp_case_entitlements/tests/__init__.py @@ -0,0 +1,3 @@ +"""Tests for spp_case_entitlements module.""" + +from . import test_case_entitlements diff --git a/spp_case_entitlements/tests/test_case_entitlements.py b/spp_case_entitlements/tests/test_case_entitlements.py new file mode 100644 index 00000000..91d694d8 --- /dev/null +++ b/spp_case_entitlements/tests/test_case_entitlements.py @@ -0,0 +1,800 @@ +"""Tests for CaseEntitlements model in spp_case_entitlements.""" + +from odoo import Command, fields +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCaseEntitlements(TransactionCase): + """Test the CaseEntitlements extension of spp.case.""" + + @classmethod + def setUpClass(cls): + """Set up test data shared across all test methods.""" + super().setUpClass() + + # Create test case worker user + cls.case_worker = ( + cls.env["res.users"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Test Case Worker", + "login": "ce_test_worker", + "email": "ce_worker@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + ) + + # Create test partner (client) — not a registrant so no domain restriction + cls.client = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Test Client", + "email": "ce_client@test.com", + } + ) + ) + + # Create registrant partner for entitlements (must have is_registrant=True) + cls.registrant = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Test Registrant", + "email": "ce_registrant@test.com", + "is_registrant": True, + } + ) + ) + + # Create a second registrant for isolation tests + cls.registrant2 = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Test Registrant 2", + "email": "ce_registrant2@test.com", + "is_registrant": True, + } + ) + ) + + # Create case type + cls.case_type = ( + cls.env["spp.case.type"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Social Protection", + "code": "CESP001", + "domain": "social_protection", + "default_intensity": "2", + } + ) + ) + + # Create case stage (non-closed) + cls.stage_intake = ( + cls.env["spp.case.stage"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Intake", + "sequence": 1, + "phase": "intake", + "is_closed": False, + } + ) + ) + + # Create a closed stage (needed for action_close_case) + cls.stage_closed = ( + cls.env["spp.case.stage"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Closed", + "sequence": 99, + "phase": "closure", + "is_closed": True, + } + ) + ) + + # Create an spp.program (needed for spp.cycle which is needed for spp.entitlement) + cls.program = ( + cls.env["spp.program"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Test Program", + } + ) + ) + + # Create an spp.cycle linked to the program + cls.cycle = ( + cls.env["spp.cycle"] + .with_context(tracking_disable=True) + .create( + { + "name": "CE Test Cycle", + "program_id": cls.program.id, + "start_date": fields.Date.today(), + "end_date": fields.Date.today(), + } + ) + ) + + # Create test case linked to the registrant (so entitlements can be matched by partner) + cls.case = ( + cls.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.registrant.id, + "case_worker_id": cls.case_worker.id, + "stage_id": cls.stage_intake.id, + "presenting_issue": "

CE test presenting issue

", + } + ) + ) + + # Create approved entitlement for registrant + cls.entitlement_approved = ( + cls.env["spp.entitlement"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": cls.registrant.id, + "cycle_id": cls.cycle.id, + "initial_amount": 500.0, + "state": "approved", + "is_cash_entitlement": True, + } + ) + ) + + # Create pending entitlement for registrant + cls.entitlement_pending = ( + cls.env["spp.entitlement"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": cls.registrant.id, + "cycle_id": cls.cycle.id, + "initial_amount": 300.0, + "state": "pending_validation", + "is_cash_entitlement": True, + } + ) + ) + + # Create draft entitlement for registrant + cls.entitlement_draft = ( + cls.env["spp.entitlement"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": cls.registrant.id, + "cycle_id": cls.cycle.id, + "initial_amount": 200.0, + "state": "draft", + "is_cash_entitlement": True, + } + ) + ) + + # ------------------------------------------------------------------ + # Computed field: _compute_entitlement_info + # ------------------------------------------------------------------ + + def test_entitlement_count_with_no_entitlements(self): + """A case with no entitlements should have count = 0 and has_entitlements = False.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Empty entitlements case

", + } + ) + ) + case._compute_entitlement_info() + + self.assertEqual(case.entitlement_count, 0, "Empty case should have entitlement_count 0") + self.assertFalse(case.has_entitlements, "Empty case should have has_entitlements False") + self.assertEqual(case.approved_entitlement_count, 0) + self.assertEqual(case.pending_entitlement_count, 0) + self.assertEqual(case.total_entitlement_amount, 0.0) + + def test_entitlement_count_with_entitlements(self): + """A case with linked entitlements should reflect the correct counts.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + Command.link(self.entitlement_draft.id), + ] + } + ) + self.case._compute_entitlement_info() + + self.assertEqual(self.case.entitlement_count, 3, "Should have 3 entitlements") + self.assertTrue(self.case.has_entitlements, "has_entitlements should be True") + + def test_approved_entitlement_count(self): + """approved_entitlement_count counts only entitlements with state 'approved'.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + Command.link(self.entitlement_draft.id), + ] + } + ) + self.case._compute_entitlement_info() + + self.assertEqual(self.case.approved_entitlement_count, 1) + + def test_pending_entitlement_count(self): + """pending_entitlement_count counts only entitlements with state 'pending_validation'.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + Command.link(self.entitlement_draft.id), + ] + } + ) + self.case._compute_entitlement_info() + + self.assertEqual(self.case.pending_entitlement_count, 1) + + def test_total_entitlement_amount_only_approved(self): + """total_entitlement_amount sums only approved entitlement initial_amount values.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + Command.link(self.entitlement_draft.id), + ] + } + ) + self.case._compute_entitlement_info() + + # Only the approved entitlement's amount (500.0) should be summed + self.assertEqual( + self.case.total_entitlement_amount, + 500.0, + "Total amount should sum only approved entitlements", + ) + + def test_total_entitlement_amount_multiple_approved(self): + """total_entitlement_amount sums all approved entitlements when more than one.""" + second_approved = ( + self.env["spp.entitlement"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": self.registrant.id, + "cycle_id": self.cycle.id, + "initial_amount": 250.0, + "state": "approved", + "is_cash_entitlement": True, + } + ) + ) + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(second_approved.id), + ] + } + ) + self.case._compute_entitlement_info() + + self.assertEqual( + self.case.total_entitlement_amount, + 750.0, + "Total amount should be sum of both approved entitlements", + ) + + def test_has_entitlements_true(self): + """has_entitlements is True when entitlement_ids is non-empty.""" + self.case.write({"entitlement_ids": [Command.link(self.entitlement_approved.id)]}) + self.case._compute_entitlement_info() + self.assertTrue(self.case.has_entitlements) + + # ------------------------------------------------------------------ + # Onchange: _onchange_partner_entitlements + # ------------------------------------------------------------------ + + def test_onchange_partner_loads_entitlements(self): + """Setting partner_id via onchange loads all partner entitlements.""" + case = self.env["spp.case"].new( + { + "case_type_id": self.case_type.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Onchange test

", + } + ) + + # Trigger the onchange by setting partner_id + case.partner_id = self.registrant + case._onchange_partner_entitlements() + + expected_ids = set(self.env["spp.entitlement"].search([("partner_id", "=", self.registrant.id)]).ids) + actual_ids = set(case.entitlement_ids.ids) + self.assertEqual( + actual_ids, + expected_ids, + "Onchange should load all entitlements for the selected partner", + ) + + def test_onchange_partner_clears_entitlements_when_none(self): + """Clearing partner_id via onchange should clear entitlement_ids.""" + case = self.env["spp.case"].new( + { + "case_type_id": self.case_type.id, + "partner_id": self.registrant.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Onchange clear test

", + } + ) + + # Clear partner then trigger onchange + case.partner_id = False + case._onchange_partner_entitlements() + + self.assertFalse( + case.entitlement_ids, + "Entitlements should be cleared when partner is removed", + ) + + def test_onchange_partner_no_entitlements_for_partner(self): + """Onchange when partner has no entitlements results in empty entitlement_ids.""" + case = self.env["spp.case"].new( + { + "case_type_id": self.case_type.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

No entitlements partner test

", + } + ) + + # registrant2 has no entitlements created in setUpClass + case.partner_id = self.registrant2 + case._onchange_partner_entitlements() + + self.assertFalse( + case.entitlement_ids, + "Entitlements should be empty for a partner with no entitlements", + ) + + # ------------------------------------------------------------------ + # Action: action_view_entitlements + # ------------------------------------------------------------------ + + def test_action_view_entitlements_returns_correct_action(self): + """action_view_entitlements returns an act_window action for spp.entitlement.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + ] + } + ) + + action = self.case.action_view_entitlements() + + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.entitlement") + self.assertIn("list", action["view_mode"]) + self.assertIn("form", action["view_mode"]) + self.assertEqual(action["name"], "Entitlements") + + def test_action_view_entitlements_domain_contains_linked_ids(self): + """action_view_entitlements domain limits to linked entitlement IDs.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + ] + } + ) + + action = self.case.action_view_entitlements() + domain_ids = action["domain"][0][2] + + self.assertIn(self.entitlement_approved.id, domain_ids) + self.assertIn(self.entitlement_pending.id, domain_ids) + self.assertNotIn(self.entitlement_draft.id, domain_ids) + + def test_action_view_entitlements_context_default_partner(self): + """action_view_entitlements sets default_partner_id in context.""" + action = self.case.action_view_entitlements() + + self.assertEqual( + action["context"]["default_partner_id"], + self.case.partner_id.id, + "Context should carry default_partner_id from the case", + ) + + def test_action_view_entitlements_context_create_false(self): + """action_view_entitlements disables record creation from the window.""" + action = self.case.action_view_entitlements() + + self.assertFalse( + action["context"].get("create"), + "Context 'create' flag should be False", + ) + + def test_action_view_entitlements_ensure_one(self): + """action_view_entitlements raises ValueError when called on an empty recordset.""" + empty = self.env["spp.case"].browse([]) + with self.assertRaises(ValueError): + empty.action_view_entitlements() + + # ------------------------------------------------------------------ + # Action: action_view_approved_entitlements + # ------------------------------------------------------------------ + + def test_action_view_approved_entitlements_returns_correct_action(self): + """action_view_approved_entitlements returns an act_window for spp.entitlement.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + Command.link(self.entitlement_draft.id), + ] + } + ) + + action = self.case.action_view_approved_entitlements() + + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.entitlement") + self.assertEqual(action["name"], "Approved Entitlements") + + def test_action_view_approved_entitlements_domain_approved_only(self): + """action_view_approved_entitlements domain contains only approved IDs.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + Command.link(self.entitlement_draft.id), + ] + } + ) + + action = self.case.action_view_approved_entitlements() + domain_ids = action["domain"][0][2] + + self.assertIn(self.entitlement_approved.id, domain_ids) + self.assertNotIn(self.entitlement_pending.id, domain_ids) + self.assertNotIn(self.entitlement_draft.id, domain_ids) + + def test_action_view_approved_entitlements_context_create_false(self): + """action_view_approved_entitlements disables record creation.""" + action = self.case.action_view_approved_entitlements() + + self.assertFalse(action["context"].get("create")) + + def test_action_view_approved_entitlements_context_default_partner(self): + """action_view_approved_entitlements sets default_partner_id in context.""" + action = self.case.action_view_approved_entitlements() + + self.assertEqual( + action["context"]["default_partner_id"], + self.case.partner_id.id, + ) + + def test_action_view_approved_entitlements_ensure_one(self): + """action_view_approved_entitlements raises ValueError on empty recordset.""" + empty = self.env["spp.case"].browse([]) + with self.assertRaises(ValueError): + empty.action_view_approved_entitlements() + + def test_action_view_approved_entitlements_empty_when_none_approved(self): + """action_view_approved_entitlements returns empty domain list when no approved.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

No approved entitlements

", + } + ) + ) + case.write({"entitlement_ids": [Command.link(self.entitlement_pending.id)]}) + + action = case.action_view_approved_entitlements() + domain_ids = action["domain"][0][2] + + self.assertEqual(domain_ids, [], "Domain IDs should be empty when no approved entitlements") + + # ------------------------------------------------------------------ + # Action: action_view_pending_entitlements + # ------------------------------------------------------------------ + + def test_action_view_pending_entitlements_returns_correct_action(self): + """action_view_pending_entitlements returns an act_window for spp.entitlement.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + Command.link(self.entitlement_draft.id), + ] + } + ) + + action = self.case.action_view_pending_entitlements() + + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.entitlement") + self.assertEqual(action["name"], "Pending Entitlements") + + def test_action_view_pending_entitlements_domain_pending_only(self): + """action_view_pending_entitlements domain contains only pending_validation IDs.""" + self.case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + Command.link(self.entitlement_draft.id), + ] + } + ) + + action = self.case.action_view_pending_entitlements() + domain_ids = action["domain"][0][2] + + self.assertIn(self.entitlement_pending.id, domain_ids) + self.assertNotIn(self.entitlement_approved.id, domain_ids) + self.assertNotIn(self.entitlement_draft.id, domain_ids) + + def test_action_view_pending_entitlements_context_create_false(self): + """action_view_pending_entitlements disables record creation.""" + action = self.case.action_view_pending_entitlements() + self.assertFalse(action["context"].get("create")) + + def test_action_view_pending_entitlements_context_default_partner(self): + """action_view_pending_entitlements sets default_partner_id in context.""" + action = self.case.action_view_pending_entitlements() + + self.assertEqual( + action["context"]["default_partner_id"], + self.case.partner_id.id, + ) + + def test_action_view_pending_entitlements_ensure_one(self): + """action_view_pending_entitlements raises ValueError on empty recordset.""" + empty = self.env["spp.case"].browse([]) + with self.assertRaises(ValueError): + empty.action_view_pending_entitlements() + + def test_action_view_pending_entitlements_empty_when_none_pending(self): + """action_view_pending_entitlements returns empty domain list when no pending.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

No pending entitlements

", + } + ) + ) + case.write({"entitlement_ids": [Command.link(self.entitlement_approved.id)]}) + + action = case.action_view_pending_entitlements() + domain_ids = action["domain"][0][2] + + self.assertEqual(domain_ids, [], "Domain IDs should be empty when no pending entitlements") + + # ------------------------------------------------------------------ + # currency_id default + # ------------------------------------------------------------------ + + def test_currency_id_defaults_to_company_currency(self): + """currency_id should default to the company currency.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Currency test

", + } + ) + ) + + self.assertEqual( + case.currency_id, + self.env.company.currency_id, + "currency_id should default to the active company currency", + ) + + # ------------------------------------------------------------------ + # Integration: linking entitlements and verifying computed fields + # ------------------------------------------------------------------ + + def test_linking_entitlements_updates_computed_fields(self): + """Linking entitlements to a case triggers recompute of entitlement info fields.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Integration test

", + } + ) + ) + + # Initially no entitlements + self.assertEqual(case.entitlement_count, 0) + self.assertFalse(case.has_entitlements) + + # Link entitlements + case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + ] + } + ) + + self.assertEqual(case.entitlement_count, 2) + self.assertTrue(case.has_entitlements) + self.assertEqual(case.approved_entitlement_count, 1) + self.assertEqual(case.pending_entitlement_count, 1) + self.assertEqual(case.total_entitlement_amount, 500.0) + + def test_removing_entitlement_updates_counts(self): + """Unlinking an entitlement from a case decrements the computed counts.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Remove entitlement test

", + } + ) + ) + case.write( + { + "entitlement_ids": [ + Command.link(self.entitlement_approved.id), + Command.link(self.entitlement_pending.id), + ] + } + ) + self.assertEqual(case.entitlement_count, 2) + + # Now unlink one + case.write({"entitlement_ids": [Command.unlink(self.entitlement_approved.id)]}) + + self.assertEqual(case.entitlement_count, 1) + self.assertEqual(case.approved_entitlement_count, 0) + self.assertEqual(case.total_entitlement_amount, 0.0) + + def test_entitlement_state_change_updates_approved_count(self): + """Changing an entitlement's state updates approved/pending counts on the case.""" + # Create a local entitlement in pending state + ent = ( + self.env["spp.entitlement"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": self.registrant.id, + "cycle_id": self.cycle.id, + "initial_amount": 100.0, + "state": "pending_validation", + "is_cash_entitlement": True, + } + ) + ) + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.registrant.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

State change test

", + "entitlement_ids": [Command.link(ent.id)], + } + ) + ) + + self.assertEqual(case.pending_entitlement_count, 1) + self.assertEqual(case.approved_entitlement_count, 0) + + # Approve the entitlement directly (bypass state machine for test isolation) + ent.write({"state": "approved"}) + + self.assertEqual(case.pending_entitlement_count, 0) + self.assertEqual(case.approved_entitlement_count, 1) + self.assertEqual(case.total_entitlement_amount, 100.0) + + def test_multiple_cases_share_entitlement(self): + """The same entitlement can be linked to multiple cases (M2M relationship).""" + case_a = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Case A

", + "entitlement_ids": [Command.link(self.entitlement_approved.id)], + } + ) + ) + case_b = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "stage_id": self.stage_intake.id, + "presenting_issue": "

Case B

", + "entitlement_ids": [Command.link(self.entitlement_approved.id)], + } + ) + ) + + self.assertIn(self.entitlement_approved, case_a.entitlement_ids) + self.assertIn(self.entitlement_approved, case_b.entitlement_ids) diff --git a/spp_case_entitlements/views/case_views.xml b/spp_case_entitlements/views/case_views.xml new file mode 100644 index 00000000..dcf91db3 --- /dev/null +++ b/spp_case_entitlements/views/case_views.xml @@ -0,0 +1,199 @@ + + + + + spp.case.form.entitlements + spp.case + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + res.partner.search.case + res.partner + + + + + + diff --git a/spp_case_session/README.rst b/spp_case_session/README.rst new file mode 100644 index 00000000..179f6f22 --- /dev/null +++ b/spp_case_session/README.rst @@ -0,0 +1,132 @@ +============================================ +OpenSPP Case Management: Session Integration +============================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b5dc605cadcb07416987b256dea88a9a7921454289d351f5a15f138b0a6edc2c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_case_session + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Links cases to required training or group sessions and tracks client +attendance. Computes attendance rates and compliance status based on +configurable thresholds. Enables case managers to monitor whether case +clients meet session attendance requirements. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Link cases to required sessions via many-to-many relationship +- Track attendance records for case clients at linked sessions +- Calculate attendance rate as percentage of required sessions attended +- Classify compliance: compliant (≥80%), partial (≥50%), non-compliant + (<50%), or N/A +- Navigate between cases and sessions via stat buttons + +Key Models +~~~~~~~~~~ + +=============== =========================================== +Model Description +=============== =========================================== +``spp.case`` Extended with session links and compliance +``spp.session`` Extended with reverse relationship to cases +=============== =========================================== + +Configuration +~~~~~~~~~~~~~ + +No configuration required after installation. The module automatically +extends existing case and session forms with session tracking fields and +navigation. + +UI Location +~~~~~~~~~~~ + +- **Case Form**: "Sessions" tab displays linked sessions, attendance + records, compliance badge, and attendance rate progress bar +- **Case Form**: Stat button opens list of linked sessions +- **Session Form**: Stat button opens list of related cases + +No standalone menus are defined. All features are accessed via existing +case and session forms. + +Security +~~~~~~~~ + +This module defines no new models or access control records. Security is +inherited from ``spp_case_base`` and ``spp_session_tracking``. Users +with access to cases and sessions can view and manage session links. + +Extension Points +~~~~~~~~~~~~~~~~ + +- Override ``_compute_session_stats()`` on ``spp.case`` to customize + compliance thresholds (default: 80% compliant, 50% partial) +- Override ``_compute_session_attendance()`` on ``spp.case`` to filter + which attendance records are included in calculations +- Inherit ``spp.case`` to add domain-specific session tracking fields or + workflows + +Dependencies +~~~~~~~~~~~~ + +``spp_security``, ``spp_case_base``, ``spp_session_tracking`` + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 +.. |maintainer-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-emjay0921| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_case_session/__init__.py b/spp_case_session/__init__.py new file mode 100644 index 00000000..c4ccea79 --- /dev/null +++ b/spp_case_session/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/spp_case_session/__manifest__.py b/spp_case_session/__manifest__.py new file mode 100644 index 00000000..169782ed --- /dev/null +++ b/spp_case_session/__manifest__.py @@ -0,0 +1,27 @@ +# pylint: disable=pointless-statement +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +{ + "name": "OpenSPP Case Management: Session Integration", + "summary": "Link sessions and training attendance to cases", + "version": "19.0.1.0.0", + "license": "LGPL-3", + "development_status": "Production/Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "category": "OpenSPP/Monitoring", + "depends": [ + "spp_security", + "spp_case_base", + "spp_session_tracking", + ], + "data": [ + "security/ir.model.access.csv", + "views/case_views.xml", + "views/session_views.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/spp_case_session/models/__init__.py b/spp_case_session/models/__init__.py new file mode 100644 index 00000000..01dac059 --- /dev/null +++ b/spp_case_session/models/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import case +from . import session diff --git a/spp_case_session/models/case.py b/spp_case_session/models/case.py new file mode 100644 index 00000000..c9b9cb10 --- /dev/null +++ b/spp_case_session/models/case.py @@ -0,0 +1,118 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Case Extension for Session Integration. + +Links cases to session attendance for tracking compliance +with required training or group sessions. +""" + +from odoo import api, fields, models + + +class Case(models.Model): + """Extension of spp.case to include session tracking.""" + + _inherit = "spp.case" + + session_ids = fields.Many2many( + comodel_name="spp.session", + relation="case_session_rel", + column1="case_id", + column2="session_id", + string="Required Sessions", + help="Sessions the client is expected to attend as part of this case", + ) + + session_attendance_ids = fields.One2many( + comodel_name="spp.session.attendance", + compute="_compute_session_attendance", + string="Session Attendance", + ) + + session_count = fields.Integer( + compute="_compute_session_stats", + ) + + session_attendance_rate = fields.Float( + compute="_compute_session_stats", + string="Attendance Rate (%)", + help="Percentage of expected sessions attended", + ) + + session_compliance = fields.Selection( + selection=[ + ("compliant", "Compliant"), + ("partial", "Partial"), + ("non_compliant", "Non-Compliant"), + ("not_applicable", "N/A"), + ], + compute="_compute_session_stats", + ) + + @api.depends("partner_id", "session_ids") + def _compute_session_attendance(self): + """Get attendance records for the case client in linked sessions.""" + Attendance = self.env["spp.session.attendance"] + for case in self: + if case.partner_id and case.session_ids: + case.session_attendance_ids = Attendance.search( + [ + ("participant_id", "=", case.partner_id.id), + ("session_id", "in", case.session_ids.ids), + ] + ) + else: + case.session_attendance_ids = Attendance + + @api.depends("session_ids", "partner_id") + def _compute_session_stats(self): + """Compute session attendance statistics.""" + Attendance = self.env["spp.session.attendance"] + for case in self: + if not case.session_ids: + case.session_count = 0 + case.session_attendance_rate = 0.0 + case.session_compliance = "not_applicable" + continue + + case.session_count = len(case.session_ids) + + # Get attendance for this client in required sessions + attendance = Attendance.search( + [ + ("participant_id", "=", case.partner_id.id), + ("session_id", "in", case.session_ids.ids), + ] + ) + + attended = len(attendance.filtered(lambda a: a.is_attended)) + total = len(case.session_ids) + + if total > 0: + rate = (attended / total) * 100 + case.session_attendance_rate = rate + + if rate >= 80: + case.session_compliance = "compliant" + elif rate >= 50: + case.session_compliance = "partial" + else: + case.session_compliance = "non_compliant" + else: + case.session_attendance_rate = 0.0 + case.session_compliance = "not_applicable" + + def action_view_sessions(self): + """Open sessions linked to this case.""" + self.ensure_one() + return { + "name": "Case Sessions", + "type": "ir.actions.act_window", + "res_model": "spp.session", + "view_mode": "list,form", + "domain": [("id", "in", self.session_ids.ids)], + "context": { + "default_case_ids": [fields.Command.link(self.id)], + "default_expected_participant_ids": [fields.Command.link(self.partner_id.id)], + }, + } diff --git a/spp_case_session/models/session.py b/spp_case_session/models/session.py new file mode 100644 index 00000000..dd00c2b8 --- /dev/null +++ b/spp_case_session/models/session.py @@ -0,0 +1,43 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Session Extension for Case Integration. + +Links sessions back to cases for reference. +""" + +from odoo import fields, models + + +class Session(models.Model): + """Extension of spp.session to link back to cases.""" + + _inherit = "spp.session" + + case_ids = fields.Many2many( + comodel_name="spp.case", + relation="case_session_rel", + column1="session_id", + column2="case_id", + string="Related Cases", + help="Cases that require attendance at this session", + ) + + case_count = fields.Integer( + compute="_compute_case_count", + ) + + def _compute_case_count(self): + """Count related cases.""" + for session in self: + session.case_count = len(session.case_ids) + + def action_view_cases(self): + """Open cases linked to this session.""" + self.ensure_one() + return { + "name": "Related Cases", + "type": "ir.actions.act_window", + "res_model": "spp.case", + "view_mode": "list,form", + "domain": [("id", "in", self.case_ids.ids)], + } diff --git a/spp_case_session/pyproject.toml b/spp_case_session/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_case_session/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_case_session/readme/DESCRIPTION.md b/spp_case_session/readme/DESCRIPTION.md new file mode 100644 index 00000000..22ca9f78 --- /dev/null +++ b/spp_case_session/readme/DESCRIPTION.md @@ -0,0 +1,42 @@ +Links cases to required training or group sessions and tracks client attendance. Computes attendance rates and compliance status based on configurable thresholds. Enables case managers to monitor whether case clients meet session attendance requirements. + +### Key Capabilities + +- Link cases to required sessions via many-to-many relationship +- Track attendance records for case clients at linked sessions +- Calculate attendance rate as percentage of required sessions attended +- Classify compliance: compliant (≥80%), partial (≥50%), non-compliant (<50%), or N/A +- Navigate between cases and sessions via stat buttons + +### Key Models + +| Model | Description | +| ------------- | ---------------------------------------------- | +| `spp.case` | Extended with session links and compliance | +| `spp.session` | Extended with reverse relationship to cases | + +### Configuration + +No configuration required after installation. The module automatically extends existing case and session forms with session tracking fields and navigation. + +### UI Location + +- **Case Form**: "Sessions" tab displays linked sessions, attendance records, compliance badge, and attendance rate progress bar +- **Case Form**: Stat button opens list of linked sessions +- **Session Form**: Stat button opens list of related cases + +No standalone menus are defined. All features are accessed via existing case and session forms. + +### Security + +This module defines no new models or access control records. Security is inherited from `spp_case_base` and `spp_session_tracking`. Users with access to cases and sessions can view and manage session links. + +### Extension Points + +- Override `_compute_session_stats()` on `spp.case` to customize compliance thresholds (default: 80% compliant, 50% partial) +- Override `_compute_session_attendance()` on `spp.case` to filter which attendance records are included in calculations +- Inherit `spp.case` to add domain-specific session tracking fields or workflows + +### Dependencies + +`spp_security`, `spp_case_base`, `spp_session_tracking` diff --git a/spp_case_session/security/ir.model.access.csv b/spp_case_session/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/spp_case_session/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/spp_case_session/static/description/icon.png b/spp_case_session/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_case_session/static/description/icon.png differ diff --git a/spp_case_session/static/description/index.html b/spp_case_session/static/description/index.html new file mode 100644 index 00000000..d7a663d7 --- /dev/null +++ b/spp_case_session/static/description/index.html @@ -0,0 +1,485 @@ + + + + + +OpenSPP Case Management: Session Integration + + + +
+

OpenSPP Case Management: Session Integration

+ + +

Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

+

Links cases to required training or group sessions and tracks client +attendance. Computes attendance rates and compliance status based on +configurable thresholds. Enables case managers to monitor whether case +clients meet session attendance requirements.

+
+

Key Capabilities

+
    +
  • Link cases to required sessions via many-to-many relationship
  • +
  • Track attendance records for case clients at linked sessions
  • +
  • Calculate attendance rate as percentage of required sessions attended
  • +
  • Classify compliance: compliant (≥80%), partial (≥50%), non-compliant +(<50%), or N/A
  • +
  • Navigate between cases and sessions via stat buttons
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + +
ModelDescription
spp.caseExtended with session links and compliance
spp.sessionExtended with reverse relationship to cases
+
+
+

Configuration

+

No configuration required after installation. The module automatically +extends existing case and session forms with session tracking fields and +navigation.

+
+
+

UI Location

+
    +
  • Case Form: “Sessions” tab displays linked sessions, attendance +records, compliance badge, and attendance rate progress bar
  • +
  • Case Form: Stat button opens list of linked sessions
  • +
  • Session Form: Stat button opens list of related cases
  • +
+

No standalone menus are defined. All features are accessed via existing +case and session forms.

+
+
+

Security

+

This module defines no new models or access control records. Security is +inherited from spp_case_base and spp_session_tracking. Users +with access to cases and sessions can view and manage session links.

+
+
+

Extension Points

+
    +
  • Override _compute_session_stats() on spp.case to customize +compliance thresholds (default: 80% compliant, 50% partial)
  • +
  • Override _compute_session_attendance() on spp.case to filter +which attendance records are included in calculations
  • +
  • Inherit spp.case to add domain-specific session tracking fields or +workflows
  • +
+
+
+

Dependencies

+

spp_security, spp_case_base, spp_session_tracking

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123 emjay0921

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_case_session/tests/__init__.py b/spp_case_session/tests/__init__.py new file mode 100644 index 00000000..a7b6235f --- /dev/null +++ b/spp_case_session/tests/__init__.py @@ -0,0 +1,6 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Tests for spp_case_session module.""" + +from . import test_case_session +from . import test_session_case diff --git a/spp_case_session/tests/test_case_session.py b/spp_case_session/tests/test_case_session.py new file mode 100644 index 00000000..c1bbf297 --- /dev/null +++ b/spp_case_session/tests/test_case_session.py @@ -0,0 +1,731 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Tests for spp.case session integration (case.py extension).""" + +from odoo import Command, fields +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCaseSessionIntegration(TransactionCase): + """Test the spp.case session-related fields and methods.""" + + @classmethod + def setUpClass(cls): + """Set up shared test data.""" + super().setUpClass() + + # Case worker user + cls.case_worker = ( + cls.env["res.users"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session Test Worker", + "login": "session_test_worker", + "email": "session_worker@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + ) + + # Client partner (the case subject) + cls.client = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session Test Client", + "email": "session_client@test.com", + } + ) + ) + + # Another partner (used to check attendance filtering) + cls.other_partner = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "Other Partner", + "email": "other@test.com", + } + ) + ) + + # Case type + cls.case_type = ( + cls.env["spp.case.type"] + .with_context(tracking_disable=True) + .create( + { + "name": "Training Case", + "code": "TRN001", + "domain": "social_protection", + } + ) + ) + + # Session type (required by spp.session) + cls.session_type = ( + cls.env["spp.session.type"] + .with_context(tracking_disable=True) + .create( + { + "name": "Group Training", + "code": "GT001", + } + ) + ) + + # Case (no sessions linked initially) + cls.case = ( + cls.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client.id, + "case_worker_id": cls.case_worker.id, + "presenting_issue": "

Needs training sessions

", + } + ) + ) + + # Sessions + cls.session1 = ( + cls.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session 1", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.case_worker.id, + } + ) + ) + + cls.session2 = ( + cls.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session 2", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.case_worker.id, + } + ) + ) + + cls.session3 = ( + cls.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session 3", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.case_worker.id, + } + ) + ) + + # ------------------------------------------------------------------------- + # _compute_session_attendance + # ------------------------------------------------------------------------- + + def test_session_attendance_empty_when_no_sessions(self): + """When no sessions are linked, session_attendance_ids is empty.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

No sessions yet

", + } + ) + ) + self.assertFalse( + case.session_attendance_ids, + "session_attendance_ids should be empty when no sessions are linked", + ) + + def test_session_attendance_empty_when_no_partner(self): + """When partner_id is not set, session_attendance_ids falls back to empty.""" + # Use .new() so partner_id can be unset + case_obj = self.env["spp.case"].new( + { + "case_type_id": self.case_type.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

No partner

", + "session_ids": [Command.link(self.session1.id)], + } + ) + case_obj._compute_session_attendance() + self.assertFalse( + case_obj.session_attendance_ids, + "session_attendance_ids must be empty when partner_id is not set", + ) + + def test_session_attendance_returns_matching_records(self): + """Attendance records for the case client in linked sessions are returned.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Attended sessions

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + ], + } + ) + ) + + # Create attendance record for the case client in session1 + att1 = ( + self.env["spp.session.attendance"] + .with_context(tracking_disable=True) + .create( + { + "session_id": self.session1.id, + "participant_id": self.client.id, + "is_attended": True, + } + ) + ) + + # Create attendance for another partner (should NOT appear) + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": self.session1.id, + "participant_id": self.other_partner.id, + "is_attended": True, + } + ) + + case._compute_session_attendance() + + self.assertIn( + att1, + case.session_attendance_ids, + "Attendance for the case client should be included", + ) + self.assertEqual( + len(case.session_attendance_ids), + 1, + "Only attendance for the case client should be returned", + ) + + def test_session_attendance_filters_by_linked_sessions_only(self): + """Attendance in sessions not linked to this case is excluded.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Filtered sessions

", + "session_ids": [Command.link(self.session1.id)], + } + ) + ) + + # Attendance in the linked session + att_linked = ( + self.env["spp.session.attendance"] + .with_context(tracking_disable=True) + .create( + { + "session_id": self.session1.id, + "participant_id": self.client.id, + "is_attended": True, + } + ) + ) + + # Attendance in an unlinked session (session2 not in case) + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": self.session2.id, + "participant_id": self.client.id, + "is_attended": True, + } + ) + + case._compute_session_attendance() + + self.assertIn(att_linked, case.session_attendance_ids) + self.assertEqual( + len(case.session_attendance_ids), + 1, + "Only attendance records from linked sessions should be returned", + ) + + # ------------------------------------------------------------------------- + # _compute_session_stats — no sessions branch + # ------------------------------------------------------------------------- + + def test_session_stats_no_sessions(self): + """When no sessions are linked, stats default to zero / not_applicable.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

No sessions

", + } + ) + ) + case._compute_session_stats() + + self.assertEqual(case.session_count, 0, "session_count should be 0 with no sessions") + self.assertAlmostEqual( + case.session_attendance_rate, + 0.0, + places=2, + msg="session_attendance_rate should be 0.0 with no sessions", + ) + self.assertEqual( + case.session_compliance, + "not_applicable", + "session_compliance should be 'not_applicable' with no sessions", + ) + + # ------------------------------------------------------------------------- + # _compute_session_stats — compliant branch (rate >= 80) + # ------------------------------------------------------------------------- + + def test_session_stats_compliant(self): + """Attendance >= 80% yields 'compliant' status.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Compliant client

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + Command.link(self.session3.id), + ], + } + ) + ) + + # Attend all 3 out of 3 → 100% → compliant + for session in [self.session1, self.session2, self.session3]: + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": session.id, + "participant_id": self.client.id, + "is_attended": True, + } + ) + + case._compute_session_stats() + + self.assertEqual(case.session_count, 3) + self.assertAlmostEqual(case.session_attendance_rate, 100.0, places=2) + self.assertEqual(case.session_compliance, "compliant") + + def test_session_stats_compliant_at_boundary(self): + """Exactly 80% attendance gives 'compliant'.""" + # 4 sessions, attend 4 → need 80% exactly from 5 sessions + session4 = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session 4", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.case_worker.id, + } + ) + ) + session5 = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session 5", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.case_worker.id, + } + ) + ) + + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Boundary client

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + Command.link(self.session3.id), + Command.link(session4.id), + Command.link(session5.id), + ], + } + ) + ) + + # Attend 4 out of 5 → 80% exactly + for session in [self.session1, self.session2, self.session3, session4]: + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": session.id, + "participant_id": self.client.id, + "is_attended": True, + } + ) + + case._compute_session_stats() + + self.assertAlmostEqual(case.session_attendance_rate, 80.0, places=2) + self.assertEqual(case.session_compliance, "compliant") + + # ------------------------------------------------------------------------- + # _compute_session_stats — partial branch (50 <= rate < 80) + # ------------------------------------------------------------------------- + + def test_session_stats_partial(self): + """50-79% attendance yields 'partial' compliance.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Partial client

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + ], + } + ) + ) + + # Attend 1 out of 2 → 50% → partial + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": self.session1.id, + "participant_id": self.client.id, + "is_attended": True, + } + ) + + case._compute_session_stats() + + self.assertAlmostEqual(case.session_attendance_rate, 50.0, places=2) + self.assertEqual(case.session_compliance, "partial") + + def test_session_stats_partial_at_boundary(self): + """Exactly 50% attendance gives 'partial'.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Partial boundary client

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + Command.link(self.session3.id), + Command.link( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session Extra", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.case_worker.id, + } + ) + .id + ), + ], + } + ) + ) + + # Attend exactly 2 out of 4 = 50% + for session in [self.session1, self.session2]: + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": session.id, + "participant_id": self.client.id, + "is_attended": True, + } + ) + + case._compute_session_stats() + + self.assertAlmostEqual(case.session_attendance_rate, 50.0, places=2) + self.assertEqual(case.session_compliance, "partial") + + # ------------------------------------------------------------------------- + # _compute_session_stats — non_compliant branch (rate < 50) + # ------------------------------------------------------------------------- + + def test_session_stats_non_compliant(self): + """Below 50% attendance yields 'non_compliant' status.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Non-compliant client

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + Command.link(self.session3.id), + ], + } + ) + ) + + # Attend 0 out of 3 → 0% → non_compliant + case._compute_session_stats() + + self.assertAlmostEqual(case.session_attendance_rate, 0.0, places=2) + self.assertEqual(case.session_compliance, "non_compliant") + + def test_session_stats_non_compliant_one_of_three(self): + """33% attendance yields 'non_compliant'.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

One-third attendance

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + Command.link(self.session3.id), + ], + } + ) + ) + + # Attend 1 out of 3 → ~33% → non_compliant + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": self.session1.id, + "participant_id": self.client.id, + "is_attended": True, + } + ) + + case._compute_session_stats() + + self.assertAlmostEqual( + case.session_attendance_rate, + 33.33, + places=1, + ) + self.assertEqual(case.session_compliance, "non_compliant") + + def test_session_stats_unattended_records_not_counted(self): + """Attendance records with is_attended=False do not count towards rate.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Absent client

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + ], + } + ) + ) + + # Create attendance records marked as NOT attended + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": self.session1.id, + "participant_id": self.client.id, + "is_attended": False, + } + ) + self.env["spp.session.attendance"].with_context(tracking_disable=True).create( + { + "session_id": self.session2.id, + "participant_id": self.client.id, + "is_attended": False, + } + ) + + case._compute_session_stats() + + self.assertAlmostEqual(case.session_attendance_rate, 0.0, places=2) + self.assertEqual(case.session_compliance, "non_compliant") + + def test_session_count_matches_linked_sessions(self): + """session_count reflects the number of sessions linked to the case.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Count test

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + ], + } + ) + ) + + case._compute_session_stats() + + self.assertEqual(case.session_count, 2) + + # ------------------------------------------------------------------------- + # action_view_sessions + # ------------------------------------------------------------------------- + + def test_action_view_sessions_returns_correct_structure(self): + """action_view_sessions returns a valid act_window action dict.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Action test

", + "session_ids": [Command.link(self.session1.id)], + } + ) + ) + + action = case.action_view_sessions() + + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.session") + self.assertIn("list", action["view_mode"]) + self.assertIn("form", action["view_mode"]) + + def test_action_view_sessions_domain_contains_session_ids(self): + """The action domain restricts to sessions linked to this case.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Domain test

", + "session_ids": [ + Command.link(self.session1.id), + Command.link(self.session2.id), + ], + } + ) + ) + + action = case.action_view_sessions() + + domain = action.get("domain", []) + # The domain should filter on ("id", "in", [session1.id, session2.id]) + self.assertTrue( + any( + len(clause) == 3 + and clause[0] == "id" + and clause[1] == "in" + and set(clause[2]) == {self.session1.id, self.session2.id} + for clause in domain + ), + "Domain should filter sessions by linked IDs", + ) + + def test_action_view_sessions_context_defaults(self): + """The action context sets default_case_ids and default_expected_participant_ids.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Context test

", + "session_ids": [Command.link(self.session1.id)], + } + ) + ) + + action = case.action_view_sessions() + ctx = action.get("context", {}) + + self.assertIn("default_case_ids", ctx) + self.assertIn("default_expected_participant_ids", ctx) + + def test_action_view_sessions_name(self): + """The action name is set to 'Case Sessions'.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": "

Name test

", + } + ) + ) + + action = case.action_view_sessions() + + self.assertEqual(action["name"], "Case Sessions") diff --git a/spp_case_session/tests/test_session_case.py b/spp_case_session/tests/test_session_case.py new file mode 100644 index 00000000..726c9343 --- /dev/null +++ b/spp_case_session/tests/test_session_case.py @@ -0,0 +1,445 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Tests for spp.session case integration (session.py extension).""" + +from odoo import Command, fields +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestSessionCaseIntegration(TransactionCase): + """Test the spp.session case-related fields and methods.""" + + @classmethod + def setUpClass(cls): + """Set up shared test data.""" + super().setUpClass() + + # Case worker (also acts as facilitator) + cls.facilitator = ( + cls.env["res.users"] + .with_context(tracking_disable=True) + .create( + { + "name": "Facilitator User", + "login": "facilitator_user", + "email": "facilitator@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + ) + + # Partner for the case client + cls.client = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "Case Client", + "email": "case_client@test.com", + } + ) + ) + + # Case type + cls.case_type = ( + cls.env["spp.case.type"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session Link Type", + "code": "SLT001", + "domain": "social_protection", + } + ) + ) + + # Session type + cls.session_type = ( + cls.env["spp.session.type"] + .with_context(tracking_disable=True) + .create( + { + "name": "Community Session", + "code": "CS001", + } + ) + ) + + # Base session (no cases linked initially) + cls.session = ( + cls.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Base Test Session", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.facilitator.id, + } + ) + ) + + # Cases + cls.case1 = ( + cls.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client.id, + "case_worker_id": cls.facilitator.id, + "presenting_issue": "

Case 1

", + } + ) + ) + + cls.case2 = ( + cls.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": cls.case_type.id, + "partner_id": cls.client.id, + "case_worker_id": cls.facilitator.id, + "presenting_issue": "

Case 2

", + } + ) + ) + + # ------------------------------------------------------------------------- + # case_ids Many2many field + # ------------------------------------------------------------------------- + + def test_case_ids_field_exists_and_writable(self): + """The case_ids M2M field can be written and read back correctly.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Linked Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "case_ids": [ + Command.link(self.case1.id), + Command.link(self.case2.id), + ], + } + ) + ) + + self.assertIn(self.case1, session.case_ids, "case1 should be linked to session") + self.assertIn(self.case2, session.case_ids, "case2 should be linked to session") + + def test_bidirectional_link_case_to_session(self): + """Linking a session to a case also makes the case appear on the session.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.facilitator.id, + "presenting_issue": "

Bidirectional test

", + "session_ids": [Command.link(self.session.id)], + } + ) + ) + + # The M2M relation is shared (case_session_rel), so the session + # must also see the case through case_ids + self.assertIn( + case, + self.session.case_ids, + "Linking session from case should also appear on session.case_ids", + ) + + # ------------------------------------------------------------------------- + # _compute_case_count + # ------------------------------------------------------------------------- + + def test_case_count_is_zero_with_no_cases(self): + """case_count is 0 when no cases are linked.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Empty Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + ) + session._compute_case_count() + + self.assertEqual(session.case_count, 0, "case_count should be 0 with no linked cases") + + def test_case_count_reflects_linked_cases(self): + """case_count matches the number of cases linked to the session.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Two Case Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "case_ids": [ + Command.link(self.case1.id), + Command.link(self.case2.id), + ], + } + ) + ) + session._compute_case_count() + + self.assertEqual(session.case_count, 2, "case_count should be 2 with two linked cases") + + def test_case_count_single_case(self): + """case_count is 1 when exactly one case is linked.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Single Case Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "case_ids": [Command.link(self.case1.id)], + } + ) + ) + session._compute_case_count() + + self.assertEqual(session.case_count, 1) + + def test_case_count_updates_after_unlinking(self): + """case_count decreases when a case is unlinked.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Unlink Test Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "case_ids": [ + Command.link(self.case1.id), + Command.link(self.case2.id), + ], + } + ) + ) + + # Unlink one case + session.write({"case_ids": [Command.unlink(self.case2.id)]}) + session._compute_case_count() + + self.assertEqual(session.case_count, 1, "case_count should decrease after unlinking") + + # ------------------------------------------------------------------------- + # action_view_cases + # ------------------------------------------------------------------------- + + def test_action_view_cases_returns_correct_structure(self): + """action_view_cases returns a valid act_window action dict.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Action View Cases Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "case_ids": [Command.link(self.case1.id)], + } + ) + ) + + action = session.action_view_cases() + + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.case") + self.assertIn("list", action["view_mode"]) + self.assertIn("form", action["view_mode"]) + + def test_action_view_cases_name(self): + """action_view_cases action name is 'Related Cases'.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Name Test Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + ) + + action = session.action_view_cases() + + self.assertEqual(action["name"], "Related Cases") + + def test_action_view_cases_domain_contains_case_ids(self): + """The action domain restricts to cases linked to this session.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Domain Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "case_ids": [ + Command.link(self.case1.id), + Command.link(self.case2.id), + ], + } + ) + ) + + action = session.action_view_cases() + domain = action.get("domain", []) + + self.assertTrue( + any( + len(clause) == 3 + and clause[0] == "id" + and clause[1] == "in" + and set(clause[2]) == {self.case1.id, self.case2.id} + for clause in domain + ), + "Domain should filter cases by linked IDs", + ) + + def test_action_view_cases_domain_empty_when_no_cases(self): + """Domain is [("id", "in", [])] when no cases are linked.""" + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "No Cases Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + ) + + action = session.action_view_cases() + domain = action.get("domain", []) + + self.assertTrue( + any(len(clause) == 3 and clause[0] == "id" and clause[1] == "in" and clause[2] == [] for clause in domain), + "Domain should have empty id list when no cases are linked", + ) + + # ------------------------------------------------------------------------- + # Many2many relation consistency + # ------------------------------------------------------------------------- + + def test_m2m_uses_shared_relation_table(self): + """Verify the M2M relation is consistent across both sides of the link.""" + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.facilitator.id, + "presenting_issue": "

Relation test

", + } + ) + ) + session = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Relation Test Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + ) + + # Link via the session side + session.write({"case_ids": [Command.link(case.id)]}) + + # Both sides should reflect the link + self.assertIn(case, session.case_ids) + self.assertIn(session, case.session_ids) + + def test_multiple_sessions_on_case_and_multiple_cases_on_session(self): + """A case can have multiple sessions; a session can have multiple cases.""" + session_a = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session A", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + ) + session_b = ( + self.env["spp.session"] + .with_context(tracking_disable=True) + .create( + { + "name": "Session B", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + ) + + case = ( + self.env["spp.case"] + .with_context(tracking_disable=True) + .create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.facilitator.id, + "presenting_issue": "

Multi-session case

", + "session_ids": [ + Command.link(session_a.id), + Command.link(session_b.id), + ], + } + ) + ) + + self.assertEqual(len(case.session_ids), 2) + + # Both sessions should see the case + self.assertIn(case, session_a.case_ids) + self.assertIn(case, session_b.case_ids) + + # Verify case counts + session_a._compute_case_count() + session_b._compute_case_count() + self.assertEqual(session_a.case_count, 1) + self.assertEqual(session_b.case_count, 1) diff --git a/spp_case_session/views/case_views.xml b/spp_case_session/views/case_views.xml new file mode 100644 index 00000000..36ff0548 --- /dev/null +++ b/spp_case_session/views/case_views.xml @@ -0,0 +1,49 @@ + + + + + spp.case.form.session + spp.case + + + + + + + + + + + + + + + + + + + diff --git a/spp_case_session/views/session_views.xml b/spp_case_session/views/session_views.xml new file mode 100644 index 00000000..3dd9db1b --- /dev/null +++ b/spp_case_session/views/session_views.xml @@ -0,0 +1,23 @@ + + + + + spp.session.form.case + spp.session + + + +
+ +
+
+
+
+
diff --git a/spp_gis_indicators/README.rst b/spp_gis_indicators/README.rst index 7c10fc49..308e7008 100644 --- a/spp_gis_indicators/README.rst +++ b/spp_gis_indicators/README.rst @@ -1,12 +1,8 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ====================== OpenSPP GIS Indicators ====================== -.. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! @@ -114,7 +110,6 @@ Dependencies .. IMPORTANT:: This is an alpha version, the data model and design can change at any time without warning. Only for development or testing purpose, do not use in production. - `More details on development status `_ **Table of contents** @@ -144,4 +139,4 @@ Maintainers This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. -You are welcome to contribute. +You are welcome to contribute. \ No newline at end of file diff --git a/spp_gis_indicators/static/description/index.html b/spp_gis_indicators/static/description/index.html index 1a8ae088..6bbb451c 100644 --- a/spp_gis_indicators/static/description/index.html +++ b/spp_gis_indicators/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +OpenSPP GIS Indicators -
+
+

OpenSPP GIS Indicators

- - -Odoo Community Association - -
-

OpenSPP GIS Indicators

+ + Standard Graduation + STANDARD + 10 + + + + 12 + Standard graduation pathway for beneficiaries who have met all program objectives and are ready to exit successfully. + + + + + Early Graduation + EARLY + 20 + + + + 18 + Early graduation pathway for beneficiaries who have exceeded program expectations and are ready to graduate ahead of schedule. + + + + + Administrative Exit + ADMIN_EXIT + 30 + + + + 0 + Administrative exit for beneficiaries who are removed from the program due to non-compliance, ineligibility, or other administrative reasons. + + + + + Income Above Poverty Line + INCOME_THRESHOLD + + 10 + 2.0 + verification + + Household income is sustainably above the poverty line for at least 6 months. + + + + Sustainable Livelihood Established + LIVELIHOOD + + 20 + 2.0 + verification + + Beneficiary has established a sustainable livelihood or income source. + + + + Active Savings Account + SAVINGS + + 30 + 1.0 + verification + + Beneficiary has an active savings account with regular deposits. + + + + School-Age Children Enrolled + SCHOOL_ENROLLMENT + + 40 + 1.5 + verification + + All school-age children in the household are enrolled and attending school regularly. + + + + Access to Health Services + HEALTH_ACCESS + + 50 + 1.0 + verification + + Household has access to and utilizes basic health services. + + + + + Income Significantly Above Poverty Line + INCOME_HIGH + + 10 + 2.0 + verification + + Household income is significantly above the poverty line (150%+) for at least 6 months. + + + + Thriving Business or Employment + BUSINESS_SUCCESS + + 20 + 2.0 + verification + + Beneficiary has a thriving business or stable formal employment. + + diff --git a/spp_graduation/models/__init__.py b/spp_graduation/models/__init__.py new file mode 100644 index 00000000..3c9d4b37 --- /dev/null +++ b/spp_graduation/models/__init__.py @@ -0,0 +1,3 @@ +from . import graduation_pathway +from . import graduation_criteria +from . import graduation_assessment diff --git a/spp_graduation/models/graduation_assessment.py b/spp_graduation/models/graduation_assessment.py new file mode 100644 index 00000000..aaa0657f --- /dev/null +++ b/spp_graduation/models/graduation_assessment.py @@ -0,0 +1,177 @@ +from odoo import api, fields, models + + +class GraduationAssessment(models.Model): + _name = "spp.graduation.assessment" + _description = "Graduation Assessment" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "assessment_date desc" + + name = fields.Char( + compute="_compute_name", + store=True, + ) + + partner_id = fields.Many2one( + "res.partner", + string="Beneficiary", + required=True, + tracking=True, + ) + pathway_id = fields.Many2one( + "spp.graduation.pathway", + required=True, + tracking=True, + ) + + assessment_date = fields.Date( + required=True, + default=fields.Date.today, + tracking=True, + ) + assessor_id = fields.Many2one( + "res.users", + string="Assessor", + default=lambda self: self.env.user, + tracking=True, + ) + + # Criteria responses + response_ids = fields.One2many( + "spp.graduation.criteria.response", + "assessment_id", + string="Responses", + ) + + # Computed scores + readiness_score = fields.Float( + compute="_compute_scores", + store=True, + help="Overall readiness score (0-1)", + ) + is_required_criteria_met = fields.Boolean( + compute="_compute_scores", + store=True, + ) + + # Recommendation + recommendation = fields.Selection( + [ + ("graduate", "Ready to Graduate"), + ("extend", "Extend Participation"), + ("exit", "Exit (Non-graduation)"), + ("defer", "Defer Assessment"), + ], + tracking=True, + ) + + recommendation_notes = fields.Text() + + # Approval workflow + state = fields.Selection( + [ + ("draft", "Draft"), + ("submitted", "Submitted"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ], + default="draft", + tracking=True, + ) + + approved_by_id = fields.Many2one("res.users", string="Approved By") + approved_date = fields.Datetime() + + # Graduation outcome + graduation_date = fields.Date() + monitoring_end_date = fields.Date( + compute="_compute_monitoring_end", + store=True, + ) + + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + + @api.depends("partner_id", "pathway_id", "assessment_date") + def _compute_name(self): + for rec in self: + if rec.partner_id and rec.pathway_id: + rec.name = f"{rec.partner_id.name} - {rec.pathway_id.name}" + else: + rec.name = "New Assessment" + + @api.depends( + "response_ids.score", + "response_ids.criteria_id.weight", + "response_ids.criteria_id.is_required", + ) + def _compute_scores(self): + for assessment in self: + if not assessment.response_ids: + assessment.readiness_score = 0 + assessment.is_required_criteria_met = False + continue + + total_weight = sum(r.criteria_id.weight for r in assessment.response_ids) + weighted_score = sum(r.score * r.criteria_id.weight for r in assessment.response_ids) + + assessment.readiness_score = (weighted_score / total_weight) if total_weight else 0 + + # Check required criteria + required_responses = assessment.response_ids.filtered(lambda r: r.criteria_id.is_required) + assessment.is_required_criteria_met = all(r.is_met for r in required_responses) + + @api.depends("graduation_date", "pathway_id.post_graduation_monitoring_months") + def _compute_monitoring_end(self): + for rec in self: + if rec.graduation_date and rec.pathway_id.post_graduation_monitoring_months: + from dateutil.relativedelta import relativedelta + + rec.monitoring_end_date = rec.graduation_date + relativedelta( + months=rec.pathway_id.post_graduation_monitoring_months + ) + else: + rec.monitoring_end_date = False + + def action_submit(self): + self.state = "submitted" + + def action_approve(self): + self.write( + { + "state": "approved", + "approved_by_id": self.env.user.id, + "approved_date": fields.Datetime.now(), + } + ) + if self.recommendation == "graduate": + self.graduation_date = fields.Date.today() + + def action_reject(self): + self.state = "rejected" + + def action_reset_draft(self): + self.state = "draft" + + +class GraduationCriteriaResponse(models.Model): + _name = "spp.graduation.criteria.response" + _description = "Criteria Response" + + assessment_id = fields.Many2one( + "spp.graduation.assessment", + required=True, + ondelete="cascade", + ) + criteria_id = fields.Many2one( + "spp.graduation.criteria", + required=True, + ) + + score = fields.Float( + help="Score from 0 to 1", + ) + is_met = fields.Boolean() + + value = fields.Char(help="Actual value observed") + notes = fields.Text() + evidence_attachment_ids = fields.Many2many("ir.attachment", string="Evidence Attachments") diff --git a/spp_graduation/models/graduation_criteria.py b/spp_graduation/models/graduation_criteria.py new file mode 100644 index 00000000..e036ee8a --- /dev/null +++ b/spp_graduation/models/graduation_criteria.py @@ -0,0 +1,36 @@ +from odoo import fields, models + + +class GraduationCriteria(models.Model): + _name = "spp.graduation.criteria" + _description = "Graduation Criteria" + _order = "sequence, name" + + name = fields.Char(required=True) + code = fields.Char() + pathway_id = fields.Many2one("spp.graduation.pathway", required=True, ondelete="cascade") + sequence = fields.Integer(default=10) + + description = fields.Text() + + weight = fields.Float( + default=1.0, + help="Weight in overall score calculation", + ) + + assessment_method = fields.Selection( + [ + ("self_report", "Self Report"), + ("verification", "Verification Required"), + ("computed", "Computed from Data"), + ("observation", "Field Observation"), + ], + default="verification", + ) + + is_required = fields.Boolean( + default=False, + help="If required, must be met regardless of overall score", + ) + + active = fields.Boolean(default=True) diff --git a/spp_graduation/models/graduation_pathway.py b/spp_graduation/models/graduation_pathway.py new file mode 100644 index 00000000..0bf3631c --- /dev/null +++ b/spp_graduation/models/graduation_pathway.py @@ -0,0 +1,41 @@ +from odoo import api, fields, models + + +class GraduationPathway(models.Model): + _name = "spp.graduation.pathway" + _description = "Graduation Pathway" + _order = "sequence, name" + + name = fields.Char(required=True) + code = fields.Char() + description = fields.Text() + active = fields.Boolean(default=True) + sequence = fields.Integer(default=10) + + is_positive_exit = fields.Boolean( + default=True, + help="Positive exit (graduation) vs negative exit (removed)", + ) + + is_requires_assessment = fields.Boolean(default=True) + is_requires_approval = fields.Boolean(default=True) + + criteria_ids = fields.One2many("spp.graduation.criteria", "pathway_id", string="Criteria") + + post_graduation_monitoring_months = fields.Integer( + default=12, + help="Months of monitoring after graduation", + ) + + criteria_count = fields.Integer( + compute="_compute_criteria_count", + store=True, + default=0, + ) + + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + + @api.depends("criteria_ids") + def _compute_criteria_count(self): + for pathway in self: + pathway.criteria_count = len(pathway.criteria_ids) diff --git a/spp_graduation/pyproject.toml b/spp_graduation/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_graduation/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_graduation/readme/DESCRIPTION.md b/spp_graduation/readme/DESCRIPTION.md new file mode 100644 index 00000000..5ece70b5 --- /dev/null +++ b/spp_graduation/readme/DESCRIPTION.md @@ -0,0 +1,55 @@ +Manages beneficiary graduation from time-bound social protection programs. Defines graduation pathways with weighted criteria, conducts assessments against those criteria, calculates readiness scores, and tracks graduation outcomes with post-graduation monitoring periods. Supports both positive exits (graduation) and negative exits (program removal). + +### Key Capabilities + +- Define graduation pathways with configurable criteria, exit type, and monitoring duration +- Create weighted criteria with different assessment methods (self-report, verification, computed, observation) +- Conduct beneficiary assessments with criteria responses and evidence attachments +- Calculate readiness scores based on weighted criteria and enforce required criteria +- Submit assessments for manager approval through a draft/submitted/approved/rejected workflow +- Track graduation dates and compute post-graduation monitoring periods +- Filter assessments by assessor, state, pathway, and recommendation + +### Key Models + +| Model | Description | +| ---------------------------------- | -------------------------------------------------------- | +| `spp.graduation.pathway` | Defines a graduation pathway with criteria and exit type | +| `spp.graduation.criteria` | Individual criterion within a pathway with weight and method | +| `spp.graduation.assessment` | Assessment of a beneficiary against a pathway with scores | +| `spp.graduation.criteria.response` | Response to a specific criterion within an assessment | + +### Configuration + +After installing: + +1. Navigate to **Graduation > Configuration > Pathways** +2. Create graduation pathways specifying exit type (positive/negative) and monitoring months +3. Add criteria to each pathway with weight, assessment method, and required flag +4. Users can then create assessments under **Graduation > Assessments > All Assessments** + +### UI Location + +- **Menu**: Graduation (top-level menu) +- **Assessments**: Graduation > Assessments > All Assessments / My Assessments +- **Configuration**: Graduation > Configuration > Pathways (managers only) +- **Views**: List, kanban (grouped by state), and form views with approval workflow +- **Pathway Form**: Criteria tab shows inline editable criteria list +- **Assessment Form**: Criteria Responses and Recommendation tabs + +### Security + +| Group | Access | +| ------------------------------------------ | --------------------------------------------------------- | +| `spp_graduation.group_spp_graduation_user` | Read pathways/criteria; create/edit own assessments (no delete) | +| `spp_graduation.group_spp_graduation_manager` | Full CRUD on all graduation data and configuration | + +### Extension Points + +- Inherit `spp.graduation.assessment` and override `_compute_scores()` to customize readiness calculation +- Inherit `spp.graduation.pathway` to add domain-specific pathway fields +- Extend approval workflow by inheriting assessment actions (`action_submit`, `action_approve`) + +### Dependencies + +`base`, `spp_security`, `mail` diff --git a/spp_graduation/security/graduation_rules.xml b/spp_graduation/security/graduation_rules.xml new file mode 100644 index 00000000..86500121 --- /dev/null +++ b/spp_graduation/security/graduation_rules.xml @@ -0,0 +1,125 @@ + + + + + Graduation Pathway: Multi-Company Access + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + True + + + + Graduation Assessment: Multi-Company Access + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + True + + + + + Graduation Pathway: User - All + + [(1, '=', 1)] + + + + + + + + + Graduation Pathway: Manager - All + + [(1, '=', 1)] + + + + + + + + + + Graduation Criteria: User - All + + [(1, '=', 1)] + + + + + + + + + Graduation Criteria: Manager - All + + [(1, '=', 1)] + + + + + + + + + + Graduation Assessment: User - Own + + [('assessor_id', '=', user.id)] + + + + + + + + + Graduation Assessment: Manager - All + + [(1, '=', 1)] + + + + + + + + + + Graduation Criteria Response: User - Own + + [('assessment_id.assessor_id', '=', user.id)] + + + + + + + + + Graduation Criteria Response: Manager - All + + [(1, '=', 1)] + + + + + + + diff --git a/spp_graduation/security/graduation_security.xml b/spp_graduation/security/graduation_security.xml new file mode 100644 index 00000000..dfd819d6 --- /dev/null +++ b/spp_graduation/security/graduation_security.xml @@ -0,0 +1,33 @@ + + + + + User + + Can view graduation pathways and criteria. Can create and manage own graduation assessments. + + + + + + Manager + + Full access to graduation management including pathways, criteria, and all assessments. Can manage graduation configurations. + + + + + + + + diff --git a/spp_graduation/security/ir.model.access.csv b/spp_graduation/security/ir.model.access.csv new file mode 100644 index 00000000..11ec54bb --- /dev/null +++ b/spp_graduation/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_graduation_pathway_group_spp_graduation_user,spp.graduation.pathway.user,model_spp_graduation_pathway,group_spp_graduation_user,1,0,0,0 +access_spp_graduation_pathway_group_spp_graduation_manager,spp.graduation.pathway.manager,model_spp_graduation_pathway,group_spp_graduation_manager,1,1,1,1 +access_spp_graduation_criteria_group_spp_graduation_user,spp.graduation.criteria.user,model_spp_graduation_criteria,group_spp_graduation_user,1,0,0,0 +access_spp_graduation_criteria_group_spp_graduation_manager,spp.graduation.criteria.manager,model_spp_graduation_criteria,group_spp_graduation_manager,1,1,1,1 +access_spp_graduation_assessment_group_spp_graduation_user,spp.graduation.assessment.user,model_spp_graduation_assessment,group_spp_graduation_user,1,1,1,0 +access_spp_graduation_assessment_group_spp_graduation_manager,spp.graduation.assessment.manager,model_spp_graduation_assessment,group_spp_graduation_manager,1,1,1,1 +access_spp_graduation_criteria_response_group_spp_graduation_user,spp.graduation.criteria.response.user,model_spp_graduation_criteria_response,group_spp_graduation_user,1,1,1,1 +access_spp_graduation_criteria_response_group_spp_graduation_manager,spp.graduation.criteria.response.manager,model_spp_graduation_criteria_response,group_spp_graduation_manager,1,1,1,1 diff --git a/spp_graduation/security/privileges.xml b/spp_graduation/security/privileges.xml new file mode 100644 index 00000000..7c4af9fb --- /dev/null +++ b/spp_graduation/security/privileges.xml @@ -0,0 +1,19 @@ + + + + + + + + User + + 10 + + + + + Manager + + 20 + + diff --git a/spp_graduation/static/description/icon.png b/spp_graduation/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_graduation/static/description/icon.png differ diff --git a/spp_graduation/static/description/index.html b/spp_graduation/static/description/index.html new file mode 100644 index 00000000..6e33b82e --- /dev/null +++ b/spp_graduation/static/description/index.html @@ -0,0 +1,532 @@ + + + + + +OpenSPP Graduation Management + + + +
+

OpenSPP Graduation Management

+ + +

Beta License: LGPL-3 OpenSPP/OpenSPP2

+

Manages beneficiary graduation from time-bound social protection +programs. Defines graduation pathways with weighted criteria, conducts +assessments against those criteria, calculates readiness scores, and +tracks graduation outcomes with post-graduation monitoring periods. +Supports both positive exits (graduation) and negative exits (program +removal).

+
+

Key Capabilities

+
    +
  • Define graduation pathways with configurable criteria, exit type, and +monitoring duration
  • +
  • Create weighted criteria with different assessment methods +(self-report, verification, computed, observation)
  • +
  • Conduct beneficiary assessments with criteria responses and evidence +attachments
  • +
  • Calculate readiness scores based on weighted criteria and enforce +required criteria
  • +
  • Submit assessments for manager approval through a +draft/submitted/approved/rejected workflow
  • +
  • Track graduation dates and compute post-graduation monitoring periods
  • +
  • Filter assessments by assessor, state, pathway, and recommendation
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + + + + + + + +
ModelDescription
spp.graduation.pathwayDefines a graduation pathway +with criteria and exit type
spp.graduation.criteriaIndividual criterion within a +pathway with weight and method
spp.graduation.assessmentAssessment of a beneficiary +against a pathway with scores
spp.graduation.criteria.responseResponse to a specific criterion +within an assessment
+
+
+

Configuration

+

After installing:

+
    +
  1. Navigate to Graduation > Configuration > Pathways
  2. +
  3. Create graduation pathways specifying exit type (positive/negative) +and monitoring months
  4. +
  5. Add criteria to each pathway with weight, assessment method, and +required flag
  6. +
  7. Users can then create assessments under Graduation > Assessments > +All Assessments
  8. +
+
+
+

UI Location

+
    +
  • Menu: Graduation (top-level menu)
  • +
  • Assessments: Graduation > Assessments > All Assessments / My +Assessments
  • +
  • Configuration: Graduation > Configuration > Pathways (managers +only)
  • +
  • Views: List, kanban (grouped by state), and form views with +approval workflow
  • +
  • Pathway Form: Criteria tab shows inline editable criteria list
  • +
  • Assessment Form: Criteria Responses and Recommendation tabs
  • +
+
+
+

Security

+ ++++ + + + + + + + + + + + + + +
GroupAccess
spp_graduation.group_spp_graduation_userRead pathways/criteria; +create/edit own assessments (no +delete)
spp_graduation.group_spp_graduation_managerFull CRUD on all graduation data +and configuration
+
+
+

Extension Points

+
    +
  • Inherit spp.graduation.assessment and override +_compute_scores() to customize readiness calculation
  • +
  • Inherit spp.graduation.pathway to add domain-specific pathway +fields
  • +
  • Extend approval workflow by inheriting assessment actions +(action_submit, action_approve)
  • +
+
+
+

Dependencies

+

base, spp_security, mail

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123 emjay0921

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_graduation/static/description/openspp-graduation-menu-icons.png b/spp_graduation/static/description/openspp-graduation-menu-icons.png new file mode 100644 index 00000000..66d7734b Binary files /dev/null and b/spp_graduation/static/description/openspp-graduation-menu-icons.png differ diff --git a/spp_graduation/tests/__init__.py b/spp_graduation/tests/__init__.py new file mode 100644 index 00000000..88571e27 --- /dev/null +++ b/spp_graduation/tests/__init__.py @@ -0,0 +1,5 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_pathway +from . import test_assessment +from . import test_graduation_security diff --git a/spp_graduation/tests/test_assessment.py b/spp_graduation/tests/test_assessment.py new file mode 100644 index 00000000..a2973975 --- /dev/null +++ b/spp_graduation/tests/test_assessment.py @@ -0,0 +1,204 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestGraduationAssessment(TransactionCase): + """Test graduation assessment management.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Assessment = cls.env["spp.graduation.assessment"] + cls.Response = cls.env["spp.graduation.criteria.response"] + cls.Pathway = cls.env["spp.graduation.pathway"] + cls.Criteria = cls.env["spp.graduation.criteria"] + cls.Partner = cls.env["res.partner"] + + cls.beneficiary = cls.Partner.create( + { + "name": "Test Beneficiary", + } + ) + + cls.pathway = cls.Pathway.create( + { + "name": "Test Pathway", + "code": "TEST", + } + ) + + cls.criteria1 = cls.Criteria.create( + { + "pathway_id": cls.pathway.id, + "name": "Economic", + "weight": 60, + "is_required": True, + } + ) + cls.criteria2 = cls.Criteria.create( + { + "pathway_id": cls.pathway.id, + "name": "Social", + "weight": 40, + "is_required": False, + } + ) + + def test_assessment_creation(self): + """Test assessment can be created.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + self.assertEqual(assessment.state, "draft") + self.assertEqual(assessment.readiness_score, 0) + + def test_assessment_name_computation(self): + """Test assessment name is computed.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + self.assertIn(self.beneficiary.name, assessment.name) + self.assertIn(self.pathway.name, assessment.name) + + def test_assessment_workflow(self): + """Test assessment state workflow.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + + self.assertEqual(assessment.state, "draft") + + assessment.action_submit() + self.assertEqual(assessment.state, "submitted") + + assessment.action_approve() + self.assertEqual(assessment.state, "approved") + self.assertTrue(assessment.approved_by_id) + self.assertTrue(assessment.approved_date) + + def test_assessment_rejection(self): + """Test assessment rejection.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + + assessment.action_submit() + assessment.action_reject() + self.assertEqual(assessment.state, "rejected") + + def test_assessment_reset(self): + """Test assessment reset to draft.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + + assessment.action_submit() + assessment.action_reset_draft() + self.assertEqual(assessment.state, "draft") + + def test_readiness_score_computation(self): + """Test readiness score computation.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + + self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria1.id, + "score": 0.8, # 80% + "is_met": True, + } + ) + self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria2.id, + "score": 0.6, # 60% + "is_met": False, + } + ) + + assessment.invalidate_recordset(["readiness_score", "is_required_criteria_met"]) + + # Expected: (0.8 * 60 + 0.6 * 40) / (60 + 40) = 0.72 + expected_score = (0.8 * 60 + 0.6 * 40) / (60 + 40) + self.assertAlmostEqual(assessment.readiness_score, expected_score, places=1) + + def test_required_criteria_check(self): + """Test required criteria validation.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + + # Only required criterion met + self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria1.id, + "score": 1.0, + "is_met": True, + } + ) + + assessment.invalidate_recordset(["is_required_criteria_met"]) + self.assertTrue(assessment.is_required_criteria_met) + + def test_required_criteria_not_met(self): + """Test required criteria not met.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + + self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria1.id, + "score": 0.4, + "is_met": False, # Required but not met + } + ) + + assessment.invalidate_recordset(["is_required_criteria_met"]) + self.assertFalse(assessment.is_required_criteria_met) + + def test_graduation_date_on_approval(self): + """Test graduation date is set on approval with graduate recommendation.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + "recommendation": "graduate", + } + ) + + assessment.action_submit() + assessment.action_approve() + + self.assertEqual(assessment.graduation_date, fields.Date.today()) diff --git a/spp_graduation/tests/test_graduation_security.py b/spp_graduation/tests/test_graduation_security.py new file mode 100644 index 00000000..87d213f1 --- /dev/null +++ b/spp_graduation/tests/test_graduation_security.py @@ -0,0 +1,137 @@ +"""Security tests for graduation module.""" + +from odoo import Command +from odoo.exceptions import AccessError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestGraduationSecurity(TransactionCase): + """Test access control for graduation records.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = cls.env["res.users"].create( + { + "name": "Graduation User", + "login": "test_graduation_user", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_graduation.group_spp_graduation_user").id), + ], + } + ) + cls.manager = cls.env["res.users"].create( + { + "name": "Graduation Manager", + "login": "test_graduation_manager", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_graduation.group_spp_graduation_manager").id), + ], + } + ) + # Create test beneficiary for assessments + cls.beneficiary = cls.env["res.partner"].create( + { + "name": "Test Beneficiary", + } + ) + + def test_group_hierarchy_manager_has_user(self): + """Test manager inherits user permissions.""" + self.assertTrue( + self.manager.has_group("spp_graduation.group_spp_graduation_user"), "Manager should have user permissions" + ) + + def test_admin_has_manager(self): + """Test OpenSPP admin has graduation manager access.""" + admin = self.env["res.users"].create( + { + "name": "Admin Test", + "login": "test_admin_graduation", + "group_ids": [ + Command.link(self.env.ref("spp_security.group_spp_admin").id), + ], + } + ) + self.assertTrue( + admin.has_group("spp_graduation.group_spp_graduation_manager"), + "Admin should have graduation manager access", + ) + + def test_user_sees_own_assessments(self): + """Test user sees own assessments.""" + pathway = self.env["spp.graduation.pathway"].create( + { + "name": "Test Pathway", + } + ) + assessment = self.env["spp.graduation.assessment"].create( + { + "name": "Test Assessment", + "pathway_id": pathway.id, + "partner_id": self.beneficiary.id, + "assessor_id": self.user.id, + } + ) + assessments_as_user = self.env["spp.graduation.assessment"].with_user(self.user).search([]) + self.assertIn(assessment, assessments_as_user, "User should see own assessments") + + def test_manager_sees_all_assessments(self): + """Test manager sees all assessments.""" + pathway = self.env["spp.graduation.pathway"].create( + { + "name": "Test Pathway", + } + ) + assessment1 = self.env["spp.graduation.assessment"].create( + { + "name": "Assessment 1", + "pathway_id": pathway.id, + "partner_id": self.beneficiary.id, + "assessor_id": self.user.id, + } + ) + assessment2 = self.env["spp.graduation.assessment"].create( + { + "name": "Assessment 2", + "pathway_id": pathway.id, + "partner_id": self.beneficiary.id, + "assessor_id": self.manager.id, + } + ) + assessments_as_manager = self.env["spp.graduation.assessment"].with_user(self.manager).search([]) + self.assertIn(assessment1, assessments_as_manager, "Manager should see all assessments") + self.assertIn(assessment2, assessments_as_manager, "Manager should see all assessments") + + def test_user_can_read_pathways(self): + """Test user can read pathways.""" + pathway = self.env["spp.graduation.pathway"].create( + { + "name": "Test Pathway", + } + ) + pathway_as_user = pathway.with_user(self.user) + self.assertEqual(pathway_as_user.name, "Test Pathway") + + def test_user_cannot_write_pathways(self): + """Test user cannot write pathways.""" + pathway = self.env["spp.graduation.pathway"].create( + { + "name": "Test Pathway", + } + ) + with self.assertRaises(AccessError): + pathway.with_user(self.user).write({"name": "Modified"}) + + def test_manager_can_write_pathways(self): + """Test manager can write pathways.""" + pathway = self.env["spp.graduation.pathway"].create( + { + "name": "Test Pathway", + } + ) + pathway.with_user(self.manager).write({"name": "Modified by Manager"}) + self.assertEqual(pathway.name, "Modified by Manager") diff --git a/spp_graduation/tests/test_pathway.py b/spp_graduation/tests/test_pathway.py new file mode 100644 index 00000000..93db72ff --- /dev/null +++ b/spp_graduation/tests/test_pathway.py @@ -0,0 +1,91 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase + + +class TestGraduationPathway(TransactionCase): + """Test graduation pathway management.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Pathway = cls.env["spp.graduation.pathway"] + cls.Criteria = cls.env["spp.graduation.criteria"] + + def test_pathway_creation(self): + """Test graduation pathway can be created.""" + pathway = self.Pathway.create( + { + "name": "Standard Graduation", + "code": "STD", + } + ) + self.assertTrue(pathway.active) + self.assertEqual(pathway.criteria_count, 0) + + def test_pathway_with_monitoring(self): + """Test pathway with post-graduation monitoring.""" + pathway = self.Pathway.create( + { + "name": "Extended Monitoring", + "code": "EXT", + "post_graduation_monitoring_months": 6, + } + ) + self.assertEqual(pathway.post_graduation_monitoring_months, 6) + + def test_pathway_with_criteria(self): + """Test pathway with graduation criteria.""" + pathway = self.Pathway.create( + { + "name": "Full Graduation", + "code": "FULL", + } + ) + + self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Economic Stability", + "weight": 30, + "is_required": True, + } + ) + self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Social Integration", + "weight": 20, + "is_required": False, + } + ) + + pathway.invalidate_recordset(["criteria_count"]) + self.assertEqual(pathway.criteria_count, 2) + + def test_criteria_weight_total(self): + """Test criteria weights can sum to different totals.""" + pathway = self.Pathway.create( + { + "name": "Weighted Pathway", + "code": "WGT", + } + ) + + self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Criterion A", + "weight": 50, + } + ) + self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Criterion B", + "weight": 50, + } + ) + + total = sum(c.weight for c in pathway.criteria_ids) + self.assertEqual(total, 100) diff --git a/spp_graduation/views/graduation_assessment_views.xml b/spp_graduation/views/graduation_assessment_views.xml new file mode 100644 index 00000000..e16ceacb --- /dev/null +++ b/spp_graduation/views/graduation_assessment_views.xml @@ -0,0 +1,295 @@ + + + + + spp.graduation.assessment.tree + spp.graduation.assessment + + + + + + + + + + + + + + + + + + spp.graduation.assessment.kanban + spp.graduation.assessment + + + + + + + + + +
+
+
+ + + +
+
+
+ +
+
+ + +
+
+ Score: + % +
+
+ +
+
+
+
+
+
+
+ + + + spp.graduation.assessment.form + spp.graduation.assessment + +
+
+
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + spp.graduation.assessment.search + spp.graduation.assessment + + + + + + + + + + + + + + + + + + + + + + + + + + + Graduation Assessments + spp.graduation.assessment + list,kanban,form + +

+ Create a new Graduation Assessment +

+

+ Assess beneficiaries for graduation readiness. +

+
+
+ + + + My Assessments + spp.graduation.assessment + list,kanban,form + {'search_default_my_assessments': 1} + +

+ Create a new Graduation Assessment +

+

+ View and manage your graduation assessments. +

+
+
+ diff --git a/spp_graduation/views/graduation_menus.xml b/spp_graduation/views/graduation_menus.xml new file mode 100644 index 00000000..e27d34c4 --- /dev/null +++ b/spp_graduation/views/graduation_menus.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/spp_graduation/views/graduation_pathway_views.xml b/spp_graduation/views/graduation_pathway_views.xml new file mode 100644 index 00000000..e678361d --- /dev/null +++ b/spp_graduation/views/graduation_pathway_views.xml @@ -0,0 +1,95 @@ + + + + + spp.graduation.pathway.tree + spp.graduation.pathway + + + + + + + + + + + + + + + + + spp.graduation.pathway.form + spp.graduation.pathway + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Graduation Pathways + spp.graduation.pathway + list,form + +

+ Create a new Graduation Pathway +

+

+ Define pathways for beneficiary graduation from programs. +

+
+
+ diff --git a/spp_grm/README.rst b/spp_grm/README.rst index ff624b77..bb58317a 100644 --- a/spp_grm/README.rst +++ b/spp_grm/README.rst @@ -177,10 +177,13 @@ Maintainers .. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px :target: https://github.com/gonzalesedwin1123 :alt: gonzalesedwin1123 +.. |maintainer-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 Current maintainers: -|maintainer-jeremi| |maintainer-gonzalesedwin1123| +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-emjay0921| This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. diff --git a/spp_grm/__manifest__.py b/spp_grm/__manifest__.py index ca70c5d1..32e96d09 100644 --- a/spp_grm/__manifest__.py +++ b/spp_grm/__manifest__.py @@ -3,7 +3,7 @@ { "name": "OpenSPP - Grievance Redress Mechanism", "summary": "Provides a centralized Grievance Redress Mechanism for receiving, tracking, and resolving beneficiary complaints and feedback. It supports multi-channel submission, manages resolution workflows through customizable stages, and links grievances directly to individual or group registrants.", - "version": "19.0.2.0.0", + "version": "19.0.1.3.1", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", @@ -11,7 +11,7 @@ "development_status": "Production/Stable", "category": "OpenSPP/Monitoring", "external_dependencies": {"python": []}, - "maintainers": ["jeremi", "gonzalesedwin1123"], + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], "depends": [ "base", "mail", @@ -42,7 +42,6 @@ "views/grm_sla_rule_views.xml", "views/grm_ticket_views.xml", "views/grm_portal_templates.xml", - "views/res_config_settings_views.xml", ], "assets": {}, "demo": [], diff --git a/spp_grm/controllers/grm_portal.py b/spp_grm/controllers/grm_portal.py index 7785e230..3deeffd6 100644 --- a/spp_grm/controllers/grm_portal.py +++ b/spp_grm/controllers/grm_portal.py @@ -9,12 +9,7 @@ class SPPGrmPortal(CustomerPortal): - @http.route( - ["/my/tickets", "/my/tickets/page/"], - type="http", - auth="user", - website=True, - ) + @http.route(["/my/tickets", "/my/tickets/page/"], type="http", auth="user", website=True) def portal_my_tickets(self, page=1, **kw): partner = request.env.user.partner_id ticket = request.env["spp.grm.ticket"] @@ -52,12 +47,10 @@ def portal_ticket_submit(self, **kw): "channel_id": request.env.ref("spp_grm.grm_ticket_channel_web").id, "partner_id": partner.id, } - # nosemgrep: odoo-sudo-without-context — portal controller with partner-based access filtering + # nosemgrep: semgrep.odoo-sudo-without-context -- portal users need sudo to create tickets ticket = request.env["spp.grm.ticket"].sudo().create(vals) ticket.send_ticket_confirmation_email(ticket) - # Redirect to the fixed internal tickets page; target is a constant - # relative URL, so this is not an open redirect. - # nosemgrep: odoo-unvalidated-redirect — redirect target is fixed internal '/my/tickets' URL + # nosemgrep: semgrep.odoo-unvalidated-redirect -- fixed internal URL return request.redirect("/my/tickets") diff --git a/spp_grm/data/grm_data.xml b/spp_grm/data/grm_data.xml index ada68782..f709fb5b 100644 --- a/spp_grm/data/grm_data.xml +++ b/spp_grm/data/grm_data.xml @@ -26,6 +26,7 @@ 1 New + new True False @@ -33,6 +34,7 @@ 2 In Progress + in_progress False False @@ -40,6 +42,7 @@ 3 Awaiting + waiting False False @@ -47,6 +50,7 @@ 4 Done + resolved False True True @@ -56,6 +60,7 @@ 5 Cancelled + cancelled False True True @@ -65,6 +70,7 @@ 6 Rejected + closed False True True diff --git a/spp_grm/data/user_roles.xml b/spp_grm/data/user_roles.xml index bba3c505..0bf178a4 100644 --- a/spp_grm/data/user_roles.xml +++ b/spp_grm/data/user_roles.xml @@ -29,7 +29,7 @@ eval=" [ Command.link(ref('base.group_user')), - Command.link(ref('spp_grm.group_grm_user')), + Command.link(ref('spp_grm.group_grm_officer')), Command.link(ref('spp_registry.group_registry_officer')), ]" /> @@ -47,7 +47,7 @@ eval=" [ Command.link(ref('base.group_user')), - Command.link(ref('spp_grm.group_grm_user')), + Command.link(ref('spp_grm.group_grm_officer')), Command.link(ref('spp_registry.group_registry_officer')), ]" /> diff --git a/spp_grm/models/grm_sla_rule.py b/spp_grm/models/grm_sla_rule.py index 30d50bd6..6ee6410e 100644 --- a/spp_grm/models/grm_sla_rule.py +++ b/spp_grm/models/grm_sla_rule.py @@ -1,7 +1,7 @@ import ast import logging -from odoo import api, fields, models +from odoo import _, api, fields, models from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) @@ -33,7 +33,6 @@ class SPPGRMSLARule(models.Model): help="Set to inactive to disable this rule without deleting it", ) description = fields.Text( - string="Description", translate=True, help="Description of when this rule applies and what it does", ) @@ -47,7 +46,6 @@ class SPPGRMSLARule(models.Model): # Condition Fields condition_domain = fields.Char( - string="Condition Domain", help="Domain filter to determine which tickets this rule applies to. " "Example: [('severity','=','critical'),('category_id.code','=','ABUSE')]", default="[]", @@ -102,11 +100,11 @@ def _check_hours_positive(self): """Ensure all hour values are positive if set.""" for rule in self: if rule.response_hours and rule.response_hours < 0: - raise ValidationError("Response hours must be positive.") + raise ValidationError(_("Response hours must be positive.")) if rule.resolution_hours and rule.resolution_hours < 0: - raise ValidationError("Resolution hours must be positive.") + raise ValidationError(_("Resolution hours must be positive.")) if rule.escalate_after_hours and rule.escalate_after_hours < 0: - raise ValidationError("Escalation hours must be positive.") + raise ValidationError(_("Escalation hours must be positive.")) @api.constrains("response_hours", "resolution_hours") def _check_response_resolution_logic(self): @@ -115,8 +113,12 @@ def _check_response_resolution_logic(self): if rule.response_hours and rule.resolution_hours: if rule.response_hours > rule.resolution_hours: raise ValidationError( - "Response time cannot be greater than resolution time. " - f"Response: {rule.response_hours}h, Resolution: {rule.resolution_hours}h" + _( + "Response time cannot be greater than resolution time. " + "Response: %(response)sh, Resolution: %(resolution)sh", + response=rule.response_hours, + resolution=rule.resolution_hours, + ) ) def evaluate_ticket(self, ticket): @@ -147,7 +149,7 @@ def evaluate_ticket(self, ticket): return False except Exception as e: # Log error but don't fail - treat invalid domain as not matching - _logger.warning("Error evaluating SLA rule ID %s domain: %s", self.id, e) + _logger.warning("Error evaluating SLA rule %s domain: %s", self.id, e) return False return True diff --git a/spp_grm/models/grm_ticket.py b/spp_grm/models/grm_ticket.py index f7fb5f6e..d2b2500a 100644 --- a/spp_grm/models/grm_ticket.py +++ b/spp_grm/models/grm_ticket.py @@ -96,7 +96,7 @@ def _default_stage_id(self): # Basic Information number = fields.Char(string="Ticket number", default="/", readonly=True) name = fields.Char(string="Title", required=True) - description = fields.Html(required=True, sanitize_style=True) + description = fields.Text(required=True) user_id = fields.Many2one( comodel_name="res.users", string="Assigned user", @@ -119,10 +119,7 @@ def _default_stage_id(self): index=True, ) partner_id = fields.Many2one( - comodel_name="res.partner", - string="Contact", - required=True, - domain="[('is_registrant', '=', True)]", + comodel_name="res.partner", string="Contact", required=True, domain="[('is_registrant', '=', True)]" ) partner_email = fields.Char(string="Email", related="partner_id.email", store=True) last_stage_update = fields.Datetime(default=fields.Datetime.now) @@ -134,11 +131,11 @@ def _default_stage_id(self): help="User who closed this ticket", ) is_closed = fields.Boolean( - string="Is Closed", compute="_compute_is_closed", store=True, help="Computed field indicating if ticket is in a closed stage", ) + stage_type = fields.Selection(related="stage_id.stage_type", store=True) unattended = fields.Boolean(related="stage_id.unattended", store=True) tag_ids = fields.Many2many(comodel_name="spp.grm.ticket.tag", string="Tags") company_id = fields.Many2one( @@ -151,7 +148,6 @@ def _default_stage_id(self): # Intake Information (per spec Section 4.1) intake_date = fields.Datetime( - string="Intake Date", default=fields.Datetime.now, help="Date and time when the complaint was received", ) @@ -162,7 +158,6 @@ def _default_stage_id(self): help="Staff member who recorded the complaint during intake", ) desired_resolution = fields.Text( - string="Desired Resolution", help="What outcome the complainant is seeking", ) @@ -214,7 +209,6 @@ def _default_stage_id(self): ("high", "High"), ("critical", "Critical"), ], - string="Severity", default="medium", required=True, tracking=True, @@ -226,7 +220,6 @@ def _default_stage_id(self): ("sensitive", "Sensitive"), ("highly_sensitive", "Highly Sensitive"), ], - string="Sensitivity", default="standard", tracking=True, help="Data sensitivity classification for handling requirements", @@ -239,7 +232,6 @@ def _default_stage_id(self): ("anonymous", "Anonymous"), ("other", "Other"), ], - string="Complainant Type", default="beneficiary", tracking=True, help="Type of person submitting the complaint", @@ -247,15 +239,12 @@ def _default_stage_id(self): # Anonymous Complaint Contact Fields contact_name = fields.Char( - string="Contact Name", help="Contact name for anonymous complaints (when partner_id is not set)", ) contact_phone = fields.Char( - string="Contact Phone", help="Contact phone for anonymous complaints", ) contact_email = fields.Char( - string="Contact Email", help="Contact email for anonymous complaints", ) @@ -289,19 +278,15 @@ def _default_stage_id(self): ("redirected", "Redirected"), ("referred_to_case", "Referred to Case"), ], - string="Decision", tracking=True, help="Final decision on the complaint", ) - resolution_summary = fields.Html( - string="Resolution Summary", - sanitize_style=True, + resolution_summary = fields.Text( help="Detailed summary of the resolution", ) # Escalation Fields is_escalated = fields.Boolean( - string="Is Escalated", default=False, tracking=True, help="Indicates if this ticket has been escalated", @@ -313,17 +298,14 @@ def _default_stage_id(self): help="User to whom this ticket was escalated", ) escalation_date = fields.Datetime( - string="Escalation Date", help="Date when the ticket was escalated", ) escalation_reason = fields.Text( - string="Escalation Reason", help="Reason for escalating the ticket", ) # Appeal Fields is_appeal = fields.Boolean( - string="Is Appeal", default=False, help="Indicates if this ticket is an appeal of another ticket", ) @@ -350,7 +332,6 @@ def _default_stage_id(self): # Computed Metrics days_open = fields.Integer( - string="Days Open", compute="_compute_days_open", store=True, help="Number of days the ticket has been open", @@ -438,7 +419,8 @@ def write(self, vals): def _compute_user_id(self): """Compute assigned user based on team or category defaults. - This provides a suggested assignment but can be overridden manually. + Falls back to the current user so officers can always see + tickets they create (required by officer record rules). """ for ticket in self: # Skip if already assigned @@ -449,7 +431,7 @@ def _compute_user_id(self): if ticket.team_id and ticket.team_id.manager_id: ticket.user_id = ticket.team_id.manager_id else: - ticket.user_id = False + ticket.user_id = self.env.user @api.depends("stage_id", "stage_id.is_closed") def _compute_is_closed(self): @@ -556,7 +538,8 @@ def _compute_sla_status(self): if ticket.sla_status == "breached" and old_status != "breached": # Use sudo() to call _on_sla_breach in a new environment context # to avoid triggering compute dependencies during the compute itself - ticket.sudo()._on_sla_breach() # nosemgrep: odoo-sudo-without-context + # nosemgrep: semgrep.odoo-sudo-without-context + ticket.sudo()._on_sla_breach() def _on_sla_breach(self): """Called when ticket SLA status changes to breached. @@ -575,10 +558,7 @@ def _on_sla_breach(self): ) continue - _logger.info( - "SLA breach detected for ticket %s, triggering auto-escalation", - ticket.number, - ) + _logger.info("SLA breach detected for ticket %s, triggering auto-escalation", ticket.number) # Try to apply escalation rules if spp_grm_cel module is installed if "spp.grm.escalation.rule" in self.env: @@ -648,6 +628,43 @@ def _read_group_stage_ids(self, stages, domain): def assign_to_me(self): self.write({"user_id": self.env.user.id}) + def _find_stage_by_type(self, stage_type): + """Find the first stage matching the given stage_type.""" + stage = self.env["spp.grm.ticket.stage"].search([("stage_type", "=", stage_type)], limit=1) + if not stage: + raise UserError(_("No stage found with type '%s'.") % stage_type) + return stage + + def action_start_progress(self): + """Move ticket to In Progress stage.""" + stage = self._find_stage_by_type("in_progress") + self.write({"stage_id": stage.id}) + + def action_set_awaiting(self): + """Move ticket to Awaiting stage.""" + stage = self._find_stage_by_type("waiting") + self.write({"stage_id": stage.id}) + + def action_resolve(self): + """Move ticket to Done/Resolved stage.""" + stage = self._find_stage_by_type("resolved") + self.write({"stage_id": stage.id}) + + def action_cancel(self): + """Move ticket to Cancelled stage.""" + stage = self._find_stage_by_type("cancelled") + self.write({"stage_id": stage.id}) + + def action_reject(self): + """Move ticket to Rejected stage.""" + stage = self._find_stage_by_type("closed") + self.write({"stage_id": stage.id}) + + def action_reopen(self): + """Reopen a closed ticket back to New stage.""" + stage = self._find_stage_by_type("new") + self.write({"stage_id": stage.id, "closed_date": False, "closed_by_id": False}) + def _prepare_ticket_number(self): # Generate ticket number return self.env["ir.sequence"].next_by_code("spp.grm.ticket.sequence") @@ -661,7 +678,8 @@ def send_ticket_confirmation_email(self, ticket): """Send the ticket submission confirmation email.""" template = self.env.ref("spp_grm.ticket_submission_confirmation", raise_if_not_found=False) if template: - template.sudo().send_mail( # nosemgrep: odoo-sudo-without-context + # nosemgrep: semgrep.odoo-sudo-without-context -- mail templates require sudo + template.sudo().send_mail( ticket.id, force_send=True, email_values={ diff --git a/spp_grm/models/grm_ticket_category.py b/spp_grm/models/grm_ticket_category.py index 6df1c912..3a4004eb 100644 --- a/spp_grm/models/grm_ticket_category.py +++ b/spp_grm/models/grm_ticket_category.py @@ -1,4 +1,4 @@ -from odoo import api, fields, models +from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -24,7 +24,6 @@ class SPPGRMCategory(models.Model): translate=True, ) code = fields.Char( - string="Code", help="Unique identifier code for this category", ) company_id = fields.Many2one( @@ -66,7 +65,6 @@ class SPPGRMCategory(models.Model): ("high", "High"), ("critical", "Critical"), ], - string="Default Severity", help="Default severity level for tickets in this category", ) default_sensitivity = fields.Selection( @@ -75,7 +73,6 @@ class SPPGRMCategory(models.Model): ("sensitive", "Sensitive"), ("highly_sensitive", "Highly Sensitive"), ], - string="Default Sensitivity", help="Default data sensitivity for tickets in this category", ) default_sla_hours = fields.Integer( @@ -95,7 +92,6 @@ class SPPGRMCategory(models.Model): help="Automatically escalate tickets in this category based on SLA rules", ) auto_create_case = fields.Boolean( - string="Auto Create Case", default=False, help="Automatically create a case management record for tickets in this category", ) @@ -115,15 +111,19 @@ def _check_code_unique(self): ) if duplicate: raise ValidationError( - f"Category code '{category.code}' already exists for company {category.company_id.name}. " - "Category codes must be unique per company." + _( + "Category code '%(code)s' already exists for company %(company)s. " + "Category codes must be unique per company.", + code=category.code, + company=category.company_id.name, + ) ) @api.constrains("parent_id") def _check_category_recursion(self): """Prevent circular references in category hierarchy.""" if not self._check_recursion(): - raise ValidationError("Error! You cannot create recursive categories.") + raise ValidationError(_("Error! You cannot create recursive categories.")) def name_get(self): """Display full category path in name.""" diff --git a/spp_grm/models/grm_ticket_stage.py b/spp_grm/models/grm_ticket_stage.py index b32a23c7..6bdb9d74 100644 --- a/spp_grm/models/grm_ticket_stage.py +++ b/spp_grm/models/grm_ticket_stage.py @@ -17,11 +17,9 @@ class SPPGRMTicketStage(models.Model): sequence = fields.Integer(default=1) active = fields.Boolean(default=True) unattended = fields.Boolean( - string="Unattended", help="Tickets in this stage are considered unattended", ) is_closed = fields.Boolean( - string="Is Closed", default=False, help="Indicates this is a closed/final stage", ) @@ -52,7 +50,6 @@ class SPPGRMTicketStage(models.Model): ("closed", "Closed"), ("cancelled", "Cancelled"), ], - string="Stage Type", help="Categorization of the stage for workflow logic", ) diff --git a/spp_grm/models/grm_ticket_subcategory.py b/spp_grm/models/grm_ticket_subcategory.py index a498fa29..e5eb643a 100644 --- a/spp_grm/models/grm_ticket_subcategory.py +++ b/spp_grm/models/grm_ticket_subcategory.py @@ -12,12 +12,10 @@ class SPPGRMTicketSubcategory(models.Model): _order = "category_id, sequence, name" name = fields.Char( - string="Name", required=True, translate=True, ) code = fields.Char( - string="Code", help="Unique code for this subcategory", ) category_id = fields.Many2one( @@ -38,7 +36,6 @@ class SPPGRMTicketSubcategory(models.Model): ("high", "High"), ("critical", "Critical"), ], - string="Default Severity", ) default_sla_hours = fields.Integer( string="Default SLA Hours", diff --git a/spp_grm/security/groups.xml b/spp_grm/security/groups.xml index 7dde3083..0ed50a19 100644 --- a/spp_grm/security/groups.xml +++ b/spp_grm/security/groups.xml @@ -19,7 +19,7 @@ Viewer - + Can view assigned GRM tickets only. Cannot modify data. @@ -28,7 +28,7 @@ Officer - + Can create and edit own tickets, view team tickets. Cannot delete. @@ -40,7 +40,7 @@ Manager - + Full GRM access including configuration and delete operations. @@ -50,7 +50,7 @@ Supervisor - + GRM: Can manage team tickets, approve resolutions. Positioned between Officer and Manager in the permission hierarchy. Inherits officer permissions. diff --git a/spp_grm/security/privileges.xml b/spp_grm/security/privileges.xml index 794e622e..cfe38d69 100644 --- a/spp_grm/security/privileges.xml +++ b/spp_grm/security/privileges.xml @@ -3,7 +3,7 @@ - + Grievance Management Access to grievance and ticket management diff --git a/spp_grm/security/rules.xml b/spp_grm/security/rules.xml index db84edc2..9191c497 100644 --- a/spp_grm/security/rules.xml +++ b/spp_grm/security/rules.xml @@ -15,7 +15,7 @@ - + GRM Ticket: Officer Own and Team Tickets @@ -27,6 +27,18 @@ + + + + + + + GRM Ticket: Officer Create + + [(1, '=', 1)] + + + diff --git a/spp_grm/static/description/OpenSPP-Helpdesk2-Icons.png b/spp_grm/static/description/OpenSPP-Helpdesk2-Icons.png new file mode 100644 index 00000000..5a3c2bca Binary files /dev/null and b/spp_grm/static/description/OpenSPP-Helpdesk2-Icons.png differ diff --git a/spp_grm/static/description/index.html b/spp_grm/static/description/index.html index b8254eeb..6d81d79a 100644 --- a/spp_grm/static/description/index.html +++ b/spp_grm/static/description/index.html @@ -555,7 +555,7 @@

Authors

Maintainers

Current maintainers:

-

jeremi gonzalesedwin1123

+

jeremi gonzalesedwin1123 emjay0921

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

You are welcome to contribute.

diff --git a/spp_grm/tests/test_compliance_generated.py b/spp_grm/tests/test_compliance_generated.py index fc3e932d..0d0aab4f 100644 --- a/spp_grm/tests/test_compliance_generated.py +++ b/spp_grm/tests/test_compliance_generated.py @@ -117,8 +117,7 @@ def test_manager_implies_officer(self): if not self.user_manager or not self.group_officer: self.skipTest("Required groups not found") self.assertTrue( - self.user_manager.has_group("spp_grm.group_grm_officer"), - "Manager should have officer privileges", + self.user_manager.has_group("spp_grm.group_grm_officer"), "Manager should have officer privileges" ) def test_officer_implies_viewer(self): @@ -126,8 +125,7 @@ def test_officer_implies_viewer(self): if not self.user_officer or not self.group_viewer: self.skipTest("Required groups not found") self.assertTrue( - self.user_officer.has_group("spp_grm.group_grm_viewer"), - "Officer should have viewer privileges", + self.user_officer.has_group("spp_grm.group_grm_viewer"), "Officer should have viewer privileges" ) def test_group_grm_write_implies(self): @@ -138,11 +136,7 @@ def test_group_grm_write_implies(self): implied_ids = group.implied_ids.mapped("id") implied_group = self.env.ref("spp_grm.group_grm_read", raise_if_not_found=False) if implied_group: - self.assertIn( - implied_group.id, - implied_ids, - "group_grm_write should imply group_grm_read", - ) + self.assertIn(implied_group.id, implied_ids, "group_grm_write should imply group_grm_read") def test_group_grm_viewer_implies(self): """Test group_grm_viewer implies correct groups.""" @@ -152,11 +146,7 @@ def test_group_grm_viewer_implies(self): implied_ids = group.implied_ids.mapped("id") implied_group = self.env.ref("spp_grm.group_grm_read", raise_if_not_found=False) if implied_group: - self.assertIn( - implied_group.id, - implied_ids, - "group_grm_viewer should imply group_grm_read", - ) + self.assertIn(implied_group.id, implied_ids, "group_grm_viewer should imply group_grm_read") def test_group_grm_officer_implies(self): """Test group_grm_officer implies correct groups.""" @@ -166,18 +156,10 @@ def test_group_grm_officer_implies(self): implied_ids = group.implied_ids.mapped("id") implied_group = self.env.ref("spp_grm.group_grm_viewer", raise_if_not_found=False) if implied_group: - self.assertIn( - implied_group.id, - implied_ids, - "group_grm_officer should imply group_grm_viewer", - ) + self.assertIn(implied_group.id, implied_ids, "group_grm_officer should imply group_grm_viewer") implied_group = self.env.ref("spp_grm.group_grm_write", raise_if_not_found=False) if implied_group: - self.assertIn( - implied_group.id, - implied_ids, - "group_grm_officer should imply group_grm_write", - ) + self.assertIn(implied_group.id, implied_ids, "group_grm_officer should imply group_grm_write") def test_group_grm_manager_implies(self): """Test group_grm_manager implies correct groups.""" @@ -187,11 +169,7 @@ def test_group_grm_manager_implies(self): implied_ids = group.implied_ids.mapped("id") implied_group = self.env.ref("spp_grm.group_grm_officer", raise_if_not_found=False) if implied_group: - self.assertIn( - implied_group.id, - implied_ids, - "group_grm_manager should imply group_grm_officer", - ) + self.assertIn(implied_group.id, implied_ids, "group_grm_manager should imply group_grm_officer") def test_group_grm_supervisor_implies(self): """Test group_grm_supervisor implies correct groups.""" @@ -201,11 +179,7 @@ def test_group_grm_supervisor_implies(self): implied_ids = group.implied_ids.mapped("id") implied_group = self.env.ref("spp_grm.group_grm_officer", raise_if_not_found=False) if implied_group: - self.assertIn( - implied_group.id, - implied_ids, - "group_grm_supervisor should imply group_grm_officer", - ) + self.assertIn(implied_group.id, implied_ids, "group_grm_supervisor should imply group_grm_officer") def test_group_grm_user_implies(self): """Test group_grm_user implies correct groups.""" @@ -215,11 +189,7 @@ def test_group_grm_user_implies(self): implied_ids = group.implied_ids.mapped("id") implied_group = self.env.ref("spp_grm.group_grm_viewer", raise_if_not_found=False) if implied_group: - self.assertIn( - implied_group.id, - implied_ids, - "group_grm_user should imply group_grm_viewer", - ) + self.assertIn(implied_group.id, implied_ids, "group_grm_user should imply group_grm_viewer") @tagged("post_install", "-at_install", "access_control", "compliance") @@ -614,11 +584,7 @@ def test_rule_rule_spp_grm_ticket_viewer_exists(self): self.skipTest("Rule rule_spp_grm_ticket_viewer not found") # Verify rule model - self.assertEqual( - rule.model_id.model, - "spp.grm.ticket", - "Rule should be for model spp.grm.ticket", - ) + self.assertEqual(rule.model_id.model, "spp.grm.ticket", "Rule should be for model spp.grm.ticket") # Verify permissions self.assertEqual(rule.perm_read, True) @@ -640,11 +606,7 @@ def test_rule_rule_spp_grm_ticket_viewer_groups(self): expected_group = self.env.ref("spp_grm.group_grm_viewer", raise_if_not_found=False) if expected_group: - self.assertIn( - expected_group, - rule.groups, - "Rule should include group group_grm_viewer", - ) + self.assertIn(expected_group, rule.groups, "Rule should include group group_grm_viewer") def test_rule_rule_spp_grm_ticket_officer_exists(self): """Test record rule rule_spp_grm_ticket_officer exists and is configured.""" @@ -653,11 +615,7 @@ def test_rule_rule_spp_grm_ticket_officer_exists(self): self.skipTest("Rule rule_spp_grm_ticket_officer not found") # Verify rule model - self.assertEqual( - rule.model_id.model, - "spp.grm.ticket", - "Rule should be for model spp.grm.ticket", - ) + self.assertEqual(rule.model_id.model, "spp.grm.ticket", "Rule should be for model spp.grm.ticket") # Verify permissions self.assertEqual(rule.perm_read, True) @@ -679,11 +637,7 @@ def test_rule_rule_spp_grm_ticket_officer_groups(self): expected_group = self.env.ref("spp_grm.group_grm_officer", raise_if_not_found=False) if expected_group: - self.assertIn( - expected_group, - rule.groups, - "Rule should include group group_grm_officer", - ) + self.assertIn(expected_group, rule.groups, "Rule should include group group_grm_officer") def test_rule_rule_spp_grm_ticket_supervisor_exists(self): """Test record rule rule_spp_grm_ticket_supervisor exists and is configured.""" @@ -692,11 +646,7 @@ def test_rule_rule_spp_grm_ticket_supervisor_exists(self): self.skipTest("Rule rule_spp_grm_ticket_supervisor not found") # Verify rule model - self.assertEqual( - rule.model_id.model, - "spp.grm.ticket", - "Rule should be for model spp.grm.ticket", - ) + self.assertEqual(rule.model_id.model, "spp.grm.ticket", "Rule should be for model spp.grm.ticket") # Verify permissions self.assertEqual(rule.perm_read, True) @@ -718,11 +668,7 @@ def test_rule_rule_spp_grm_ticket_supervisor_groups(self): expected_group = self.env.ref("spp_grm.group_grm_supervisor", raise_if_not_found=False) if expected_group: - self.assertIn( - expected_group, - rule.groups, - "Rule should include group group_grm_supervisor", - ) + self.assertIn(expected_group, rule.groups, "Rule should include group group_grm_supervisor") def test_rule_rule_spp_grm_ticket_manager_exists(self): """Test record rule rule_spp_grm_ticket_manager exists and is configured.""" @@ -731,11 +677,7 @@ def test_rule_rule_spp_grm_ticket_manager_exists(self): self.skipTest("Rule rule_spp_grm_ticket_manager not found") # Verify rule model - self.assertEqual( - rule.model_id.model, - "spp.grm.ticket", - "Rule should be for model spp.grm.ticket", - ) + self.assertEqual(rule.model_id.model, "spp.grm.ticket", "Rule should be for model spp.grm.ticket") # Verify permissions self.assertEqual(rule.perm_read, True) @@ -757,11 +699,7 @@ def test_rule_rule_spp_grm_ticket_manager_groups(self): expected_group = self.env.ref("spp_grm.group_grm_manager", raise_if_not_found=False) if expected_group: - self.assertIn( - expected_group, - rule.groups, - "Rule should include group group_grm_manager", - ) + self.assertIn(expected_group, rule.groups, "Rule should include group group_grm_manager") def test_rule_rule_spp_grm_team_manager_exists(self): """Test record rule rule_spp_grm_team_manager exists and is configured.""" @@ -792,11 +730,7 @@ def test_rule_rule_spp_grm_team_manager_groups(self): expected_group = self.env.ref("spp_grm.group_grm_manager", raise_if_not_found=False) if expected_group: - self.assertIn( - expected_group, - rule.groups, - "Rule should include group group_grm_manager", - ) + self.assertIn(expected_group, rule.groups, "Rule should include group group_grm_manager") def test_rule_rule_spp_grm_ticket_category_manager_exists(self): """Test record rule rule_spp_grm_ticket_category_manager exists and is configured.""" @@ -806,9 +740,7 @@ def test_rule_rule_spp_grm_ticket_category_manager_exists(self): # Verify rule model self.assertEqual( - rule.model_id.model, - "spp.grm.ticket.category", - "Rule should be for model spp.grm.ticket.category", + rule.model_id.model, "spp.grm.ticket.category", "Rule should be for model spp.grm.ticket.category" ) # Verify permissions @@ -831,11 +763,7 @@ def test_rule_rule_spp_grm_ticket_category_manager_groups(self): expected_group = self.env.ref("spp_grm.group_grm_manager", raise_if_not_found=False) if expected_group: - self.assertIn( - expected_group, - rule.groups, - "Rule should include group group_grm_manager", - ) + self.assertIn(expected_group, rule.groups, "Rule should include group group_grm_manager") def test_rule_rule_spp_grm_ticket_stage_manager_exists(self): """Test record rule rule_spp_grm_ticket_stage_manager exists and is configured.""" @@ -844,11 +772,7 @@ def test_rule_rule_spp_grm_ticket_stage_manager_exists(self): self.skipTest("Rule rule_spp_grm_ticket_stage_manager not found") # Verify rule model - self.assertEqual( - rule.model_id.model, - "spp.grm.ticket.stage", - "Rule should be for model spp.grm.ticket.stage", - ) + self.assertEqual(rule.model_id.model, "spp.grm.ticket.stage", "Rule should be for model spp.grm.ticket.stage") # Verify permissions self.assertEqual(rule.perm_read, True) @@ -870,11 +794,7 @@ def test_rule_rule_spp_grm_ticket_stage_manager_groups(self): expected_group = self.env.ref("spp_grm.group_grm_manager", raise_if_not_found=False) if expected_group: - self.assertIn( - expected_group, - rule.groups, - "Rule should include group group_grm_manager", - ) + self.assertIn(expected_group, rule.groups, "Rule should include group group_grm_manager") def test_rule_rule_spp_grm_sla_rule_manager_exists(self): """Test record rule rule_spp_grm_sla_rule_manager exists and is configured.""" @@ -883,11 +803,7 @@ def test_rule_rule_spp_grm_sla_rule_manager_exists(self): self.skipTest("Rule rule_spp_grm_sla_rule_manager not found") # Verify rule model - self.assertEqual( - rule.model_id.model, - "spp.grm.sla.rule", - "Rule should be for model spp.grm.sla.rule", - ) + self.assertEqual(rule.model_id.model, "spp.grm.sla.rule", "Rule should be for model spp.grm.sla.rule") # Verify permissions self.assertEqual(rule.perm_read, True) @@ -909,11 +825,7 @@ def test_rule_rule_spp_grm_sla_rule_manager_groups(self): expected_group = self.env.ref("spp_grm.group_grm_manager", raise_if_not_found=False) if expected_group: - self.assertIn( - expected_group, - rule.groups, - "Rule should include group group_grm_manager", - ) + self.assertIn(expected_group, rule.groups, "Rule should include group group_grm_manager") @tagged("post_install", "-at_install", "access_control", "compliance") @@ -925,8 +837,7 @@ def test_admin_has_manager_group(self): if not self.user_admin: self.skipTest("Admin user not created") self.assertTrue( - self.user_admin.has_group("spp_grm.group_grm_manager"), - "Admin should have group_grm_manager permissions", + self.user_admin.has_group("spp_grm.group_grm_manager"), "Admin should have group_grm_manager permissions" ) def test_admin_group_implies_manager(self): @@ -951,7 +862,5 @@ def get_all_implied(group, visited=None): all_implied = get_all_implied(admin_group) self.assertIn( - manager_group.id, - all_implied, - "Admin group should imply group_grm_manager (directly or transitively)", + manager_group.id, all_implied, "Admin group should imply group_grm_manager (directly or transitively)" ) diff --git a/spp_grm/tests/test_grm_security.py b/spp_grm/tests/test_grm_security.py index 6f699878..e653375e 100644 --- a/spp_grm/tests/test_grm_security.py +++ b/spp_grm/tests/test_grm_security.py @@ -57,24 +57,15 @@ def setUpClass(cls): def test_group_hierarchy_manager_has_officer(self): """Test manager inherits officer permissions.""" - self.assertTrue( - self.manager.has_group("spp_grm.group_grm_officer"), - "Manager should have officer permissions", - ) + self.assertTrue(self.manager.has_group("spp_grm.group_grm_officer"), "Manager should have officer permissions") def test_group_hierarchy_manager_has_viewer(self): """Test manager inherits viewer permissions.""" - self.assertTrue( - self.manager.has_group("spp_grm.group_grm_viewer"), - "Manager should have viewer permissions", - ) + self.assertTrue(self.manager.has_group("spp_grm.group_grm_viewer"), "Manager should have viewer permissions") def test_group_hierarchy_officer_has_viewer(self): """Test officer inherits viewer permissions.""" - self.assertTrue( - self.officer.has_group("spp_grm.group_grm_viewer"), - "Officer should have viewer permissions", - ) + self.assertTrue(self.officer.has_group("spp_grm.group_grm_viewer"), "Officer should have viewer permissions") def test_admin_has_manager(self): """Test OpenSPP admin has GRM manager access.""" @@ -87,10 +78,7 @@ def test_admin_has_manager(self): ], } ) - self.assertTrue( - admin.has_group("spp_grm.group_grm_manager"), - "Admin should have GRM manager access", - ) + self.assertTrue(admin.has_group("spp_grm.group_grm_manager"), "Admin should have GRM manager access") def test_user_sees_own_tickets(self): """Test user sees own tickets.""" diff --git a/spp_grm/views/grm_ticket_menu.xml b/spp_grm/views/grm_ticket_menu.xml index d7921c33..6dc40e4c 100644 --- a/spp_grm/views/grm_ticket_menu.xml +++ b/spp_grm/views/grm_ticket_menu.xml @@ -5,7 +5,7 @@ id="spp_grm_ticket_main_menu" name="Helpdesk" sequence="16" - web_icon="spp_grm,static/description/icon.png" + web_icon="spp_grm,static/description/OpenSPP-Helpdesk2-Icons.png" /> + + + + + + + + +
+
+ diff --git a/spp_grm_cel/views/grm_routing_rule_views.xml b/spp_grm_cel/views/grm_routing_rule_views.xml new file mode 100644 index 00000000..d8627817 --- /dev/null +++ b/spp_grm_cel/views/grm_routing_rule_views.xml @@ -0,0 +1,177 @@ + + + + + spp.grm.routing.rule.tree + spp.grm.routing.rule + + + + + + + + + + + + + + + + + + spp.grm.routing.rule.form + spp.grm.routing.rule + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + spp.grm.routing.rule.search + spp.grm.routing.rule + + + + + + + + + + + + + + + + + + + + Routing Rules + spp.grm.routing.rule + list,form + {'search_default_active': 1} + +

+ Create a new routing rule +

+

+ Routing rules automatically assign tickets to teams and users based on CEL conditions. + Rules are evaluated in sequence order, and only the first matching rule is applied. +

+
+
+ + + +
diff --git a/spp_grm_demo/README.md b/spp_grm_demo/README.md index a0fcc1ca..4d8481a7 100644 --- a/spp_grm_demo/README.md +++ b/spp_grm_demo/README.md @@ -19,9 +19,15 @@ data that simulates real-world grievance cases. ## Dependencies -- `spp_demo_common` - Core demo data infrastructure Optional: `spp_demo_scenarios` - - Scenario library and loader (not required; falls back to built-in samples) +- `spp_demo` - Consolidated demo module (creates registrants, programs) - `spp_grm` - Grievance Redress Mechanism module +- `spp_grm_registry` - GRM registry integration (registrant/household linking, repeat + detection) +- `spp_grm_programs` - GRM program integration (program/entitlement linking) +- `spp_security` - Security roles and access control + +Optional: `spp_demo_scenarios` - Scenario library and loader (not required; falls back +to built-in samples) ## Installation @@ -118,6 +124,30 @@ consistent cross-module demos: | Rosa Garcia | Food delivery schedule | Elderly Pension + Food | Garcia Elder Care | | Carlos Morales | Adding new child to grant | Universal Child Grant | Morales Household Crisis | +### Repeat Ticket Detection + +The demo generator creates realistic repeat ticket patterns for the `spp_grm_registry` +repeat detection feature: + +- **Repeat Pool**: ~10% of beneficiaries are selected as "repeat filers" who get + assigned to multiple tickets +- **Biased Selection**: ~30% of volume tickets are assigned from the repeat pool, + ensuring some registrants have 2-5+ tickets within 6 months +- **Registry Fields**: When `spp_grm_registry` is installed, tickets automatically get + `registrant_id`, `household_id`, and `area_id` populated +- **Story Repeats**: Ahmed Said has 3 story tickets, demonstrating a repeat filer + pattern +- **Previous Tickets Tab**: Repeat tickets show a "Previous Tickets" tab and stat button + on the ticket form + +This makes the following features visible in the demo: + +- Repeat ticket badge (warning alert on form) +- Previous Tickets stat button with count +- "Is Repeat" column in list view +- "Repeat Tickets" search filter +- "Repeat Status" group-by option + ### GRM-to-Case Escalation Story tickets can be marked for case escalation (`escalate_to_case: True`), diff --git a/spp_grm_demo/README.rst b/spp_grm_demo/README.rst index 5fee4485..be88bc6d 100644 --- a/spp_grm_demo/README.rst +++ b/spp_grm_demo/README.rst @@ -10,9 +10,9 @@ OpenSPP GRM Demo Data !! source digest: sha256:e06d51d82fd6af706b02cdfeac83514a24586b5176e2052f2fbc269ff30eccda !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png :target: https://odoo-community.org/page/development-status - :alt: Alpha + :alt: Production/Stable .. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 @@ -111,10 +111,6 @@ Dependencies ``spp_demo``, ``spp_grm``, ``spp_security`` -.. IMPORTANT:: - This is an alpha version, the data model and design can change at any time without warning. - Only for development or testing purpose, do not use in production. - **Table of contents** .. contents:: @@ -147,10 +143,13 @@ Maintainers .. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px :target: https://github.com/gonzalesedwin1123 :alt: gonzalesedwin1123 +.. |maintainer-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 Current maintainers: -|maintainer-jeremi| |maintainer-gonzalesedwin1123| +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-emjay0921| This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. diff --git a/spp_grm_demo/__manifest__.py b/spp_grm_demo/__manifest__.py index 88d4e954..a5f53faa 100644 --- a/spp_grm_demo/__manifest__.py +++ b/spp_grm_demo/__manifest__.py @@ -3,22 +3,25 @@ { "name": "OpenSPP GRM Demo Data", - "version": "19.0.2.0.0", + "version": "19.0.1.0.0", "category": "OpenSPP/Monitoring", "summary": "Demo data generator for Grievance Redress Mechanism", "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", "license": "LGPL-3", - "development_status": "Alpha", - "maintainers": ["jeremi", "gonzalesedwin1123"], + "development_status": "Production/Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], "depends": [ "spp_demo", # Consolidated demo module "spp_grm", + "spp_grm_registry", + "spp_grm_programs", "spp_security", ], - "external_dependencies": {"python": []}, + "external_dependencies": {"python": ["faker"]}, "data": [ "security/ir.model.access.csv", + "data/demo_users.xml", "data/ticket_categories.xml", "views/grm_demo_wizard_view.xml", ], diff --git a/spp_grm_demo/data/demo_users.xml b/spp_grm_demo/data/demo_users.xml new file mode 100644 index 00000000..0fd103df --- /dev/null +++ b/spp_grm_demo/data/demo_users.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + Demo GRM Officer + local + Demo role for GRM officers. Can create and manage own/team tickets. + + + + + + Demo GRM Manager + demo_grm_manager + demo_grm_manager@demo.spp + demo + + + + + + + + Demo GRM Officer + demo_grm_officer + demo_grm_officer@demo.spp + demo + + + + + diff --git a/spp_grm_demo/models/generate_tickets.py b/spp_grm_demo/models/generate_tickets.py index 6b30948d..a8053cb3 100644 --- a/spp_grm_demo/models/generate_tickets.py +++ b/spp_grm_demo/models/generate_tickets.py @@ -51,7 +51,6 @@ "Wants to understand requirements and application process." ), "category": "eligibility", - "ticket_type": "inquiry", "priority": "low", "days_back": 45, # Before her case was opened "program_name": "Universal Child Grant", @@ -78,7 +77,6 @@ "Needs assistance with Emergency Relief Fund and permanent resettlement." ), "category": "service", - "ticket_type": "inquiry", "priority": "medium", "severity": "high", "days_back": 65, # Before his case was opened @@ -150,7 +148,6 @@ "Need funds for wheelchair and medical equipment." ), "category": "eligibility", - "ticket_type": "inquiry", "priority": "medium", "days_back": 105, # Before case was opened "program_name": "Disability Support Grant", @@ -177,7 +174,6 @@ "Wants to understand next steps and any continued support available." ), "category": "general", - "ticket_type": "inquiry", "priority": "low", "days_back": 10, "program_name": "Cash Transfer Program", @@ -203,7 +199,6 @@ "Also enrolled in Elderly Social Pension." ), "category": "service", - "ticket_type": "inquiry", "priority": "low", "days_back": 50, "program_name": "Food Assistance", @@ -251,9 +246,8 @@ class SPPGRMDemoGenerator(models.TransientModel): _name = "spp.grm.demo.generator" _description = "GRM Demo Data Generator" - name = fields.Char(string="Name", default="GRM Demo Data", required=True) + name = fields.Char(default="GRM Demo Data", required=True) enroll_demo_stories = fields.Boolean( - string="Enroll Demo Stories", default=True, help="Generate tickets based on demo stories (Juan Dela Cruz, Ibrahim Hassan, etc.)", ) @@ -263,16 +257,6 @@ class SPPGRMDemoGenerator(models.TransientModel): help="Generate volume tickets in addition to story tickets", ) number_of_tickets = fields.Integer(string="Number of Tickets", default=50, required=True) - use_scenarios = fields.Boolean(string="Use Scenarios", default=True) - scenario_ids = fields.Many2many( - # Placeholder - actual model spp.demo.scenario.registry may not be installed - comodel_name="res.partner", - string="Scenarios", - help=( - "Select specific scenarios to use. If empty, all GRM ticket scenarios will be used. " - "Note: This field requires spp_demo_scenarios module." - ), - ) tickets_days_back = fields.Integer( string="Days Back", default=90, @@ -291,7 +275,6 @@ class SPPGRMDemoGenerator(models.TransientModel): help="Percentage of tickets that should be escalated (of unresolved tickets)", ) assign_teams = fields.Boolean( - string="Assign Teams", default=True, help="Assign tickets to random GRM teams", ) @@ -317,7 +300,6 @@ class SPPGRMDemoGenerator(models.TransientModel): ) locale_origin = fields.Many2one( "res.country", - string="Locale Origin", default=lambda self: self.env.user.company_id.country_id or self.env.ref("base.us"), help="Country for Faker locale", ) @@ -398,6 +380,14 @@ def generate_tickets(self): if self.assign_teams: _logger.info("Found %d teams for assignment", len(teams)) + # Build repeat pool: ~10% of beneficiaries will file multiple tickets + repeat_pool = self._build_repeat_pool(beneficiaries) if beneficiaries else [] + if repeat_pool: + _logger.info( + "Built repeat pool of %d registrants for repeat ticket detection", + len(repeat_pool), + ) + # Generate volume tickets for i in range(self.number_of_tickets): try: @@ -411,6 +401,7 @@ def generate_tickets(self): beneficiaries, programs, teams, + repeat_pool=repeat_pool, ) if ticket: @@ -433,7 +424,7 @@ def generate_tickets(self): "type": "ir.actions.act_window", "name": "Generated GRM Tickets", "res_model": "spp.grm.ticket", - "view_mode": "tree,form,kanban", + "view_mode": "list,form,kanban", "domain": [("id", "in", created_tickets.ids)], "context": {"search_default_group_by_stage": 1}, } @@ -481,12 +472,7 @@ def _generate_story_tickets(self, fake): if ticket: created_tickets |= ticket except Exception as e: - _logger.error( - "Error creating story ticket for %s: %s", - story_id, - e, - exc_info=True, - ) + _logger.error("Error creating story ticket for %s: %s", story_id, e, exc_info=True) continue return created_tickets @@ -524,7 +510,9 @@ def _create_story_ticket(self, ticket_def, partner, fake): "create_date": ticket_date, } - # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin + # Set registrant/household fields if spp_grm_registry is installed + vals.update(self._get_registrant_vals(partner)) + ticket = self.env["spp.grm.ticket"].sudo().create(vals) # Backdate creation @@ -548,8 +536,8 @@ def _apply_story_resolution(self, ticket, resolution, ticket_date): for i, note in enumerate(notes): note_date = ticket_date + timedelta(days=int((i + 1) * resolution_days / (len(notes) + 1))) - ticket.sudo().message_post( # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin - body=f"

{note.get('text', '')}

", + ticket.sudo().message_post( + body=_("

%(note_text)s

", note_text=note.get("text", "")), message_type="comment", subtype_xmlid="mail.mt_note", ) @@ -567,7 +555,7 @@ def _apply_story_resolution(self, ticket, resolution, ticket_date): close_date = ticket_date + timedelta(days=resolution_days) decision = resolution.get("decision", "upheld") - ticket.sudo().write( # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin + ticket.sudo().write( { "stage_id": closed_stage.id, "decision": decision, @@ -586,21 +574,6 @@ def _load_scenarios(self): if loader: try: - if self.scenario_ids: - scenarios = [] - for scenario_record in self.scenario_ids: - try: - scenario_dict = loader._load_yaml_file(scenario_record.source_file) - scenarios.append(scenario_dict) - except Exception as e: - _logger.warning( - "Failed to load scenario %s: %s", - scenario_record.name, - e, - ) - if scenarios: - return scenarios - scenarios = loader.load_scenarios(category="grm_ticket") if scenarios: return scenarios @@ -646,7 +619,7 @@ def _select_weighted_scenario(self, scenarios): weights = [scenario.get("weight", 10) for scenario in scenarios] return random.choices(scenarios, weights=weights, k=1)[0] - def _create_ticket_from_scenario(self, scenario, fake, beneficiaries, programs, teams): + def _create_ticket_from_scenario(self, scenario, fake, beneficiaries, programs, teams, repeat_pool=None): """Create a GRM ticket from a scenario""" ticket_profile = scenario.get("ticket_profile", {}) description_templates = scenario.get("description_templates", []) @@ -654,8 +627,8 @@ def _create_ticket_from_scenario(self, scenario, fake, beneficiaries, programs, # Get ticket date (random within range) ticket_date = self._random_date_in_range(self.tickets_days_back) - # Get beneficiary - partner = self._select_random_beneficiary(beneficiaries) if beneficiaries else None + # Get beneficiary (biased toward repeat pool) + partner = self._select_random_beneficiary(beneficiaries, repeat_pool) if beneficiaries else None if not partner: _logger.warning("No beneficiary found, skipping ticket creation") return None @@ -701,7 +674,9 @@ def _create_ticket_from_scenario(self, scenario, fake, beneficiaries, programs, "create_date": ticket_date, } - # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin + # Set registrant/household fields if spp_grm_registry is installed + vals.update(self._get_registrant_vals(partner)) + ticket = self.env["spp.grm.ticket"].sudo().create(vals) # Backdate creation @@ -754,8 +729,8 @@ def _add_resolution_notes(self, ticket, resolution_path, fake): note_date = ticket.create_date + timedelta(days=days_offset) # Post message - ticket.sudo().message_post( # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin - body=f"

{step}

", + ticket.sudo().message_post( + body=_("

%(step)s

", step=step), message_type="comment", subtype_xmlid="mail.mt_note", ) @@ -773,8 +748,8 @@ def _add_escalation_note(self, ticket, scenario, fake): escalation = scenario.get("escalation", {}) case_type = escalation.get("case_type", "general_investigation") - ticket.sudo().message_post( # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin - body=f"

Ticket escalated to case management ({case_type})

", + ticket.sudo().message_post( + body=_("

Ticket escalated to case management (%(case_type)s)

", case_type=case_type), message_type="comment", subtype_xmlid="mail.mt_note", ) @@ -803,7 +778,7 @@ def _close_ticket(self, ticket, resolution_path): decision_choices, weights = zip(*decisions, strict=False) decision = random.choices(decision_choices, weights=weights, k=1)[0] - ticket.sudo().write( # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin + ticket.sudo().write( { "stage_id": closed_stage.id, "decision": decision, @@ -821,7 +796,6 @@ def _assign_ticket(self, ticket): users = self.env["res.users"].search([("active", "=", True)], limit=20) if users: user = random.choice(users) - # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin ticket.sudo().write({"user_id": user.id}) def _render_description_template(self, template, fake, programs): @@ -868,15 +842,74 @@ def _render_description_template(self, template, fake, programs): return result - def _select_random_beneficiary(self, beneficiaries): - """Select a random beneficiary from available registrants""" + def _build_repeat_pool(self, beneficiaries, pool_ratio=0.10): + """Select a subset of beneficiaries who will file multiple tickets. + + Args: + beneficiaries: Recordset of individual registrants + pool_ratio: Fraction of beneficiaries to use as repeat filers (default 10%) + + Returns: + list of partner records that should get multiple tickets + """ + if not beneficiaries: + return [] + pool_size = max(3, int(len(beneficiaries) * pool_ratio)) + return random.sample(list(beneficiaries), min(pool_size, len(beneficiaries))) + + def _select_random_beneficiary(self, beneficiaries, repeat_pool=None): + """Select a beneficiary, biased toward repeat pool. + + ~30% of the time picks from the repeat pool (if available), + ensuring some registrants get multiple tickets for repeat detection. + """ if not beneficiaries: return None + if repeat_pool and random.random() < 0.30: + return random.choice(repeat_pool) return random.choice(beneficiaries) def _get_available_beneficiaries(self): - """Get list of available beneficiaries (registrants)""" - return self.env["res.partner"].search([("is_registrant", "=", True)], limit=1000) + """Get list of available individual registrants (not groups). + + Returns individual registrants that can be used as complainants. + When spp_grm_registry is installed, these will be linked via + registrant_id and their household via household_id. + """ + return self.env["res.partner"].search( + [("is_registrant", "=", True), ("is_group", "=", False)], + limit=1000, + ) + + def _has_registry_fields(self): + """Check if spp_grm_registry is installed (registrant_id field exists).""" + return "registrant_id" in self.env["spp.grm.ticket"]._fields + + def _get_registrant_vals(self, partner): + """Get registrant_id, household_id, and area_id vals for a ticket. + + Args: + partner: Individual registrant (res.partner with is_group=False) + + Returns: + dict with registrant/household/area fields to merge into ticket vals + """ + if not self._has_registry_fields(): + return {} + + vals = {"registrant_id": partner.id} + + # Find household via membership + if hasattr(partner, "individual_membership_ids") and partner.individual_membership_ids: + household = partner.individual_membership_ids[0].group + if household: + vals["household_id"] = household.id + + # Fill area if available + if hasattr(partner, "area_id") and partner.area_id: + vals["area_id"] = partner.area_id.id + + return vals def _get_available_programs(self): """Get list of available programs""" @@ -907,7 +940,6 @@ def _get_or_create_category(self, category_name): except Exception: # Create new category category = ( - # nosemgrep: odoo-sudo-without-context — demo data generation runs as admin self.env["spp.grm.ticket.category"] .sudo() .create( diff --git a/spp_grm_demo/security/ir.model.access.csv b/spp_grm_demo/security/ir.model.access.csv index 8a3c57b5..26a48d52 100644 --- a/spp_grm_demo/security/ir.model.access.csv +++ b/spp_grm_demo/security/ir.model.access.csv @@ -1,2 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_grm_demo_generator_admin,access_spp_grm_demo_generator_admin,model_spp_grm_demo_generator,spp_security.group_spp_admin,1,1,1,1 access_spp_grm_demo_wizard_admin,access_spp_grm_demo_wizard_admin,model_spp_grm_demo_wizard,spp_security.group_spp_admin,1,1,1,1 diff --git a/spp_grm_demo/static/description/index.html b/spp_grm_demo/static/description/index.html index fda6418e..859035eb 100644 --- a/spp_grm_demo/static/description/index.html +++ b/spp_grm_demo/static/description/index.html @@ -369,7 +369,7 @@

OpenSPP GRM Demo Data

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:e06d51d82fd6af706b02cdfeac83514a24586b5176e2052f2fbc269ff30eccda !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

Demo data generator for the Grievance Redress Mechanism. Creates both story-based tickets linked to specific personas (Juan Dela Cruz, Ibrahim Hassan, Fatima Al-Rahman) and volume tickets using scenario templates. @@ -478,11 +478,6 @@

Integration

Dependencies

spp_demo, spp_grm, spp_security

-
-

Important

-

This is an alpha version, the data model and design can change at any time without warning. -Only for development or testing purpose, do not use in production.

-

Table of contents

    @@ -513,7 +508,7 @@

    Authors

    Maintainers

    Current maintainers:

    -

    jeremi gonzalesedwin1123

    +

    jeremi gonzalesedwin1123 emjay0921

    This module is part of the OpenSPP/OpenSPP2 project on GitHub.

    You are welcome to contribute.

    diff --git a/spp_grm_demo/tests/test_story_tickets.py b/spp_grm_demo/tests/test_story_tickets.py index 02284b57..fb7231b9 100644 --- a/spp_grm_demo/tests/test_story_tickets.py +++ b/spp_grm_demo/tests/test_story_tickets.py @@ -8,7 +8,7 @@ class TestStoryTicketDefinitions(TransactionCase): def test_grm_story_tickets_defined(self): """Test that GRM story tickets are properly defined.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS self.assertIsInstance(GRM_STORY_TICKETS, dict) self.assertGreater(len(GRM_STORY_TICKETS), 0) @@ -33,7 +33,7 @@ def test_grm_story_tickets_defined(self): def test_story_tickets_have_required_fields(self): """Test that all story tickets have required fields.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS required_fields = ["title", "description", "category", "priority", "days_back"] @@ -50,7 +50,7 @@ def test_story_tickets_have_required_fields(self): def test_juan_dela_cruz_story(self): """Test Juan Dela Cruz story ticket definition.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS juan = GRM_STORY_TICKETS.get("juan_dela_cruz") self.assertIsNotNone(juan) @@ -73,7 +73,7 @@ def test_juan_dela_cruz_story(self): def test_fatima_al_rahman_story(self): """Test Fatima Al-Rahman story ticket definition.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS fatima = GRM_STORY_TICKETS.get("fatima_al_rahman") self.assertIsNotNone(fatima) @@ -83,7 +83,6 @@ def test_fatima_al_rahman_story(self): ticket = tickets[0] self.assertEqual(ticket["title"], "How do I qualify for Universal Child Grant?") - self.assertEqual(ticket["ticket_type"], "inquiry") self.assertEqual(ticket["priority"], "low") self.assertEqual(ticket["program_name"], "Universal Child Grant") self.assertTrue(ticket.get("escalate_to_case")) # Should escalate to case assessment @@ -95,7 +94,7 @@ def test_fatima_al_rahman_story(self): def test_ibrahim_hassan_story(self): """Test Ibrahim Hassan story ticket definition.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS ibrahim = GRM_STORY_TICKETS.get("ibrahim_hassan") self.assertIsNotNone(ibrahim) @@ -105,14 +104,13 @@ def test_ibrahim_hassan_story(self): ticket = tickets[0] self.assertEqual(ticket["title"], "Request for resettlement support") - self.assertEqual(ticket["ticket_type"], "inquiry") # Should remain open (no resolution) self.assertIsNone(ticket.get("resolution")) def test_ahmed_said_story(self): """Test Ahmed Said story with multiple tickets.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS ahmed = GRM_STORY_TICKETS.get("ahmed_said") self.assertIsNotNone(ahmed) @@ -135,7 +133,7 @@ def test_ahmed_said_story(self): def test_valid_categories(self): """Test that all tickets use valid categories.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS valid_categories = [ "payment", @@ -157,7 +155,7 @@ def test_valid_categories(self): def test_valid_priorities(self): """Test that all tickets use valid priorities.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS valid_priorities = ["low", "medium", "high", "very_high"] @@ -172,7 +170,7 @@ def test_valid_priorities(self): def test_valid_decisions(self): """Test that all resolutions use valid decisions.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS valid_decisions = [ "upheld", @@ -195,7 +193,7 @@ def test_valid_decisions(self): def test_days_back_positive(self): """Test that all days_back values are positive.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS for story_id, story_data in GRM_STORY_TICKETS.items(): for ticket in story_data.get("tickets", []): @@ -208,7 +206,7 @@ def test_days_back_positive(self): def test_resolution_notes_have_text(self): """Test that resolution notes have text content.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS for story_id, story_data in GRM_STORY_TICKETS.items(): for ticket in story_data.get("tickets", []): @@ -229,7 +227,7 @@ def test_resolution_notes_have_text(self): def test_story_alignment_with_demo_stories(self): """Test that GRM stories match demo_stories.py personas.""" - from odoo.addons.spp_grm_demo.models.generate_tickets import GRM_STORY_TICKETS + from ..models.generate_tickets import GRM_STORY_TICKETS # These should match the names in spp_demo/models/demo_stories.py # and spp_mis_demo_v2/data/demo_personas.xml diff --git a/spp_grm_programs/README.md b/spp_grm_programs/README.md new file mode 100644 index 00000000..c88289db --- /dev/null +++ b/spp_grm_programs/README.md @@ -0,0 +1,98 @@ +# OpenSPP GRM Programs Integration + +This module integrates the OpenSPP Grievance Redress Mechanism (GRM) with the Programs +module, allowing GRM tickets to be linked to programs, entitlements, and payments. + +## Features + +### Program Linkage + +- Link GRM tickets to specific programs +- Link to program memberships (enrollments) +- Link to specific program cycles +- Link to entitlements being disputed +- Link to payments being disputed + +### Auto-fill Functionality + +- When a registrant and program are selected, automatically finds and suggests the + program membership +- When a membership is selected, automatically fills the program and registrant +- When a cycle is selected, automatically fills the program +- When an entitlement is selected, automatically fills the cycle, program, and + registrant +- When a payment is selected, automatically fills the entitlement, cycle, and registrant + +### Computed Information + +- Display enrollment status from program membership +- Display entitlement amount +- Display payment amount + +### Enhanced Views + +- Added "Program Information" section in ticket form view +- Added stat buttons to quickly navigate to related program, entitlement, or payment +- Added program-related fields to tree view (optional columns) +- Added search filters for tickets with programs, entitlements, or payments +- Added group by options for program, cycle, and enrollment status +- Display program information in kanban cards + +## Dependencies + +- `spp_grm`: Base GRM module +- `spp_programs_base`: Base programs module + +## Usage + +### Creating a Program-Related Ticket + +1. Create or edit a GRM ticket +2. In the "Program Information" section, select the related program +3. Optionally select the program membership, cycle, entitlement, or payment +4. The system will auto-fill related fields based on your selection + +### Viewing Related Records + +- Use the stat buttons in the ticket header to quickly navigate to the related program, + entitlement, or payment +- The computed fields show key information without needing to open the related records + +### Filtering and Grouping + +- Use the search filters to find tickets related to programs, entitlements, or payments +- Group tickets by program, cycle, or enrollment status for better organization + +## Technical Details + +### Model Extensions + +- Extends `spp.grm.ticket` with the following fields: + - `program_id`: Many2one to `spp.program` + - `program_membership_id`: Many2one to `spp.program_membership` + - `cycle_id`: Many2one to `spp.cycle` + - `entitlement_id`: Many2one to `spp.entitlement` + - `payment_id`: Many2one to `spp.payment` + - `enrollment_status`: Computed Char field + - `entitlement_amount`: Computed Float field + - `payment_amount`: Computed Float field + +### Methods + +- `_compute_program_info()`: Computes enrollment status and amounts +- `_onchange_program_membership()`: Auto-fills membership +- `_onchange_membership()`: Auto-fills program and registrant +- `_onchange_cycle()`: Auto-fills program +- `_onchange_entitlement()`: Auto-fills cycle, program, and registrant +- `_onchange_payment()`: Auto-fills entitlement, cycle, and registrant +- `action_view_program()`: Opens the related program form +- `action_view_entitlement()`: Opens the related entitlement form +- `action_view_payment()`: Opens the related payment form + +## License + +LGPL-3 + +## Author + +OpenSPP.org diff --git a/spp_grm_programs/README.rst b/spp_grm_programs/README.rst new file mode 100644 index 00000000..8b4ca133 --- /dev/null +++ b/spp_grm_programs/README.rst @@ -0,0 +1,143 @@ +================================ +OpenSPP GRM Programs Integration +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4fb795bbfab51647d271be873b470c596c76ff2a93c16c18ff8e446ca299e746 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_grm_programs + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Bridge module linking GRM tickets to program records. Auto-installs when +both ``spp_grm`` and ``spp_programs`` are present. Enables tracking of +program-specific grievances through relational links to programs, +enrollments, cycles, entitlements, and payments with automatic field +population and computed status/amount displays. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Link tickets to programs, enrollments, cycles, entitlements, and + payments via Many2one fields +- Auto-populate related fields based on record relationships (selecting + payment fills entitlement, cycle, and registrant) +- Display computed enrollment status and monetary amounts from linked + records +- Navigate to program records via stat buttons on ticket form +- Filter and group tickets by program, cycle, or enrollment status + +Models Extended +~~~~~~~~~~~~~~~ + +================== =============================================== +Model Description +================== =============================================== +``spp.grm.ticket`` Adds 5 relational and 3 computed program fields +================== =============================================== + +New Fields on ``spp.grm.ticket`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Relational fields:** + +- ``program_id`` → ``spp.program`` +- ``program_membership_id`` → ``spp.program.membership`` +- ``cycle_id`` → ``spp.cycle`` +- ``entitlement_id`` → ``spp.entitlement`` +- ``payment_id`` → ``spp.payment`` + +**Computed fields (stored):** + +- ``enrollment_status``: Current state from program membership +- ``entitlement_amount``: Amount from linked entitlement +- ``payment_amount``: Amount from linked payment + +UI Location +~~~~~~~~~~~ + +- **Menu**: Helpdesk > Tickets (no new menus, extends existing ticket + views) +- **Form**: "Program Information" section below ticket details with stat + buttons for linked records +- **Search**: Filters for "Has Program", "Has Entitlement", "Has + Payment"; group by Program, Cycle, Enrollment Status +- **Tree/Kanban**: Program fields available as optional columns and card + elements + +Security +~~~~~~~~ + +No additional access rules defined. Inherits all security from +``spp_grm``. Users with read/write access to GRM tickets can read/write +program links. + +Extension Points +~~~~~~~~~~~~~~~~ + +- Override ``_compute_program_info()`` to customize enrollment status + and amount extraction logic +- Extend ``_onchange_*`` methods to add domain-specific auto-fill + behavior + +Dependencies +~~~~~~~~~~~~ + +``spp_security``, ``spp_grm``, ``spp_programs`` + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 +.. |maintainer-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-emjay0921| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_grm_programs/__init__.py b/spp_grm_programs/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/spp_grm_programs/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/spp_grm_programs/__manifest__.py b/spp_grm_programs/__manifest__.py new file mode 100644 index 00000000..af39d359 --- /dev/null +++ b/spp_grm_programs/__manifest__.py @@ -0,0 +1,25 @@ +# pylint: disable=pointless-statement + +{ + "name": "OpenSPP GRM Programs Integration", + "version": "19.0.1.0.0", + "category": "OpenSPP/Monitoring", + "summary": "Link GRM tickets to OpenSPP programs, entitlements, and payments", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Production/Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "depends": [ + "spp_security", + "spp_grm", + "spp_programs", + ], + "data": [ + "security/ir.model.access.csv", + "views/grm_ticket_views.xml", + ], + "installable": True, + "application": False, + "auto_install": True, +} diff --git a/spp_grm_programs/models/__init__.py b/spp_grm_programs/models/__init__.py new file mode 100644 index 00000000..400ce596 --- /dev/null +++ b/spp_grm_programs/models/__init__.py @@ -0,0 +1 @@ +from . import grm_ticket diff --git a/spp_grm_programs/models/grm_ticket.py b/spp_grm_programs/models/grm_ticket.py new file mode 100644 index 00000000..c6a229ab --- /dev/null +++ b/spp_grm_programs/models/grm_ticket.py @@ -0,0 +1,204 @@ +from odoo import api, fields, models + + +class GRMTicketPrograms(models.Model): + """Extend GRM Ticket with program-related fields.""" + + _inherit = "spp.grm.ticket" + + # Program-related fields + program_id = fields.Many2one( + "spp.program", + string="Related Program", + help="Program related to this complaint", + index=True, + ) + program_membership_id = fields.Many2one( + "spp.program.membership", + string="Program Membership", + help="Specific program enrollment related to this complaint", + index=True, + ) + cycle_id = fields.Many2one( + "spp.cycle", + string="Program Cycle", + help="Specific cycle related to this complaint", + index=True, + ) + entitlement_id = fields.Many2one( + "spp.entitlement", + string="Related Entitlement", + help="Specific entitlement being disputed", + index=True, + ) + payment_id = fields.Many2one( + "spp.payment", + string="Related Payment", + help="Specific payment being disputed", + index=True, + ) + + # Computed information from related records + enrollment_status = fields.Char( + compute="_compute_program_info", + store=True, + help="Current status of the program enrollment", + ) + entitlement_amount = fields.Float( + compute="_compute_program_info", + store=True, + help="Amount of the related entitlement", + ) + payment_amount = fields.Float( + compute="_compute_program_info", + store=True, + help="Amount of the related payment", + ) + + @api.depends("program_membership_id", "entitlement_id", "payment_id") + def _compute_program_info(self): + """Compute program-related information from linked records.""" + for ticket in self: + # Compute enrollment status + if ticket.program_membership_id: + # Get the state selection field + state_field = ticket.program_membership_id._fields.get("state") + if state_field and hasattr(state_field, "selection"): + selection_dict = dict(state_field.selection) + ticket.enrollment_status = selection_dict.get(ticket.program_membership_id.state, "") + else: + ticket.enrollment_status = ticket.program_membership_id.state or "" + else: + ticket.enrollment_status = "" + + # Compute entitlement amount + if ticket.entitlement_id: + # Check for initial_amount or amount fields + if hasattr(ticket.entitlement_id, "initial_amount"): + ticket.entitlement_amount = ticket.entitlement_id.initial_amount + elif hasattr(ticket.entitlement_id, "amount"): + ticket.entitlement_amount = ticket.entitlement_id.amount + else: + ticket.entitlement_amount = 0.0 + else: + ticket.entitlement_amount = 0.0 + + # Compute payment amount + if ticket.payment_id: + # Check for amount_issued or amount fields + if hasattr(ticket.payment_id, "amount_issued"): + ticket.payment_amount = ticket.payment_id.amount_issued + elif hasattr(ticket.payment_id, "amount"): + ticket.payment_amount = ticket.payment_id.amount + else: + ticket.payment_amount = 0.0 + else: + ticket.payment_amount = 0.0 + + @api.onchange("registrant_id", "program_id") + def _onchange_program_membership(self): + """Auto-fill membership when registrant and program are set.""" + if self.registrant_id and self.program_id: + membership = self.env["spp.program.membership"].search( + [ + ("partner_id", "=", self.registrant_id.id), + ("program_id", "=", self.program_id.id), + ], + limit=1, + ) + if membership: + self.program_membership_id = membership.id + + @api.onchange("program_membership_id") + def _onchange_membership(self): + """Auto-fill program and registrant when membership is set.""" + if self.program_membership_id: + self.program_id = self.program_membership_id.program_id.id + # Only set registrant if not already set + if not self.registrant_id: + self.registrant_id = self.program_membership_id.partner_id.id + + @api.onchange("cycle_id") + def _onchange_cycle(self): + """Auto-fill program and clear downstream fields when cycle changes.""" + self.entitlement_id = False + self.payment_id = False + if self.cycle_id and not self.program_id: + self.program_id = self.cycle_id.program_id.id + + @api.onchange("entitlement_id") + def _onchange_entitlement(self): + """Auto-fill related fields when entitlement is set.""" + self.payment_id = False + if self.entitlement_id: + # Set cycle and program if available + if hasattr(self.entitlement_id, "cycle_id") and self.entitlement_id.cycle_id: + self.cycle_id = self.entitlement_id.cycle_id.id + if hasattr(self.entitlement_id.cycle_id, "program_id") and not self.program_id: + self.program_id = self.entitlement_id.cycle_id.program_id.id + + # Set registrant if available + if hasattr(self.entitlement_id, "partner_id") and self.entitlement_id.partner_id and not self.registrant_id: + self.registrant_id = self.entitlement_id.partner_id.id + + @api.onchange("payment_id") + def _onchange_payment(self): + """Auto-fill related fields when payment is set.""" + if self.payment_id: + # Set entitlement if available + if ( + hasattr(self.payment_id, "entitlement_id") + and self.payment_id.entitlement_id + and not self.entitlement_id + ): + self.entitlement_id = self.payment_id.entitlement_id.id + + # Set cycle if available + if hasattr(self.payment_id, "cycle_id") and self.payment_id.cycle_id: + self.cycle_id = self.payment_id.cycle_id.id + + # Set registrant if available + if hasattr(self.payment_id, "partner_id") and self.payment_id.partner_id and not self.registrant_id: + self.registrant_id = self.payment_id.partner_id.id + + def action_view_program(self): + """Open the related program.""" + self.ensure_one() + if not self.program_id: + return False + return { + "type": "ir.actions.act_window", + "name": "Program", + "res_model": "spp.program", + "res_id": self.program_id.id, + "view_mode": "form", + "target": "current", + } + + def action_view_entitlement(self): + """Open the related entitlement.""" + self.ensure_one() + if not self.entitlement_id: + return False + return { + "type": "ir.actions.act_window", + "name": "Entitlement", + "res_model": "spp.entitlement", + "res_id": self.entitlement_id.id, + "view_mode": "form", + "target": "current", + } + + def action_view_payment(self): + """Open the related payment.""" + self.ensure_one() + if not self.payment_id: + return False + return { + "type": "ir.actions.act_window", + "name": "Payment", + "res_model": "spp.payment", + "res_id": self.payment_id.id, + "view_mode": "form", + "target": "current", + } diff --git a/spp_grm_programs/pyproject.toml b/spp_grm_programs/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_grm_programs/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_grm_programs/readme/DESCRIPTION.md b/spp_grm_programs/readme/DESCRIPTION.md new file mode 100644 index 00000000..b425ef2f --- /dev/null +++ b/spp_grm_programs/readme/DESCRIPTION.md @@ -0,0 +1,49 @@ +Bridge module linking GRM tickets to program records. Auto-installs when both `spp_grm` and `spp_programs` are present. Enables tracking of program-specific grievances through relational links to programs, enrollments, cycles, entitlements, and payments with automatic field population and computed status/amount displays. + +### Key Capabilities + +- Link tickets to programs, enrollments, cycles, entitlements, and payments via Many2one fields +- Auto-populate related fields based on record relationships (selecting payment fills entitlement, cycle, and registrant) +- Display computed enrollment status and monetary amounts from linked records +- Navigate to program records via stat buttons on ticket form +- Filter and group tickets by program, cycle, or enrollment status + +### Models Extended + +| Model | Description | +| ---------------- | --------------------------------------------- | +| `spp.grm.ticket` | Adds 5 relational and 3 computed program fields | + +### New Fields on `spp.grm.ticket` + +**Relational fields:** +- `program_id` → `spp.program` +- `program_membership_id` → `spp.program.membership` +- `cycle_id` → `spp.cycle` +- `entitlement_id` → `spp.entitlement` +- `payment_id` → `spp.payment` + +**Computed fields (stored):** +- `enrollment_status`: Current state from program membership +- `entitlement_amount`: Amount from linked entitlement +- `payment_amount`: Amount from linked payment + +### UI Location + +- **Menu**: Helpdesk > Tickets (no new menus, extends existing ticket views) +- **Form**: "Program Information" section below ticket details with stat buttons for linked records +- **Search**: Filters for "Has Program", "Has Entitlement", "Has Payment"; group by Program, Cycle, Enrollment Status +- **Tree/Kanban**: Program fields available as optional columns and card elements + +### Security + +No additional access rules defined. Inherits all security from `spp_grm`. Users with read/write access to GRM tickets can read/write program links. + +### Extension Points + +- Override `_compute_program_info()` to customize enrollment status and amount extraction logic +- Extend `_onchange_*` methods to add domain-specific auto-fill behavior + +### Dependencies + +`spp_security`, `spp_grm`, `spp_programs` diff --git a/spp_grm_programs/security/ir.model.access.csv b/spp_grm_programs/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/spp_grm_programs/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/spp_grm_programs/static/description/icon.png b/spp_grm_programs/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_grm_programs/static/description/icon.png differ diff --git a/spp_grm_programs/static/description/index.html b/spp_grm_programs/static/description/index.html new file mode 100644 index 00000000..f33f90fe --- /dev/null +++ b/spp_grm_programs/static/description/index.html @@ -0,0 +1,496 @@ + + + + + +OpenSPP GRM Programs Integration + + + +
    +

    OpenSPP GRM Programs Integration

    + + +

    Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

    +

    Bridge module linking GRM tickets to program records. Auto-installs when +both spp_grm and spp_programs are present. Enables tracking of +program-specific grievances through relational links to programs, +enrollments, cycles, entitlements, and payments with automatic field +population and computed status/amount displays.

    +
    +

    Key Capabilities

    +
      +
    • Link tickets to programs, enrollments, cycles, entitlements, and +payments via Many2one fields
    • +
    • Auto-populate related fields based on record relationships (selecting +payment fills entitlement, cycle, and registrant)
    • +
    • Display computed enrollment status and monetary amounts from linked +records
    • +
    • Navigate to program records via stat buttons on ticket form
    • +
    • Filter and group tickets by program, cycle, or enrollment status
    • +
    +
    +
    +

    Models Extended

    + ++++ + + + + + + + + + + +
    ModelDescription
    spp.grm.ticketAdds 5 relational and 3 computed program fields
    +
    +
    +

    New Fields on spp.grm.ticket

    +

    Relational fields:

    +
      +
    • program_idspp.program
    • +
    • program_membership_idspp.program.membership
    • +
    • cycle_idspp.cycle
    • +
    • entitlement_idspp.entitlement
    • +
    • payment_idspp.payment
    • +
    +

    Computed fields (stored):

    +
      +
    • enrollment_status: Current state from program membership
    • +
    • entitlement_amount: Amount from linked entitlement
    • +
    • payment_amount: Amount from linked payment
    • +
    +
    +
    +

    UI Location

    +
      +
    • Menu: Helpdesk > Tickets (no new menus, extends existing ticket +views)
    • +
    • Form: “Program Information” section below ticket details with stat +buttons for linked records
    • +
    • Search: Filters for “Has Program”, “Has Entitlement”, “Has +Payment”; group by Program, Cycle, Enrollment Status
    • +
    • Tree/Kanban: Program fields available as optional columns and card +elements
    • +
    +
    +
    +

    Security

    +

    No additional access rules defined. Inherits all security from +spp_grm. Users with read/write access to GRM tickets can read/write +program links.

    +
    +
    +

    Extension Points

    +
      +
    • Override _compute_program_info() to customize enrollment status +and amount extraction logic
    • +
    • Extend _onchange_* methods to add domain-specific auto-fill +behavior
    • +
    +
    +
    +

    Dependencies

    +

    spp_security, spp_grm, spp_programs

    +

    Table of contents

    + +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • OpenSPP.org
    • +
    +
    +
    +

    Maintainers

    +

    Current maintainers:

    +

    jeremi gonzalesedwin1123 emjay0921

    +

    This module is part of the OpenSPP/OpenSPP2 project on GitHub.

    +

    You are welcome to contribute.

    +
    +
    +
    +
    + + diff --git a/spp_grm_programs/tests/__init__.py b/spp_grm_programs/tests/__init__.py new file mode 100644 index 00000000..29111752 --- /dev/null +++ b/spp_grm_programs/tests/__init__.py @@ -0,0 +1 @@ +from . import test_grm_ticket_programs diff --git a/spp_grm_programs/tests/test_grm_ticket_programs.py b/spp_grm_programs/tests/test_grm_ticket_programs.py new file mode 100644 index 00000000..5e6d4934 --- /dev/null +++ b/spp_grm_programs/tests/test_grm_ticket_programs.py @@ -0,0 +1,810 @@ +"""Tests for GRM Programs integration - spp_grm_programs module.""" + +from odoo import fields +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestGRMTicketPrograms(TransactionCase): + """Test GRM ticket program-related fields and methods.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a ticket stage to satisfy required stage_id default + cls.stage_open = ( + cls.env["spp.grm.ticket.stage"] + .with_context(tracking_disable=True) + .create({"name": "Open", "is_closed": False}) + ) + + # Create a registrant partner + cls.registrant = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "Test Registrant", + "is_registrant": True, + } + ) + ) + + # Create a second registrant for onchange tests + cls.registrant2 = ( + cls.env["res.partner"] + .with_context(tracking_disable=True) + .create( + { + "name": "Test Registrant 2", + "is_registrant": True, + } + ) + ) + + # Create a program + cls.program = cls.env["spp.program"].with_context(tracking_disable=True).create({"name": "Test Program"}) + + # Create a cycle for the program + cls.cycle = ( + cls.env["spp.cycle"] + .with_context(tracking_disable=True) + .create( + { + "name": "Test Cycle", + "program_id": cls.program.id, + "start_date": fields.Date.today(), + "end_date": fields.Date.today(), + } + ) + ) + + # Create a program membership + cls.membership = ( + cls.env["spp.program.membership"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": cls.registrant.id, + "program_id": cls.program.id, + "state": "enrolled", + } + ) + ) + + # Find a usable journal for entitlement creation + cls.journal = cls.env["account.journal"].search( + [("type", "in", ("bank", "cash")), ("company_id", "=", cls.env.company.id)], + limit=1, + ) + + # Create an entitlement + cls.entitlement = ( + cls.env["spp.entitlement"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": cls.registrant.id, + "cycle_id": cls.cycle.id, + "initial_amount": 500.0, + "is_cash_entitlement": True, + } + ) + ) + + # Create a payment linked to the entitlement + cls.payment = ( + cls.env["spp.payment"] + .with_context(tracking_disable=True) + .create( + { + "entitlement_id": cls.entitlement.id, + "cycle_id": cls.cycle.id, + "amount_issued": 500.0, + "state": "issued", + } + ) + ) + + # Create a base ticket (no program fields) + cls.ticket = ( + cls.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": "Test GRM Ticket", + "description": "Test ticket for program integration", + "partner_id": cls.registrant.id, + "stage_id": cls.stage_open.id, + } + ) + ) + + # ------------------------------------------------------------------------- + # Tests for _compute_program_info + # ------------------------------------------------------------------------- + + def test_compute_program_info_no_links(self): + """Computed fields are empty/zero when no program records are linked.""" + self.assertEqual(self.ticket.enrollment_status, "") + self.assertEqual(self.ticket.entitlement_amount, 0.0) + self.assertEqual(self.ticket.payment_amount, 0.0) + + def test_compute_program_info_with_membership_enrolled(self): + """enrollment_status reflects the human-readable label of membership state.""" + self.ticket.with_context(tracking_disable=True).write({"program_membership_id": self.membership.id}) + # "enrolled" state should produce the label "Enrolled" + self.assertEqual(self.ticket.enrollment_status, "Enrolled") + + def test_compute_program_info_membership_state_draft(self): + """enrollment_status shows 'Draft' when membership is in draft state.""" + draft_membership = ( + self.env["spp.program.membership"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": self.registrant2.id, + "program_id": self.program.id, + "state": "draft", + } + ) + ) + ticket = ( + self.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": "Draft Membership Ticket", + "description": "Testing draft membership state", + "partner_id": self.registrant2.id, + "stage_id": self.stage_open.id, + "program_membership_id": draft_membership.id, + } + ) + ) + self.assertEqual(ticket.enrollment_status, "Draft") + + def test_compute_program_info_with_entitlement(self): + """entitlement_amount is taken from entitlement initial_amount.""" + self.ticket.with_context(tracking_disable=True).write({"entitlement_id": self.entitlement.id}) + self.assertEqual(self.ticket.entitlement_amount, 500.0) + + def test_compute_program_info_with_payment(self): + """payment_amount is taken from payment amount_issued.""" + self.ticket.with_context(tracking_disable=True).write({"payment_id": self.payment.id}) + self.assertEqual(self.ticket.payment_amount, 500.0) + + def test_compute_program_info_all_linked(self): + """All computed fields populated when all program records are linked.""" + ticket = ( + self.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": "Full Program Ticket", + "description": "Ticket with all program records linked", + "partner_id": self.registrant.id, + "stage_id": self.stage_open.id, + "program_id": self.program.id, + "program_membership_id": self.membership.id, + "cycle_id": self.cycle.id, + "entitlement_id": self.entitlement.id, + "payment_id": self.payment.id, + } + ) + ) + self.assertEqual(ticket.enrollment_status, "Enrolled") + self.assertEqual(ticket.entitlement_amount, 500.0) + self.assertEqual(ticket.payment_amount, 500.0) + + def test_compute_program_info_clears_on_unlink(self): + """Computed fields reset to defaults when linked records are removed.""" + ticket = ( + self.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": "Unlink Test Ticket", + "description": "Ticket for unlink test", + "partner_id": self.registrant.id, + "stage_id": self.stage_open.id, + "program_membership_id": self.membership.id, + "entitlement_id": self.entitlement.id, + "payment_id": self.payment.id, + } + ) + ) + # Confirm values are set + self.assertEqual(ticket.enrollment_status, "Enrolled") + self.assertEqual(ticket.entitlement_amount, 500.0) + self.assertEqual(ticket.payment_amount, 500.0) + + # Remove all links + ticket.with_context(tracking_disable=True).write( + { + "program_membership_id": False, + "entitlement_id": False, + "payment_id": False, + } + ) + self.assertEqual(ticket.enrollment_status, "") + self.assertEqual(ticket.entitlement_amount, 0.0) + self.assertEqual(ticket.payment_amount, 0.0) + + # ------------------------------------------------------------------------- + # Tests for _onchange_program_membership + # ------------------------------------------------------------------------- + + def test_onchange_program_membership_fills_membership(self): + """Setting registrant and program auto-fills program_membership_id.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Onchange Test", + "description": "Testing onchange", + "partner_id": self.registrant.id, + "stage_id": self.stage_open.id, + "registrant_id": self.registrant.id, + "program_id": self.program.id, + } + ) + ticket._onchange_program_membership() + self.assertEqual(ticket.program_membership_id, self.membership) + + def test_onchange_program_membership_no_match(self): + """No membership set when registrant is not enrolled in the program.""" + other_program = self.env["spp.program"].with_context(tracking_disable=True).create({"name": "Other Program"}) + + ticket = self.env["spp.grm.ticket"].new( + { + "name": "No Match Ticket", + "description": "Testing no match", + "partner_id": self.registrant.id, + "registrant_id": self.registrant.id, + "program_id": other_program.id, + } + ) + ticket._onchange_program_membership() + self.assertFalse(ticket.program_membership_id) + + def test_onchange_program_membership_missing_registrant(self): + """No membership lookup when registrant is not set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Missing Registrant", + "description": "Testing missing registrant", + "partner_id": self.registrant.id, + "program_id": self.program.id, + } + ) + # registrant_id not set + ticket.registrant_id = False + ticket._onchange_program_membership() + self.assertFalse(ticket.program_membership_id) + + def test_onchange_program_membership_missing_program(self): + """No membership lookup when program is not set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Missing Program", + "description": "Testing missing program", + "partner_id": self.registrant.id, + "registrant_id": self.registrant.id, + } + ) + # program_id not set + ticket.program_id = False + ticket._onchange_program_membership() + self.assertFalse(ticket.program_membership_id) + + # ------------------------------------------------------------------------- + # Tests for _onchange_membership + # ------------------------------------------------------------------------- + + def test_onchange_membership_fills_program(self): + """Setting program_membership_id auto-fills program_id.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Membership Onchange", + "description": "Testing membership onchange", + "partner_id": self.registrant.id, + "program_membership_id": self.membership.id, + } + ) + ticket._onchange_membership() + self.assertEqual(ticket.program_id, self.program) + + def test_onchange_membership_fills_registrant_when_empty(self): + """Setting program_membership_id fills registrant_id when not already set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Membership Fills Registrant", + "description": "Testing registrant fill", + "partner_id": self.registrant.id, + "program_membership_id": self.membership.id, + } + ) + # Clear registrant so the onchange can fill it + ticket.registrant_id = False + ticket._onchange_membership() + self.assertEqual(ticket.registrant_id, self.registrant) + + def test_onchange_membership_does_not_overwrite_registrant(self): + """Setting program_membership_id does not overwrite an existing registrant_id.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "No Overwrite Test", + "description": "Testing no overwrite", + "partner_id": self.registrant2.id, + "registrant_id": self.registrant2.id, + "program_membership_id": self.membership.id, + } + ) + ticket._onchange_membership() + # registrant_id should remain as registrant2, not get replaced by membership.partner_id + self.assertEqual(ticket.registrant_id, self.registrant2) + + def test_onchange_membership_no_membership(self): + """No change when program_membership_id is not set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "No Membership", + "description": "Testing no membership", + "partner_id": self.registrant.id, + } + ) + ticket.program_membership_id = False + ticket._onchange_membership() + # program_id should remain empty + self.assertFalse(ticket.program_id) + + # ------------------------------------------------------------------------- + # Tests for _onchange_cycle + # ------------------------------------------------------------------------- + + def test_onchange_cycle_clears_entitlement_and_payment(self): + """Changing cycle clears entitlement_id and payment_id.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Cycle Onchange", + "description": "Testing cycle onchange", + "partner_id": self.registrant.id, + "cycle_id": self.cycle.id, + "entitlement_id": self.entitlement.id, + "payment_id": self.payment.id, + } + ) + ticket._onchange_cycle() + self.assertFalse(ticket.entitlement_id) + self.assertFalse(ticket.payment_id) + + def test_onchange_cycle_fills_program_when_empty(self): + """Changing cycle fills program_id when not already set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Cycle Fills Program", + "description": "Testing cycle fills program", + "partner_id": self.registrant.id, + "cycle_id": self.cycle.id, + } + ) + # Ensure program_id is not set + ticket.program_id = False + ticket._onchange_cycle() + self.assertEqual(ticket.program_id, self.program) + + def test_onchange_cycle_does_not_overwrite_program(self): + """Changing cycle does not overwrite an already-set program_id.""" + other_program = self.env["spp.program"].with_context(tracking_disable=True).create({"name": "Pre-set Program"}) + + ticket = self.env["spp.grm.ticket"].new( + { + "name": "No Overwrite Program", + "description": "Testing no overwrite program", + "partner_id": self.registrant.id, + "program_id": other_program.id, + "cycle_id": self.cycle.id, + } + ) + ticket._onchange_cycle() + # program_id should remain as other_program + self.assertEqual(ticket.program_id, other_program) + + def test_onchange_cycle_no_cycle(self): + """No program fill when cycle is not set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "No Cycle", + "description": "Testing no cycle", + "partner_id": self.registrant.id, + } + ) + ticket.cycle_id = False + ticket._onchange_cycle() + self.assertFalse(ticket.program_id) + + # ------------------------------------------------------------------------- + # Tests for _onchange_entitlement + # ------------------------------------------------------------------------- + + def test_onchange_entitlement_clears_payment(self): + """Changing entitlement clears payment_id.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Entitlement Onchange", + "description": "Testing entitlement onchange", + "partner_id": self.registrant.id, + "entitlement_id": self.entitlement.id, + "payment_id": self.payment.id, + } + ) + ticket._onchange_entitlement() + self.assertFalse(ticket.payment_id) + + def test_onchange_entitlement_fills_cycle_and_program(self): + """Setting entitlement fills cycle and program when not already set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Entitlement Fills Cycle", + "description": "Testing entitlement fills cycle", + "partner_id": self.registrant.id, + "entitlement_id": self.entitlement.id, + } + ) + ticket.cycle_id = False + ticket.program_id = False + ticket._onchange_entitlement() + self.assertEqual(ticket.cycle_id, self.cycle) + self.assertEqual(ticket.program_id, self.program) + + def test_onchange_entitlement_fills_registrant_when_empty(self): + """Setting entitlement fills registrant_id when not already set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Entitlement Fills Registrant", + "description": "Testing registrant fill from entitlement", + "partner_id": self.registrant.id, + "entitlement_id": self.entitlement.id, + } + ) + ticket.registrant_id = False + ticket._onchange_entitlement() + self.assertEqual(ticket.registrant_id, self.registrant) + + def test_onchange_entitlement_does_not_overwrite_registrant(self): + """Setting entitlement does not overwrite an existing registrant_id.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Entitlement No Overwrite", + "description": "Testing no overwrite registrant", + "partner_id": self.registrant2.id, + "registrant_id": self.registrant2.id, + "entitlement_id": self.entitlement.id, + } + ) + ticket._onchange_entitlement() + self.assertEqual(ticket.registrant_id, self.registrant2) + + def test_onchange_entitlement_no_entitlement(self): + """No error or fill when entitlement is not set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "No Entitlement", + "description": "Testing no entitlement", + "partner_id": self.registrant.id, + } + ) + ticket.entitlement_id = False + ticket._onchange_entitlement() + self.assertFalse(ticket.cycle_id) + + # ------------------------------------------------------------------------- + # Tests for _onchange_payment + # ------------------------------------------------------------------------- + + def test_onchange_payment_fills_entitlement_when_empty(self): + """Setting payment fills entitlement_id when not already set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Payment Fills Entitlement", + "description": "Testing payment fills entitlement", + "partner_id": self.registrant.id, + "payment_id": self.payment.id, + } + ) + ticket.entitlement_id = False + ticket._onchange_payment() + self.assertEqual(ticket.entitlement_id, self.entitlement) + + def test_onchange_payment_does_not_overwrite_entitlement(self): + """Setting payment does not overwrite an existing entitlement_id.""" + other_entitlement = ( + self.env["spp.entitlement"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": self.registrant.id, + "cycle_id": self.cycle.id, + "initial_amount": 100.0, + "is_cash_entitlement": True, + } + ) + ) + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Payment No Overwrite Entitlement", + "description": "Testing no overwrite entitlement", + "partner_id": self.registrant.id, + "entitlement_id": other_entitlement.id, + "payment_id": self.payment.id, + } + ) + ticket._onchange_payment() + self.assertEqual(ticket.entitlement_id, other_entitlement) + + def test_onchange_payment_fills_cycle(self): + """Setting payment fills cycle_id.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Payment Fills Cycle", + "description": "Testing payment fills cycle", + "partner_id": self.registrant.id, + "payment_id": self.payment.id, + } + ) + ticket.cycle_id = False + ticket._onchange_payment() + self.assertEqual(ticket.cycle_id, self.cycle) + + def test_onchange_payment_fills_registrant_when_empty(self): + """Setting payment fills registrant_id when not already set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Payment Fills Registrant", + "description": "Testing payment fills registrant", + "partner_id": self.registrant.id, + "payment_id": self.payment.id, + } + ) + ticket.registrant_id = False + ticket._onchange_payment() + self.assertEqual(ticket.registrant_id, self.registrant) + + def test_onchange_payment_does_not_overwrite_registrant(self): + """Setting payment does not overwrite an existing registrant_id.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "Payment No Overwrite Registrant", + "description": "Testing no overwrite registrant from payment", + "partner_id": self.registrant2.id, + "registrant_id": self.registrant2.id, + "payment_id": self.payment.id, + } + ) + ticket._onchange_payment() + self.assertEqual(ticket.registrant_id, self.registrant2) + + def test_onchange_payment_no_payment(self): + """No error or fill when payment is not set.""" + ticket = self.env["spp.grm.ticket"].new( + { + "name": "No Payment", + "description": "Testing no payment", + "partner_id": self.registrant.id, + } + ) + ticket.payment_id = False + ticket._onchange_payment() + self.assertFalse(ticket.entitlement_id) + + # ------------------------------------------------------------------------- + # Tests for action_view_program + # ------------------------------------------------------------------------- + + def test_action_view_program_with_program(self): + """action_view_program returns an act_window action for the linked program.""" + self.ticket.with_context(tracking_disable=True).write({"program_id": self.program.id}) + action = self.ticket.action_view_program() + self.assertIsInstance(action, dict) + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.program") + self.assertEqual(action["res_id"], self.program.id) + self.assertEqual(action["view_mode"], "form") + + def test_action_view_program_without_program(self): + """action_view_program returns False when no program is linked.""" + ticket = ( + self.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": "No Program Ticket", + "description": "Ticket with no program", + "partner_id": self.registrant.id, + "stage_id": self.stage_open.id, + } + ) + ) + result = ticket.action_view_program() + self.assertFalse(result) + + # ------------------------------------------------------------------------- + # Tests for action_view_entitlement + # ------------------------------------------------------------------------- + + def test_action_view_entitlement_with_entitlement(self): + """action_view_entitlement returns an act_window action for the linked entitlement.""" + self.ticket.with_context(tracking_disable=True).write({"entitlement_id": self.entitlement.id}) + action = self.ticket.action_view_entitlement() + self.assertIsInstance(action, dict) + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.entitlement") + self.assertEqual(action["res_id"], self.entitlement.id) + self.assertEqual(action["view_mode"], "form") + + def test_action_view_entitlement_without_entitlement(self): + """action_view_entitlement returns False when no entitlement is linked.""" + ticket = ( + self.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": "No Entitlement Ticket", + "description": "Ticket with no entitlement", + "partner_id": self.registrant.id, + "stage_id": self.stage_open.id, + } + ) + ) + result = ticket.action_view_entitlement() + self.assertFalse(result) + + # ------------------------------------------------------------------------- + # Tests for action_view_payment + # ------------------------------------------------------------------------- + + def test_action_view_payment_with_payment(self): + """action_view_payment returns an act_window action for the linked payment.""" + self.ticket.with_context(tracking_disable=True).write({"payment_id": self.payment.id}) + action = self.ticket.action_view_payment() + self.assertIsInstance(action, dict) + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.payment") + self.assertEqual(action["res_id"], self.payment.id) + self.assertEqual(action["view_mode"], "form") + + def test_action_view_payment_without_payment(self): + """action_view_payment returns False when no payment is linked.""" + ticket = ( + self.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": "No Payment Ticket", + "description": "Ticket with no payment", + "partner_id": self.registrant.id, + "stage_id": self.stage_open.id, + } + ) + ) + result = ticket.action_view_payment() + self.assertFalse(result) + + # ------------------------------------------------------------------------- + # Tests for field presence (structural / smoke tests) + # ------------------------------------------------------------------------- + + def test_program_fields_exist_on_ticket(self): + """Confirm program-related fields are present on the GRM ticket model.""" + ticket_fields = self.env["spp.grm.ticket"]._fields + for field_name in ( + "program_id", + "program_membership_id", + "cycle_id", + "entitlement_id", + "payment_id", + "enrollment_status", + "entitlement_amount", + "payment_amount", + ): + self.assertIn( + field_name, + ticket_fields, + f"Field '{field_name}' should exist on spp.grm.ticket", + ) + + def test_ticket_creation_with_program_fields(self): + """A ticket with program fields can be created without error.""" + ticket = ( + self.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": "Full Linked Ticket", + "description": "Ticket with all program links", + "partner_id": self.registrant.id, + "stage_id": self.stage_open.id, + "program_id": self.program.id, + "program_membership_id": self.membership.id, + "cycle_id": self.cycle.id, + "entitlement_id": self.entitlement.id, + "payment_id": self.payment.id, + } + ) + ) + self.assertTrue(ticket.id) + self.assertEqual(ticket.program_id, self.program) + self.assertEqual(ticket.program_membership_id, self.membership) + self.assertEqual(ticket.cycle_id, self.cycle) + self.assertEqual(ticket.entitlement_id, self.entitlement) + self.assertEqual(ticket.payment_id, self.payment) + + # ------------------------------------------------------------------------- + # Tests for membership state labels (ensure all states resolve correctly) + # ------------------------------------------------------------------------- + + def _create_ticket_with_membership_state(self, registrant, state): + """Helper to create a ticket with a membership in the given state.""" + # Create a fresh program to avoid unique-per-program constraint + program = self.env["spp.program"].with_context(tracking_disable=True).create({"name": f"State Program {state}"}) + membership = ( + self.env["spp.program.membership"] + .with_context(tracking_disable=True) + .create( + { + "partner_id": registrant.id, + "program_id": program.id, + "state": state, + } + ) + ) + ticket = ( + self.env["spp.grm.ticket"] + .with_context(tracking_disable=True) + .create( + { + "name": f"Ticket {state}", + "description": f"Ticket with membership state {state}", + "partner_id": registrant.id, + "stage_id": self.stage_open.id, + "program_membership_id": membership.id, + } + ) + ) + return ticket + + def test_enrollment_status_paused(self): + """enrollment_status shows 'Paused' for paused membership.""" + ticket = self._create_ticket_with_membership_state(self.registrant2, "paused") + self.assertEqual(ticket.enrollment_status, "Paused") + + def test_enrollment_status_exited(self): + """enrollment_status shows 'Exited' for exited membership.""" + # Create a unique registrant to avoid membership uniqueness constraint + registrant = ( + self.env["res.partner"] + .with_context(tracking_disable=True) + .create({"name": "Exited Registrant", "is_registrant": True}) + ) + ticket = self._create_ticket_with_membership_state(registrant, "exited") + self.assertEqual(ticket.enrollment_status, "Exited") + + def test_enrollment_status_not_eligible(self): + """enrollment_status shows 'Not Eligible' for not_eligible membership.""" + registrant = ( + self.env["res.partner"] + .with_context(tracking_disable=True) + .create({"name": "Not Eligible Registrant", "is_registrant": True}) + ) + ticket = self._create_ticket_with_membership_state(registrant, "not_eligible") + self.assertEqual(ticket.enrollment_status, "Not Eligible") + + def test_enrollment_status_duplicated(self): + """enrollment_status shows 'Duplicated' for duplicated membership.""" + registrant = ( + self.env["res.partner"] + .with_context(tracking_disable=True) + .create({"name": "Duplicated Registrant", "is_registrant": True}) + ) + ticket = self._create_ticket_with_membership_state(registrant, "duplicated") + self.assertEqual(ticket.enrollment_status, "Duplicated") diff --git a/spp_grm_programs/views/grm_ticket_views.xml b/spp_grm_programs/views/grm_ticket_views.xml new file mode 100644 index 00000000..edf54a55 --- /dev/null +++ b/spp_grm_programs/views/grm_ticket_views.xml @@ -0,0 +1,204 @@ + + + + + spp.grm.ticket.form.programs + spp.grm.ticket + + 99 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + spp.grm.ticket.tree.registry + spp.grm.ticket + + + + + + + + + + + + + + spp.grm.ticket.search.registry + spp.grm.ticket + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + spp.grm.ticket.kanban.registry + spp.grm.ticket + + + + + + + + + + + + + + + Repeat () + + + +
    + Registrant: + +
    +
    +
    +
    +
    +
    diff --git a/spp_grm_registry/views/res_partner_views.xml b/spp_grm_registry/views/res_partner_views.xml new file mode 100644 index 00000000..14c038cc --- /dev/null +++ b/spp_grm_registry/views/res_partner_views.xml @@ -0,0 +1,101 @@ + + + + + res.partner.form.grm + res.partner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + res.partner.group.form.grm + res.partner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spp_hxl/README.rst b/spp_hxl/README.rst index 1bccb105..b37b4d11 100644 --- a/spp_hxl/README.rst +++ b/spp_hxl/README.rst @@ -1,12 +1,8 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ======================= OpenSPP HXL Integration ======================= -.. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! @@ -173,4 +169,4 @@ Current maintainers: This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. -You are welcome to contribute. +You are welcome to contribute. \ No newline at end of file diff --git a/spp_hxl/static/description/index.html b/spp_hxl/static/description/index.html index 68420ed9..bc405e1b 100644 --- a/spp_hxl/static/description/index.html +++ b/spp_hxl/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +OpenSPP HXL Integration -
    +
    +

    OpenSPP HXL Integration

    - - -Odoo Community Association - -
    -

    OpenSPP HXL Integration

    + + Training Session + TRAINING + General training and capacity building sessions for beneficiaries + monthly + 3.0 + 80.0 + True + + + + Group Meeting + MEETING + Regular support group meetings for community members + biweekly + 2.0 + 75.0 + False + + + + Family Development Session + FDS + Family Development Sessions for conditional cash transfer programs + monthly + 2.5 + 85.0 + True + + + + Workshop + WORKSHOP + Specialized workshops on various topics + one_time + 4.0 + 90.0 + True + + + + + Nutrition and Health + NUTRITION + Proper nutrition, health practices, and child care + + 10 + + + + Education and Child Development + EDUCATION + Importance of education and supporting children's learning + + 20 + + + + Financial Literacy + FINANCE + Budgeting, saving, and financial planning for families + + 30 + + + + Positive Parenting + PARENTING + Positive discipline, communication, and family relationships + + 40 + + + + + Livelihood Skills + LIVELIHOOD + Skills development for income generation + + 10 + + + + Basic Business Management + BUSINESS + Starting and managing a small business + + 20 + + diff --git a/spp_session_tracking/models/__init__.py b/spp_session_tracking/models/__init__.py new file mode 100644 index 00000000..38e4adf9 --- /dev/null +++ b/spp_session_tracking/models/__init__.py @@ -0,0 +1,4 @@ +from . import session_type +from . import session_topic +from . import session +from . import session_attendance diff --git a/spp_session_tracking/models/session.py b/spp_session_tracking/models/session.py new file mode 100644 index 00000000..b2160e3e --- /dev/null +++ b/spp_session_tracking/models/session.py @@ -0,0 +1,97 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class Session(models.Model): + _name = "spp.session" + _description = "Session" + _inherit = ["mail.thread"] + _order = "date desc" + + name = fields.Char(required=True, tracking=True) + session_type_id = fields.Many2one("spp.session.type", required=True, string="Session Type", tracking=True) + + date = fields.Date(required=True, default=fields.Date.today, tracking=True) + start_time = fields.Float() + end_time = fields.Float() + duration_hours = fields.Float(compute="_compute_duration", store=True, string="Duration (Hours)") + + facilitator_id = fields.Many2one("res.users", required=True, string="Facilitator", tracking=True) + co_facilitator_ids = fields.Many2many( + "res.users", + "session_co_facilitator_rel", + "session_id", + "user_id", + string="Co-Facilitators", + ) + + location = fields.Char() + area_id = fields.Many2one("spp.area", string="Area") + + # Topics covered (if tracking enabled) + topic_ids = fields.Many2many( + "spp.session.topic", + "session_topic_rel", + "session_id", + "topic_id", + string="Topics Covered", + ) + + # Participants + expected_participant_ids = fields.Many2many( + "res.partner", + "session_expected_participant_rel", + "session_id", + "partner_id", + string="Expected Participants", + ) + max_participants = fields.Integer() + + # Attendance + attendance_ids = fields.One2many("spp.session.attendance", "session_id", string="Attendance Records") + attendance_count = fields.Integer(compute="_compute_attendance") + attendance_rate = fields.Float(compute="_compute_attendance", string="Attendance Rate (%)") + + state = fields.Selection( + [ + ("scheduled", "Scheduled"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="scheduled", + tracking=True, + ) + + notes = fields.Text() + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + + @api.depends("start_time", "end_time") + def _compute_duration(self): + for rec in self: + if rec.start_time and rec.end_time: + rec.duration_hours = rec.end_time - rec.start_time + else: + rec.duration_hours = 0.0 + + @api.depends("attendance_ids", "attendance_ids.is_attended", "expected_participant_ids") + def _compute_attendance(self): + for rec in self: + attended = len(rec.attendance_ids.filtered(lambda a: a.is_attended)) + rec.attendance_count = attended + + expected = len(rec.expected_participant_ids) + if expected > 0: + rec.attendance_rate = (attended / expected) * 100.0 + else: + rec.attendance_rate = 0.0 + + def action_start(self): + self.state = "in_progress" + + def action_complete(self): + self.state = "completed" + + def action_cancel(self): + self.state = "cancelled" diff --git a/spp_session_tracking/models/session_attendance.py b/spp_session_tracking/models/session_attendance.py new file mode 100644 index 00000000..9ea27385 --- /dev/null +++ b/spp_session_tracking/models/session_attendance.py @@ -0,0 +1,29 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class SessionAttendance(models.Model): + _name = "spp.session.attendance" + _description = "Session Attendance" + _rec_name = "participant_id" + + session_id = fields.Many2one("spp.session", required=True, ondelete="cascade", string="Session") + participant_id = fields.Many2one("res.partner", required=True, string="Participant") + + is_attended = fields.Boolean(default=False) + attendance_time = fields.Datetime(string="Time of Attendance") + + is_excused = fields.Boolean(default=False) + excuse_reason = fields.Char() + + notes = fields.Text() + + # Computed from session + session_date = fields.Date(related="session_id.date", store=True, string="Session Date") + session_type_id = fields.Many2one(related="session_id.session_type_id", store=True, string="Session Type") + + _unique_participant_session = models.Constraint( + "unique(session_id, participant_id)", + "A participant can only have one attendance record per session.", + ) diff --git a/spp_session_tracking/models/session_topic.py b/spp_session_tracking/models/session_topic.py new file mode 100644 index 00000000..24edc255 --- /dev/null +++ b/spp_session_tracking/models/session_topic.py @@ -0,0 +1,16 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class SessionTopic(models.Model): + _name = "spp.session.topic" + _description = "Session Topic" + _order = "sequence, name" + + name = fields.Char(required=True) + code = fields.Char() + description = fields.Text() + session_type_id = fields.Many2one("spp.session.type", string="Session Type") + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) diff --git a/spp_session_tracking/models/session_type.py b/spp_session_tracking/models/session_type.py new file mode 100644 index 00000000..513a921d --- /dev/null +++ b/spp_session_tracking/models/session_type.py @@ -0,0 +1,57 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class SessionType(models.Model): + _name = "spp.session.type" + _description = "Session Type" + + name = fields.Char(required=True) + code = fields.Char() + description = fields.Text() + active = fields.Boolean(default=True) + + frequency = fields.Selection( + [ + ("weekly", "Weekly"), + ("biweekly", "Bi-weekly"), + ("monthly", "Monthly"), + ("quarterly", "Quarterly"), + ("one_time", "One-time"), + ], + default="monthly", + ) + + required_attendance_pct = fields.Float( + default=80.0, + help="Minimum attendance percentage required for compliance", + ) + duration_hours = fields.Float(default=2.0) + + track_topics = fields.Boolean( + default=False, + help="Track which topics are covered in each session", + ) + topic_ids = fields.One2many("spp.session.topic", "session_type_id", string="Topics") + + session_count = fields.Integer(compute="_compute_counts", string="Number of Sessions") + + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + + @api.depends("topic_ids") + def _compute_counts(self): + for rec in self: + rec.session_count = self.env["spp.session"].search_count([("session_type_id", "=", rec.id)]) + + def action_view_sessions(self): + """Open view with sessions of this type.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": f"Sessions - {self.name}", + "res_model": "spp.session", + "view_mode": "tree,form,calendar,kanban", + "domain": [("session_type_id", "=", self.id)], + "context": {"default_session_type_id": self.id}, + } diff --git a/spp_session_tracking/pyproject.toml b/spp_session_tracking/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_session_tracking/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_session_tracking/readme/DESCRIPTION.md b/spp_session_tracking/readme/DESCRIPTION.md new file mode 100644 index 00000000..5c7acc0d --- /dev/null +++ b/spp_session_tracking/readme/DESCRIPTION.md @@ -0,0 +1,58 @@ +Tracks attendance at required sessions and trainings for social protection programs. Records participant attendance, computes attendance rates against expected participation, and manages session lifecycle from scheduling through completion. Supports conditional cash transfer programs requiring minimum attendance thresholds. + +### Key Capabilities + +- Define session types with attendance requirements and frequency (weekly, biweekly, monthly, quarterly, one-time) +- Schedule sessions with facilitators, co-facilitators, location, and expected participants +- Record attendance with timestamps, excused absences, and notes +- Compute attendance rates and track attendance counts automatically +- Track topics covered in each session (optional, configurable per session type) +- Manage session state: scheduled → in progress → completed → cancelled +- Filter sessions by facilitator, type, state, and date range +- View sessions in list, form, calendar (by date), or kanban (grouped by state) + +### Key Models + +| Model | Description | +| ------------------------- | -------------------------------------------------------- | +| `spp.session.type` | Session type definition with attendance requirements | +| `spp.session.topic` | Topics that can be covered in sessions | +| `spp.session` | Session instance with facilitator, participants, and date | +| `spp.session.attendance` | Attendance record for a participant at a session | + +### Configuration + +After installing: + +1. Navigate to **Session Tracking > Configuration > Session Types** +2. Review pre-configured session types (Training, Family Development Session, Group Meeting, Workshop) +3. Add or modify session types and topics as needed +4. Adjust required attendance percentage per session type + +Four session types are pre-configured with sample topics for Family Development Sessions and Training Sessions. + +### UI Location + +- **Menu**: Session Tracking > Sessions > All Sessions +- **My Sessions**: Session Tracking > Sessions > My Sessions (filtered to current user as facilitator) +- **Configuration**: Session Tracking > Configuration > Session Types (managers only) +- **Views**: List, form, calendar (by date), kanban (grouped by state) + +### Security + +| Group | Access | +| ---------------------------------------------- | ------------------------------------- | +| `spp_session_tracking.group_session_user` | Read all sessions and session types/topics; write own facilitated sessions; read/write/create attendance (no delete) | +| `spp_session_tracking.group_session_manager` | Full CRUD on all sessions, types, topics, and attendance | + +The session user group can view all sessions but only edit sessions they facilitate (via record rule). The `spp_security.group_spp_admin` group implies manager access. + +### Extension Points + +- Inherit `spp.session.type` to add custom fields for program-specific session metadata +- Inherit `spp.session.attendance` to track additional compliance data +- Override `_compute_attendance()` on `spp.session` to customize attendance rate calculations + +### Dependencies + +`base`, `mail`, `spp_area`, `spp_security` diff --git a/spp_session_tracking/security/ir.model.access.csv b/spp_session_tracking/security/ir.model.access.csv new file mode 100644 index 00000000..9198b30a --- /dev/null +++ b/spp_session_tracking/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_session_type_user,access_spp_session_type_user,spp_session_tracking.model_spp_session_type,spp_session_tracking.group_session_user,1,0,0,0 +access_spp_session_type_manager,access_spp_session_type_manager,spp_session_tracking.model_spp_session_type,spp_session_tracking.group_session_manager,1,1,1,1 +access_spp_session_topic_user,access_spp_session_topic_user,spp_session_tracking.model_spp_session_topic,spp_session_tracking.group_session_user,1,0,0,0 +access_spp_session_topic_manager,access_spp_session_topic_manager,spp_session_tracking.model_spp_session_topic,spp_session_tracking.group_session_manager,1,1,1,1 +access_spp_session_user,access_spp_session_user,spp_session_tracking.model_spp_session,spp_session_tracking.group_session_user,1,0,0,0 +access_spp_session_manager,access_spp_session_manager,spp_session_tracking.model_spp_session,spp_session_tracking.group_session_manager,1,1,1,1 +access_spp_session_attendance_user,access_spp_session_attendance_user,spp_session_tracking.model_spp_session_attendance,spp_session_tracking.group_session_user,1,1,1,0 +access_spp_session_attendance_manager,access_spp_session_attendance_manager,spp_session_tracking.model_spp_session_attendance,spp_session_tracking.group_session_manager,1,1,1,1 diff --git a/spp_session_tracking/security/privileges.xml b/spp_session_tracking/security/privileges.xml new file mode 100644 index 00000000..7992edde --- /dev/null +++ b/spp_session_tracking/security/privileges.xml @@ -0,0 +1,15 @@ + + + + + User + + 10 + + + + Manager + + 20 + + diff --git a/spp_session_tracking/security/session_rules.xml b/spp_session_tracking/security/session_rules.xml new file mode 100644 index 00000000..aa5b658a --- /dev/null +++ b/spp_session_tracking/security/session_rules.xml @@ -0,0 +1,38 @@ + + + + + + Session User: Own Facilitated Sessions + + [('facilitator_id', '=', user.id)] + + + + + + + + + Session User: Read All Sessions + + [(1, '=', 1)] + + + + + + + + + + Session Manager: Full Access + + [(1, '=', 1)] + + + + + + + diff --git a/spp_session_tracking/security/session_security.xml b/spp_session_tracking/security/session_security.xml new file mode 100644 index 00000000..969e414a --- /dev/null +++ b/spp_session_tracking/security/session_security.xml @@ -0,0 +1,26 @@ + + + + + User + + Can view all sessions and edit own facilitated sessions. + + + + + Manager + + Full session management including all CRUD operations. + + + + + + + + diff --git a/spp_session_tracking/static/description/OpenSPP-Session-Tracking-Menu-Icons.png b/spp_session_tracking/static/description/OpenSPP-Session-Tracking-Menu-Icons.png new file mode 100644 index 00000000..b6f59082 Binary files /dev/null and b/spp_session_tracking/static/description/OpenSPP-Session-Tracking-Menu-Icons.png differ diff --git a/spp_session_tracking/static/description/icon.png b/spp_session_tracking/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_session_tracking/static/description/icon.png differ diff --git a/spp_session_tracking/static/description/index.html b/spp_session_tracking/static/description/index.html new file mode 100644 index 00000000..4b682d2f --- /dev/null +++ b/spp_session_tracking/static/description/index.html @@ -0,0 +1,531 @@ + + + + + +OpenSPP Session Tracking + + + +
    +

    OpenSPP Session Tracking

    + + +

    Beta License: LGPL-3 OpenSPP/OpenSPP2

    +

    Tracks attendance at required sessions and trainings for social +protection programs. Records participant attendance, computes attendance +rates against expected participation, and manages session lifecycle from +scheduling through completion. Supports conditional cash transfer +programs requiring minimum attendance thresholds.

    +
    +

    Key Capabilities

    +
      +
    • Define session types with attendance requirements and frequency +(weekly, biweekly, monthly, quarterly, one-time)
    • +
    • Schedule sessions with facilitators, co-facilitators, location, and +expected participants
    • +
    • Record attendance with timestamps, excused absences, and notes
    • +
    • Compute attendance rates and track attendance counts automatically
    • +
    • Track topics covered in each session (optional, configurable per +session type)
    • +
    • Manage session state: scheduled → in progress → completed → cancelled
    • +
    • Filter sessions by facilitator, type, state, and date range
    • +
    • View sessions in list, form, calendar (by date), or kanban (grouped by +state)
    • +
    +
    +
    +

    Key Models

    + ++++ + + + + + + + + + + + + + + + + + + + +
    ModelDescription
    spp.session.typeSession type definition with attendance +requirements
    spp.session.topicTopics that can be covered in sessions
    spp.sessionSession instance with facilitator, +participants, and date
    spp.session.attendanceAttendance record for a participant at +a session
    +
    +
    +

    Configuration

    +

    After installing:

    +
      +
    1. Navigate to Session Tracking > Configuration > Session Types
    2. +
    3. Review pre-configured session types (Training, Family Development +Session, Group Meeting, Workshop)
    4. +
    5. Add or modify session types and topics as needed
    6. +
    7. Adjust required attendance percentage per session type
    8. +
    +

    Four session types are pre-configured with sample topics for Family +Development Sessions and Training Sessions.

    +
    +
    +

    UI Location

    +
      +
    • Menu: Session Tracking > Sessions > All Sessions
    • +
    • My Sessions: Session Tracking > Sessions > My Sessions (filtered +to current user as facilitator)
    • +
    • Configuration: Session Tracking > Configuration > Session Types +(managers only)
    • +
    • Views: List, form, calendar (by date), kanban (grouped by state)
    • +
    +
    +
    +

    Security

    + ++++ + + + + + + + + + + + + + +
    GroupAccess
    spp_session_tracking.group_session_userRead all sessions and session +types/topics; write own +facilitated sessions; +read/write/create attendance (no +delete)
    spp_session_tracking.group_session_managerFull CRUD on all sessions, +types, topics, and attendance
    +

    The session user group can view all sessions but only edit sessions they +facilitate (via record rule). The spp_security.group_spp_admin group +implies manager access.

    +
    +
    +

    Extension Points

    +
      +
    • Inherit spp.session.type to add custom fields for program-specific +session metadata
    • +
    • Inherit spp.session.attendance to track additional compliance data
    • +
    • Override _compute_attendance() on spp.session to customize +attendance rate calculations
    • +
    +
    +
    +

    Dependencies

    +

    base, mail, spp_area, spp_security

    +

    Table of contents

    + +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • OpenSPP.org
    • +
    +
    +
    +

    Maintainers

    +

    Current maintainers:

    +

    jeremi gonzalesedwin1123 emjay0921

    +

    This module is part of the OpenSPP/OpenSPP2 project on GitHub.

    +

    You are welcome to contribute.

    +
    +
    +
    +
    + + diff --git a/spp_session_tracking/tests/__init__.py b/spp_session_tracking/tests/__init__.py new file mode 100644 index 00000000..35c2ad2a --- /dev/null +++ b/spp_session_tracking/tests/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_session +from . import test_attendance diff --git a/spp_session_tracking/tests/test_attendance.py b/spp_session_tracking/tests/test_attendance.py new file mode 100644 index 00000000..5cb4c76c --- /dev/null +++ b/spp_session_tracking/tests/test_attendance.py @@ -0,0 +1,124 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestAttendance(TransactionCase): + """Test session attendance tracking.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Session = cls.env["spp.session"] + cls.SessionType = cls.env["spp.session.type"] + cls.Attendance = cls.env["spp.session.attendance"] + cls.Partner = cls.env["res.partner"] + + cls.session_type = cls.SessionType.create( + { + "name": "Training", + "code": "TRAIN", + } + ) + + cls.facilitator = cls.env.user + + cls.participant1 = cls.Partner.create({"name": "John Doe"}) + cls.participant2 = cls.Partner.create({"name": "Jane Doe"}) + + cls.session = cls.Session.create( + { + "name": "Test Training", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.facilitator.id, + "expected_participant_ids": [ + (4, cls.participant1.id), + (4, cls.participant2.id), + ], + } + ) + + def test_attendance_creation(self): + """Test attendance record can be created.""" + attendance = self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + self.assertTrue(attendance.is_attended) + + def test_attendance_count_computation(self): + """Test attendance count is computed correctly.""" + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant2.id, + "is_attended": False, + } + ) + + self.session.invalidate_recordset(["attendance_count", "attendance_rate"]) + self.assertEqual(self.session.attendance_count, 1) + + def test_attendance_rate_computation(self): + """Test attendance rate is computed correctly.""" + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant2.id, + "is_attended": True, + } + ) + + self.session.invalidate_recordset(["attendance_count", "attendance_rate"]) + self.assertEqual(self.session.attendance_rate, 100.0) + + def test_partial_attendance_rate(self): + """Test partial attendance rate calculation.""" + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant2.id, + "is_attended": False, + } + ) + + self.session.invalidate_recordset(["attendance_count", "attendance_rate"]) + self.assertEqual(self.session.attendance_rate, 50.0) + + def test_attendance_with_notes(self): + """Test attendance with notes.""" + attendance = self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": False, + "notes": "Called in sick", + } + ) + self.assertEqual(attendance.notes, "Called in sick") diff --git a/spp_session_tracking/tests/test_session.py b/spp_session_tracking/tests/test_session.py new file mode 100644 index 00000000..8c69e3a2 --- /dev/null +++ b/spp_session_tracking/tests/test_session.py @@ -0,0 +1,111 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestSession(TransactionCase): + """Test session management.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Session = cls.env["spp.session"] + cls.SessionType = cls.env["spp.session.type"] + cls.Partner = cls.env["res.partner"] + + cls.session_type = cls.SessionType.create( + { + "name": "Training Session", + "code": "TRAIN", + } + ) + + cls.facilitator = cls.env.user + + cls.participant1 = cls.Partner.create( + { + "name": "Participant 1", + } + ) + cls.participant2 = cls.Partner.create( + { + "name": "Participant 2", + } + ) + + def test_session_creation(self): + """Test session can be created.""" + session = self.Session.create( + { + "name": "Test Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + self.assertEqual(session.state, "scheduled") + self.assertEqual(session.attendance_count, 0) + + def test_session_duration_computation(self): + """Test session duration is computed correctly.""" + session = self.Session.create( + { + "name": "Morning Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "start_time": 9.0, # 9:00 AM + "end_time": 12.0, # 12:00 PM + } + ) + self.assertEqual(session.duration_hours, 3.0) + + def test_session_state_workflow(self): + """Test session state transitions.""" + session = self.Session.create( + { + "name": "Workflow Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + + self.assertEqual(session.state, "scheduled") + + session.action_start() + self.assertEqual(session.state, "in_progress") + + session.action_complete() + self.assertEqual(session.state, "completed") + + def test_session_cancel(self): + """Test session cancellation.""" + session = self.Session.create( + { + "name": "Cancel Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + + session.action_cancel() + self.assertEqual(session.state, "cancelled") + + def test_session_with_expected_participants(self): + """Test session with expected participants.""" + session = self.Session.create( + { + "name": "Group Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "expected_participant_ids": [ + (4, self.participant1.id), + (4, self.participant2.id), + ], + } + ) + self.assertEqual(len(session.expected_participant_ids), 2) diff --git a/spp_session_tracking/views/session_menus.xml b/spp_session_tracking/views/session_menus.xml new file mode 100644 index 00000000..2e77e677 --- /dev/null +++ b/spp_session_tracking/views/session_menus.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/spp_session_tracking/views/session_type_views.xml b/spp_session_tracking/views/session_type_views.xml new file mode 100644 index 00000000..4d15456d --- /dev/null +++ b/spp_session_tracking/views/session_type_views.xml @@ -0,0 +1,132 @@ + + + + + spp.session.topic.list + spp.session.topic + + + + + + + + + + + + + + spp.session.type.form + spp.session.type + +
    + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + + spp.session.type.tree + spp.session.type + + + + + + + + + + + + + + + + spp.session.type.search + spp.session.type + + + + + + + + + + + + + + + + Session Types + spp.session.type + + list,form + {'search_default_active': 1} + +

    + Create a new session type +

    +

    + Session types define the categories of sessions you want to track, + such as training workshops, family development sessions, or support group meetings. +

    +
    +
    +
    diff --git a/spp_session_tracking/views/session_views.xml b/spp_session_tracking/views/session_views.xml new file mode 100644 index 00000000..9d6d6553 --- /dev/null +++ b/spp_session_tracking/views/session_views.xml @@ -0,0 +1,325 @@ + + + + + spp.session.form + spp.session + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + spp.session.tree + spp.session + + + + + + + + + + + + + + + + + spp.session.calendar + spp.session + + + + + + + + + + + + spp.session.kanban + spp.session + + + + + + + + + + +
    +
    +
    + + + +
    +
    + +
    + +
    + +
    + Attendance: % +
    +
    +
    +
    +
    +
    +
    +
    + + + + spp.session.search + spp.session + + + + + + + + + + + + + + + + + + + + + + + + + + Sessions + spp.session + + list,form,calendar,kanban + +

    + Create a new session +

    +

    + Track attendance at training sessions, workshops, meetings, and other events. +

    +
    +
    + + + + My Sessions + spp.session + + list,form,calendar,kanban + {'search_default_my_sessions': 1} + +

    + You have no sessions yet +

    +

    + Sessions where you are the facilitator will appear here. +

    +
    +
    +
    diff --git a/spp_simulation/README.rst b/spp_simulation/README.rst index 52b61039..779bbd3a 100644 --- a/spp_simulation/README.rst +++ b/spp_simulation/README.rst @@ -1,12 +1,8 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============================ OpenSPP Targeting Simulation ============================ -.. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! @@ -100,7 +96,6 @@ Templates / Custom Metrics .. IMPORTANT:: This is an alpha version, the data model and design can change at any time without warning. Only for development or testing purpose, do not use in production. - `More details on development status `_ **Table of contents** @@ -138,4 +133,4 @@ Current maintainer: This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. -You are welcome to contribute. +You are welcome to contribute. \ No newline at end of file diff --git a/spp_simulation/static/description/index.html b/spp_simulation/static/description/index.html index 1d41b132..5a13b8d7 100644 --- a/spp_simulation/static/description/index.html +++ b/spp_simulation/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +OpenSPP Targeting Simulation -
    +
    +

    OpenSPP Targeting Simulation

    - - -Odoo Community Association - -
    -

    OpenSPP Targeting Simulation

    Alpha License: LGPL-3 OpenSPP/OpenSPP2

    -

    Targeting Simulation & Fairness Analysis

    +

    Targeting Simulation & Fairness Analysis

    Simulate targeting scenarios, analyze fairness and distribution, and compare different targeting strategies before committing to criteria.

    -

    Key Features

    +

    Key Features

    • Scenario Builder: Define targeting criteria using CEL expressions with live preview counts
    • @@ -400,12 +395,12 @@

      Key Features

    -

    Privacy

    +

    Privacy

    Only aggregated counts, percentages, and metrics are stored. No individual beneficiary records are persisted in simulation results.

    -

    Models

    +

    Models

    @@ -441,7 +436,7 @@

    Models

    -

    Security Groups

    +

    Security Groups

    @@ -466,21 +461,20 @@

    Security Groups

    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -488,15 +482,15 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • OpenSPP.org
    -

    Maintainers

    +

    Maintainers

    Current maintainer:

    jeremi

    This module is part of the OpenSPP/OpenSPP2 project on GitHub.

    @@ -504,6 +498,5 @@

    Maintainers

    -