From c118239bdae7fdf6a1fbbcc49ff41b682371edfa Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 5 Mar 2026 12:08:17 +0800 Subject: [PATCH 01/12] feat(grm,case): add case management modules and sync GRM with stable Add 12 new modules: spp_case_base, spp_case_cel, spp_case_demo, spp_case_entitlements, spp_case_graduation, spp_case_programs, spp_case_registry, spp_case_session, spp_grm_case_link, spp_grm_cel, spp_grm_programs, and spp_grm_registry. Update spp_grm and spp_grm_demo to match stable upstream: add ticket workflow action buttons, refine officer record rules with separate create permission, rename privilege_grm_user to privilege_grm, convert Html fields to Text, and default ticket assignment to current user. --- spp_case_base/README.rst | 179 ++ spp_case_base/__init__.py | 1 + spp_case_base/__manifest__.py | 42 + spp_case_base/data/ir_cron.xml | 14 + spp_case_base/models/__init__.py | 10 + spp_case_base/models/case.py | 508 +++++ spp_case_base/models/case_assessment.py | 261 +++ spp_case_base/models/case_config.py | 142 ++ spp_case_base/models/case_intervention.py | 178 ++ .../models/case_intervention_plan.py | 272 +++ spp_case_base/models/case_note.py | 114 ++ spp_case_base/models/case_referral.py | 146 ++ spp_case_base/models/case_stage.py | 76 + spp_case_base/models/case_type.py | 112 ++ spp_case_base/models/case_visit.py | 123 ++ spp_case_base/pyproject.toml | 3 + spp_case_base/readme/DESCRIPTION.md | 68 + spp_case_base/security/case_security.xml | 7 + spp_case_base/security/compliance.yaml | 505 +++++ spp_case_base/security/groups.xml | 60 + spp_case_base/security/ir.model.access.csv | 53 + spp_case_base/security/privileges.xml | 12 + spp_case_base/security/rules.xml | 211 ++ .../OpenSPP-Case-Management-Icons.png | Bin 0 -> 5069 bytes spp_case_base/static/description/icon.png | Bin 0 -> 15480 bytes spp_case_base/static/description/index.html | 574 ++++++ spp_case_base/tests/__init__.py | 6 + spp_case_base/tests/test_case.py | 405 ++++ spp_case_base/tests/test_case_intervention.py | 436 +++++ .../tests/test_case_intervention_plan.py | 408 ++++ spp_case_base/tests/test_case_security.py | 468 +++++ .../tests/test_compliance_generated.py | 1690 +++++++++++++++++ spp_case_base/views/case_activity_views.xml | 352 ++++ spp_case_base/views/case_assessment_views.xml | 165 ++ spp_case_base/views/case_config_views.xml | 372 ++++ .../views/case_intervention_views.xml | 302 +++ spp_case_base/views/case_menus.xml | 139 ++ spp_case_base/views/case_stage_views.xml | 90 + spp_case_base/views/case_type_views.xml | 154 ++ spp_case_base/views/case_views.xml | 439 +++++ spp_case_cel/README.rst | 156 ++ spp_case_cel/__init__.py | 3 + spp_case_cel/__manifest__.py | 28 + spp_case_cel/models/__init__.py | 5 + spp_case_cel/models/case.py | 33 + spp_case_cel/models/case_assignment_rule.py | 245 +++ spp_case_cel/models/case_triage_rule.py | 236 +++ spp_case_cel/pyproject.toml | 3 + spp_case_cel/readme/DESCRIPTION.md | 51 + spp_case_cel/security/ir.model.access.csv | 5 + spp_case_cel/static/description/icon.png | Bin 0 -> 15480 bytes spp_case_cel/static/description/index.html | 526 +++++ spp_case_cel/tests/__init__.py | 4 + spp_case_cel/tests/test_assignment_rules.py | 459 +++++ spp_case_cel/tests/test_triage_rules.py | 371 ++++ .../views/case_assignment_rule_views.xml | 120 ++ spp_case_cel/views/case_cel_menus.xml | 21 + spp_case_cel/views/case_triage_rule_views.xml | 117 ++ spp_case_demo/README.md | 202 ++ spp_case_demo/README.rst | 172 ++ spp_case_demo/__init__.py | 4 + spp_case_demo/__manifest__.py | 31 + spp_case_demo/data/case_stages.xml | 39 + spp_case_demo/data/case_types.xml | 39 + spp_case_demo/models/__init__.py | 4 + spp_case_demo/models/case_demo_stories.py | 475 +++++ spp_case_demo/models/generate_cases.py | 555 ++++++ spp_case_demo/pyproject.toml | 3 + spp_case_demo/readme/DESCRIPTION.md | 62 + spp_case_demo/security/ir.model.access.csv | 3 + spp_case_demo/static/description/icon.png | Bin 0 -> 15480 bytes spp_case_demo/static/description/index.html | 538 ++++++ spp_case_demo/views/case_demo_wizard_view.xml | 68 + spp_case_demo/wizard/__init__.py | 3 + spp_case_demo/wizard/case_demo_wizard.py | 13 + spp_case_entitlements/README.rst | 130 ++ spp_case_entitlements/__init__.py | 3 + spp_case_entitlements/__manifest__.py | 27 + spp_case_entitlements/models/__init__.py | 3 + spp_case_entitlements/models/case.py | 143 ++ spp_case_entitlements/pyproject.toml | 3 + spp_case_entitlements/readme/DESCRIPTION.md | 46 + .../security/ir.model.access.csv | 3 + .../static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 508 +++++ spp_case_entitlements/views/case_views.xml | 193 ++ spp_case_graduation/README.rst | 140 ++ spp_case_graduation/__init__.py | 3 + spp_case_graduation/__manifest__.py | 27 + spp_case_graduation/models/__init__.py | 4 + spp_case_graduation/models/case.py | 118 ++ .../models/graduation_assessment.py | 21 + spp_case_graduation/pyproject.toml | 3 + spp_case_graduation/readme/DESCRIPTION.md | 44 + .../security/ir.model.access.csv | 1 + .../static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 495 +++++ spp_case_graduation/views/case_views.xml | 52 + .../views/graduation_views.xml | 26 + spp_case_programs/README.md | 107 ++ spp_case_programs/README.rst | 140 ++ spp_case_programs/__init__.py | 3 + spp_case_programs/__manifest__.py | 27 + spp_case_programs/models/__init__.py | 3 + spp_case_programs/models/case.py | 107 ++ spp_case_programs/pyproject.toml | 3 + spp_case_programs/readme/DESCRIPTION.md | 51 + .../security/ir.model.access.csv | 3 + spp_case_programs/static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 516 +++++ spp_case_programs/tests/__init__.py | 3 + spp_case_programs/tests/test_case_programs.py | 193 ++ spp_case_programs/views/case_views.xml | 164 ++ spp_case_registry/README.rst | 140 ++ spp_case_registry/__init__.py | 1 + spp_case_registry/__manifest__.py | 29 + spp_case_registry/models/__init__.py | 1 + spp_case_registry/models/case.py | 107 ++ spp_case_registry/models/res_partner.py | 48 + spp_case_registry/pyproject.toml | 3 + spp_case_registry/readme/DESCRIPTION.md | 44 + .../security/ir.model.access.csv | 3 + spp_case_registry/static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 510 +++++ spp_case_registry/views/case_views.xml | 81 + spp_case_registry/views/res_partner_views.xml | 79 + spp_case_session/README.rst | 126 ++ spp_case_session/__init__.py | 3 + spp_case_session/__manifest__.py | 27 + spp_case_session/models/__init__.py | 4 + spp_case_session/models/case.py | 120 ++ spp_case_session/models/session.py | 44 + spp_case_session/pyproject.toml | 3 + spp_case_session/readme/DESCRIPTION.md | 42 + spp_case_session/security/ir.model.access.csv | 1 + spp_case_session/static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 488 +++++ spp_case_session/views/case_views.xml | 39 + spp_case_session/views/session_views.xml | 19 + spp_grm/README.rst | 75 +- spp_grm/__manifest__.py | 9 +- spp_grm/controllers/grm_portal.py | 13 +- spp_grm/data/grm_data.xml | 10 +- spp_grm/data/mail_templates.xml | 54 +- spp_grm/data/user_roles.xml | 5 +- spp_grm/models/grm_sla_rule.py | 2 +- spp_grm/models/grm_ticket.py | 67 +- spp_grm/security/compliance.yaml | 7 +- spp_grm/security/grm_security.xml | 2 +- spp_grm/security/groups.xml | 47 +- spp_grm/security/privileges.xml | 7 +- spp_grm/security/rules.xml | 166 +- .../description/OpenSPP-Helpdesk2-Icons.png | Bin 0 -> 4311 bytes spp_grm/static/description/index.html | 12 +- spp_grm/tests/test_compliance_generated.py | 143 +- spp_grm/tests/test_grm_security.py | 20 +- spp_grm/views/grm_portal_templates.xml | 25 +- spp_grm/views/grm_sla_rule_views.xml | 47 +- spp_grm/views/grm_team_views.xml | 36 +- spp_grm/views/grm_ticket_category_views.xml | 28 +- spp_grm/views/grm_ticket_channel_views.xml | 23 +- spp_grm/views/grm_ticket_menu.xml | 3 +- spp_grm/views/grm_ticket_stage_views.xml | 28 +- .../views/grm_ticket_subcategory_views.xml | 41 +- spp_grm/views/grm_ticket_tag_views.xml | 20 +- spp_grm/views/grm_ticket_views.xml | 392 +--- spp_grm/views/res_config_settings_views.xml | 9 +- spp_grm_case_link/README.rst | 151 ++ spp_grm_case_link/__init__.py | 4 + spp_grm_case_link/__manifest__.py | 32 + spp_grm_case_link/models/__init__.py | 4 + spp_grm_case_link/models/case.py | 78 + spp_grm_case_link/models/grm_ticket.py | 57 + spp_grm_case_link/pyproject.toml | 3 + spp_grm_case_link/readme/DESCRIPTION.md | 60 + .../security/grm_case_link_security.xml | 29 + .../security/ir.model.access.csv | 5 + spp_grm_case_link/static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 533 ++++++ spp_grm_case_link/views/case_views.xml | 111 ++ .../views/escalate_wizard_views.xml | 55 + spp_grm_case_link/views/grm_ticket_views.xml | 72 + spp_grm_case_link/wizard/__init__.py | 3 + .../wizard/escalate_to_case_wizard.py | 218 +++ spp_grm_cel/README.rst | 151 ++ spp_grm_cel/__init__.py | 1 + spp_grm_cel/__manifest__.py | 27 + spp_grm_cel/data/ir_cron.xml | 15 + spp_grm_cel/models/__init__.py | 3 + spp_grm_cel/models/grm_escalation_rule.py | 472 +++++ spp_grm_cel/models/grm_routing_rule.py | 264 +++ spp_grm_cel/models/grm_ticket.py | 121 ++ spp_grm_cel/pyproject.toml | 3 + spp_grm_cel/readme/DESCRIPTION.md | 56 + spp_grm_cel/security/ir.model.access.csv | 11 + spp_grm_cel/static/description/icon.png | Bin 0 -> 15480 bytes spp_grm_cel/static/description/index.html | 544 ++++++ spp_grm_cel/tests/__init__.py | 4 + spp_grm_cel/tests/test_escalation_rules.py | 384 ++++ spp_grm_cel/tests/test_routing_rules.py | 357 ++++ .../views/grm_escalation_rule_views.xml | 174 ++ spp_grm_cel/views/grm_routing_rule_views.xml | 137 ++ spp_grm_demo/README.md | 82 +- spp_grm_demo/README.rst | 40 +- spp_grm_demo/__manifest__.py | 13 +- spp_grm_demo/data/demo_users.xml | 67 + spp_grm_demo/data/ticket_categories.xml | 48 +- spp_grm_demo/models/generate_tickets.py | 145 +- spp_grm_demo/security/ir.model.access.csv | 1 + spp_grm_demo/static/description/index.html | 13 +- spp_grm_demo/tests/test_story_tickets.py | 2 - spp_grm_demo/views/grm_demo_wizard_view.xml | 9 +- spp_grm_programs/README.md | 95 + spp_grm_programs/README.rst | 129 ++ spp_grm_programs/__init__.py | 1 + spp_grm_programs/__manifest__.py | 25 + spp_grm_programs/models/__init__.py | 1 + spp_grm_programs/models/grm_ticket.py | 207 ++ spp_grm_programs/pyproject.toml | 3 + spp_grm_programs/readme/DESCRIPTION.md | 49 + spp_grm_programs/security/ir.model.access.csv | 1 + spp_grm_programs/static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 494 +++++ spp_grm_programs/views/grm_ticket_views.xml | 195 ++ spp_grm_registry/README.rst | 143 ++ spp_grm_registry/__init__.py | 1 + spp_grm_registry/__manifest__.py | 25 + spp_grm_registry/models/__init__.py | 2 + spp_grm_registry/models/grm_ticket.py | 114 ++ spp_grm_registry/models/res_partner.py | 72 + spp_grm_registry/pyproject.toml | 3 + spp_grm_registry/readme/DESCRIPTION.md | 57 + spp_grm_registry/security/ir.model.access.csv | 1 + spp_grm_registry/static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 507 +++++ spp_grm_registry/views/grm_ticket_views.xml | 163 ++ spp_grm_registry/views/res_partner_views.xml | 103 + 237 files changed, 26213 insertions(+), 994 deletions(-) create mode 100644 spp_case_base/README.rst create mode 100644 spp_case_base/__init__.py create mode 100644 spp_case_base/__manifest__.py create mode 100644 spp_case_base/data/ir_cron.xml create mode 100644 spp_case_base/models/__init__.py create mode 100644 spp_case_base/models/case.py create mode 100644 spp_case_base/models/case_assessment.py create mode 100644 spp_case_base/models/case_config.py create mode 100644 spp_case_base/models/case_intervention.py create mode 100644 spp_case_base/models/case_intervention_plan.py create mode 100644 spp_case_base/models/case_note.py create mode 100644 spp_case_base/models/case_referral.py create mode 100644 spp_case_base/models/case_stage.py create mode 100644 spp_case_base/models/case_type.py create mode 100644 spp_case_base/models/case_visit.py create mode 100644 spp_case_base/pyproject.toml create mode 100644 spp_case_base/readme/DESCRIPTION.md create mode 100644 spp_case_base/security/case_security.xml create mode 100644 spp_case_base/security/compliance.yaml create mode 100644 spp_case_base/security/groups.xml create mode 100644 spp_case_base/security/ir.model.access.csv create mode 100644 spp_case_base/security/privileges.xml create mode 100644 spp_case_base/security/rules.xml create mode 100644 spp_case_base/static/description/OpenSPP-Case-Management-Icons.png create mode 100644 spp_case_base/static/description/icon.png create mode 100644 spp_case_base/static/description/index.html create mode 100644 spp_case_base/tests/__init__.py create mode 100644 spp_case_base/tests/test_case.py create mode 100644 spp_case_base/tests/test_case_intervention.py create mode 100644 spp_case_base/tests/test_case_intervention_plan.py create mode 100644 spp_case_base/tests/test_case_security.py create mode 100644 spp_case_base/tests/test_compliance_generated.py create mode 100644 spp_case_base/views/case_activity_views.xml create mode 100644 spp_case_base/views/case_assessment_views.xml create mode 100644 spp_case_base/views/case_config_views.xml create mode 100644 spp_case_base/views/case_intervention_views.xml create mode 100644 spp_case_base/views/case_menus.xml create mode 100644 spp_case_base/views/case_stage_views.xml create mode 100644 spp_case_base/views/case_type_views.xml create mode 100644 spp_case_base/views/case_views.xml create mode 100644 spp_case_cel/README.rst create mode 100644 spp_case_cel/__init__.py create mode 100644 spp_case_cel/__manifest__.py create mode 100644 spp_case_cel/models/__init__.py create mode 100644 spp_case_cel/models/case.py create mode 100644 spp_case_cel/models/case_assignment_rule.py create mode 100644 spp_case_cel/models/case_triage_rule.py create mode 100644 spp_case_cel/pyproject.toml create mode 100644 spp_case_cel/readme/DESCRIPTION.md create mode 100644 spp_case_cel/security/ir.model.access.csv create mode 100644 spp_case_cel/static/description/icon.png create mode 100644 spp_case_cel/static/description/index.html create mode 100644 spp_case_cel/tests/__init__.py create mode 100644 spp_case_cel/tests/test_assignment_rules.py create mode 100644 spp_case_cel/tests/test_triage_rules.py create mode 100644 spp_case_cel/views/case_assignment_rule_views.xml create mode 100644 spp_case_cel/views/case_cel_menus.xml create mode 100644 spp_case_cel/views/case_triage_rule_views.xml create mode 100644 spp_case_demo/README.md create mode 100644 spp_case_demo/README.rst create mode 100644 spp_case_demo/__init__.py create mode 100644 spp_case_demo/__manifest__.py create mode 100644 spp_case_demo/data/case_stages.xml create mode 100644 spp_case_demo/data/case_types.xml create mode 100644 spp_case_demo/models/__init__.py create mode 100644 spp_case_demo/models/case_demo_stories.py create mode 100644 spp_case_demo/models/generate_cases.py create mode 100644 spp_case_demo/pyproject.toml create mode 100644 spp_case_demo/readme/DESCRIPTION.md create mode 100644 spp_case_demo/security/ir.model.access.csv create mode 100644 spp_case_demo/static/description/icon.png create mode 100644 spp_case_demo/static/description/index.html create mode 100644 spp_case_demo/views/case_demo_wizard_view.xml create mode 100644 spp_case_demo/wizard/__init__.py create mode 100644 spp_case_demo/wizard/case_demo_wizard.py create mode 100644 spp_case_entitlements/README.rst create mode 100644 spp_case_entitlements/__init__.py create mode 100644 spp_case_entitlements/__manifest__.py create mode 100644 spp_case_entitlements/models/__init__.py create mode 100644 spp_case_entitlements/models/case.py create mode 100644 spp_case_entitlements/pyproject.toml create mode 100644 spp_case_entitlements/readme/DESCRIPTION.md create mode 100644 spp_case_entitlements/security/ir.model.access.csv create mode 100644 spp_case_entitlements/static/description/icon.png create mode 100644 spp_case_entitlements/static/description/index.html create mode 100644 spp_case_entitlements/views/case_views.xml create mode 100644 spp_case_graduation/README.rst create mode 100644 spp_case_graduation/__init__.py create mode 100644 spp_case_graduation/__manifest__.py create mode 100644 spp_case_graduation/models/__init__.py create mode 100644 spp_case_graduation/models/case.py create mode 100644 spp_case_graduation/models/graduation_assessment.py create mode 100644 spp_case_graduation/pyproject.toml create mode 100644 spp_case_graduation/readme/DESCRIPTION.md create mode 100644 spp_case_graduation/security/ir.model.access.csv create mode 100644 spp_case_graduation/static/description/icon.png create mode 100644 spp_case_graduation/static/description/index.html create mode 100644 spp_case_graduation/views/case_views.xml create mode 100644 spp_case_graduation/views/graduation_views.xml create mode 100644 spp_case_programs/README.md create mode 100644 spp_case_programs/README.rst create mode 100644 spp_case_programs/__init__.py create mode 100644 spp_case_programs/__manifest__.py create mode 100644 spp_case_programs/models/__init__.py create mode 100644 spp_case_programs/models/case.py create mode 100644 spp_case_programs/pyproject.toml create mode 100644 spp_case_programs/readme/DESCRIPTION.md create mode 100644 spp_case_programs/security/ir.model.access.csv create mode 100644 spp_case_programs/static/description/icon.png create mode 100644 spp_case_programs/static/description/index.html create mode 100644 spp_case_programs/tests/__init__.py create mode 100644 spp_case_programs/tests/test_case_programs.py create mode 100644 spp_case_programs/views/case_views.xml create mode 100644 spp_case_registry/README.rst create mode 100644 spp_case_registry/__init__.py create mode 100644 spp_case_registry/__manifest__.py create mode 100644 spp_case_registry/models/__init__.py create mode 100644 spp_case_registry/models/case.py create mode 100644 spp_case_registry/models/res_partner.py create mode 100644 spp_case_registry/pyproject.toml create mode 100644 spp_case_registry/readme/DESCRIPTION.md create mode 100644 spp_case_registry/security/ir.model.access.csv create mode 100644 spp_case_registry/static/description/icon.png create mode 100644 spp_case_registry/static/description/index.html create mode 100644 spp_case_registry/views/case_views.xml create mode 100644 spp_case_registry/views/res_partner_views.xml create mode 100644 spp_case_session/README.rst create mode 100644 spp_case_session/__init__.py create mode 100644 spp_case_session/__manifest__.py create mode 100644 spp_case_session/models/__init__.py create mode 100644 spp_case_session/models/case.py create mode 100644 spp_case_session/models/session.py create mode 100644 spp_case_session/pyproject.toml create mode 100644 spp_case_session/readme/DESCRIPTION.md create mode 100644 spp_case_session/security/ir.model.access.csv create mode 100644 spp_case_session/static/description/icon.png create mode 100644 spp_case_session/static/description/index.html create mode 100644 spp_case_session/views/case_views.xml create mode 100644 spp_case_session/views/session_views.xml create mode 100644 spp_grm/static/description/OpenSPP-Helpdesk2-Icons.png create mode 100644 spp_grm_case_link/README.rst create mode 100644 spp_grm_case_link/__init__.py create mode 100644 spp_grm_case_link/__manifest__.py create mode 100644 spp_grm_case_link/models/__init__.py create mode 100644 spp_grm_case_link/models/case.py create mode 100644 spp_grm_case_link/models/grm_ticket.py create mode 100644 spp_grm_case_link/pyproject.toml create mode 100644 spp_grm_case_link/readme/DESCRIPTION.md create mode 100644 spp_grm_case_link/security/grm_case_link_security.xml create mode 100644 spp_grm_case_link/security/ir.model.access.csv create mode 100644 spp_grm_case_link/static/description/icon.png create mode 100644 spp_grm_case_link/static/description/index.html create mode 100644 spp_grm_case_link/views/case_views.xml create mode 100644 spp_grm_case_link/views/escalate_wizard_views.xml create mode 100644 spp_grm_case_link/views/grm_ticket_views.xml create mode 100644 spp_grm_case_link/wizard/__init__.py create mode 100644 spp_grm_case_link/wizard/escalate_to_case_wizard.py create mode 100644 spp_grm_cel/README.rst create mode 100644 spp_grm_cel/__init__.py create mode 100644 spp_grm_cel/__manifest__.py create mode 100644 spp_grm_cel/data/ir_cron.xml create mode 100644 spp_grm_cel/models/__init__.py create mode 100644 spp_grm_cel/models/grm_escalation_rule.py create mode 100644 spp_grm_cel/models/grm_routing_rule.py create mode 100644 spp_grm_cel/models/grm_ticket.py create mode 100644 spp_grm_cel/pyproject.toml create mode 100644 spp_grm_cel/readme/DESCRIPTION.md create mode 100644 spp_grm_cel/security/ir.model.access.csv create mode 100644 spp_grm_cel/static/description/icon.png create mode 100644 spp_grm_cel/static/description/index.html create mode 100644 spp_grm_cel/tests/__init__.py create mode 100644 spp_grm_cel/tests/test_escalation_rules.py create mode 100644 spp_grm_cel/tests/test_routing_rules.py create mode 100644 spp_grm_cel/views/grm_escalation_rule_views.xml create mode 100644 spp_grm_cel/views/grm_routing_rule_views.xml create mode 100644 spp_grm_demo/data/demo_users.xml create mode 100644 spp_grm_programs/README.md create mode 100644 spp_grm_programs/README.rst create mode 100644 spp_grm_programs/__init__.py create mode 100644 spp_grm_programs/__manifest__.py create mode 100644 spp_grm_programs/models/__init__.py create mode 100644 spp_grm_programs/models/grm_ticket.py create mode 100644 spp_grm_programs/pyproject.toml create mode 100644 spp_grm_programs/readme/DESCRIPTION.md create mode 100644 spp_grm_programs/security/ir.model.access.csv create mode 100644 spp_grm_programs/static/description/icon.png create mode 100644 spp_grm_programs/static/description/index.html create mode 100644 spp_grm_programs/views/grm_ticket_views.xml create mode 100644 spp_grm_registry/README.rst create mode 100644 spp_grm_registry/__init__.py create mode 100644 spp_grm_registry/__manifest__.py create mode 100644 spp_grm_registry/models/__init__.py create mode 100644 spp_grm_registry/models/grm_ticket.py create mode 100644 spp_grm_registry/models/res_partner.py create mode 100644 spp_grm_registry/pyproject.toml create mode 100644 spp_grm_registry/readme/DESCRIPTION.md create mode 100644 spp_grm_registry/security/ir.model.access.csv create mode 100644 spp_grm_registry/static/description/icon.png create mode 100644 spp_grm_registry/static/description/index.html create mode 100644 spp_grm_registry/views/grm_ticket_views.xml create mode 100644 spp_grm_registry/views/res_partner_views.xml diff --git a/spp_case_base/README.rst b/spp_case_base/README.rst new file mode 100644 index 00000000..f4e0610e --- /dev/null +++ b/spp_case_base/README.rst @@ -0,0 +1,179 @@ +============================ +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-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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%2Fopenspp--modules-lightgray.png?logo=github + :target: https://github.com/OpenSPP/openspp-modules/tree/19.0/spp_case_base + :alt: OpenSPP/openspp-modules + +|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`` + +.. 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:: + :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 + +Maintainers +----------- + +This module is part of the `OpenSPP/openspp-modules `_ project on GitHub. + +You are welcome to contribute. 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..83798ee2 --- /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", + "website": "https://github.com/OpenSPP/openspp-modules", + "license": "LGPL-3", + "development_status": "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..d3c807f4 --- /dev/null +++ b/spp_case_base/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + 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..a2d28afd --- /dev/null +++ b/spp_case_base/models/case.py @@ -0,0 +1,508 @@ +"""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"), + ], + string="Intensity Level", + default="2", + required=True, + tracking=True, + help="Level of case management intensity required", + ) + + priority = fields.Selection( + [ + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("urgent", "Urgent"), + ], + string="Priority", + default="medium", + required=True, + tracking=True, + ) + + # Client Information + client_type = fields.Selection( + [ + ("individual", "Individual"), + ("household", "Household"), + ("group", "Group"), + ], + string="Client Type", + 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( + string="Opened Date", + default=fields.Date.context_today, + required=True, + tracking=True, + ) + + target_closure_date = fields.Date( + string="Target Closure Date", + tracking=True, + ) + + actual_closure_date = fields.Date( + string="Actual Closure 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"), + ], + string="Intake Source", + tracking=True, + ) + + referral_source = fields.Char( + string="Referral Source", + help="Name or organization that referred the client", + ) + + presenting_issue = fields.Html( + string="Presenting Issue", + 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( + string="Assessment Count", + compute="_compute_related_counts", + ) + intervention_plan_count = fields.Integer( + string="Intervention Plan Count", + compute="_compute_related_counts", + ) + visit_count = fields.Integer( + string="Visit Count", + compute="_compute_related_counts", + ) + note_count = fields.Integer( + string="Note Count", + compute="_compute_related_counts", + ) + referral_count = fields.Integer( + string="Referral Count", + 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"), + ], + string="Closure Outcome", + ) + + closure_summary = fields.Html( + string="Closure Summary", + ) + + # Review Dates + next_review_date = fields.Date( + string="Next Review Date", + tracking=True, + ) + + last_review_date = fields.Date( + string="Last Review Date", + ) + + # Company + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + ) + + # UI Fields + active = fields.Boolean( + string="Active", + 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( + string="Days Open", + compute="_compute_days_open", + store=False, + ) + + is_active = fields.Boolean( + string="Is Active", + compute="_compute_is_active", + store=True, + ) + + has_active_plan = fields.Boolean( + string="Has Active Plan", + 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.""" + 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(f"Stage '{case.stage_id.name}' requires an approved intervention plan.") + + # Check minimum intensity level + if case.stage_id.min_intensity: + if int(case.intensity_level) < int(case.stage_id.min_intensity): + raise ValidationError( + f"Stage '{case.stage_id.name}' requires minimum intensity level " + f"{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 %s is due for review. Last review: %s") % (case.name, 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 %s review is scheduled for %s") % (case.name, 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..1d6d3940 --- /dev/null +++ b/spp_case_base/models/case_assessment.py @@ -0,0 +1,261 @@ +"""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( + string="Assessment 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"), + ], + string="Assessment Type", + required=True, + default="periodic", + tracking=True, + ) + + # Assessment Content + findings = fields.Html( + string="Findings", + help="Key findings from the assessment", + ) + + recommendations = fields.Html( + string="Recommendations", + help="Recommended actions based on assessment", + ) + + # Risk Assessment + risk_score = fields.Float( + string="Risk Score", + help="Risk score from 0 to 100", + tracking=True, + ) + + risk_level = fields.Selection( + [ + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("critical", "Critical"), + ], + string="Risk Level", + 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"), + ], + string="State", + 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( + string="Reviewed Date", + 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( + string="Active", + 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..d9222510 --- /dev/null +++ b/spp_case_base/models/case_intervention.py @@ -0,0 +1,178 @@ +"""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( + string="Sequence", + default=10, + help="Order of interventions in the plan", + ) + + description = fields.Text( + string="Description", + help="Detailed description of the intervention", + ) + + target_outcome = fields.Char( + string="Target Outcome", + 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( + string="Target Date", + help="Target completion date", + ) + + completed_date = fields.Date( + string="Completed 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( + string="Completion Notes", + help="Notes about the completion or outcome of the intervention", + ) + + # Computed fields + is_overdue = fields.Boolean( + string="Is Overdue", + compute="_compute_is_overdue", + store=False, + ) + + days_until_due = fields.Integer( + string="Days Until Due", + 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..f74c8214 --- /dev/null +++ b/spp_case_base/models/case_intervention_plan.py @@ -0,0 +1,272 @@ +"""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( + string="Version", + 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( + string="Goals", + required=True, + help="Case management goals to be achieved", + ) + + expected_outcomes = fields.Html( + string="Expected Outcomes", + help="Expected outcomes and success criteria", + ) + + client_responsibilities = fields.Html( + string="Client Responsibilities", + help="Client's roles and responsibilities in the plan", + ) + + # Dates + start_date = fields.Date( + string="Start Date", + default=fields.Date.context_today, + required=True, + tracking=True, + ) + + target_end_date = fields.Date( + string="Target End Date", + tracking=True, + ) + + actual_end_date = fields.Date( + string="Actual End 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( + string="Approved Date", + 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..c463ca10 --- /dev/null +++ b/spp_case_base/models/case_note.py @@ -0,0 +1,114 @@ +"""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( + string="Note Date", + 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"), + ], + string="Note Type", + required=True, + default="general", + ) + + content = fields.Html( + string="Content", + 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=f"New {note_type_label} added by {note.author_id.name}", + subject=f"Case Note: {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..c5ccbc7b --- /dev/null +++ b/spp_case_base/models/case_referral.py @@ -0,0 +1,146 @@ +"""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( + string="Referral 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( + string="Service Name", + required=True, + help="Name of the service being referred to", + ) + + provider_name = fields.Char( + string="Provider Name", + 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"), + ], + string="Status", + required=True, + default="pending", + ) + + outcome = fields.Text( + string="Outcome", + 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( + string="Is Overdue", + 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=f"Referral created to {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..e31e85af --- /dev/null +++ b/spp_case_base/models/case_type.py @@ -0,0 +1,112 @@ +"""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( + string="Recommended Caseload", + 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..10d119b1 --- /dev/null +++ b/spp_case_base/models/case_visit.py @@ -0,0 +1,123 @@ +"""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( + string="Visit Date", + required=True, + default=fields.Datetime.now, + ) + + visit_type = fields.Selection( + [ + ("home", "Home Visit"), + ("office", "Office Visit"), + ("phone", "Phone Call"), + ("virtual", "Virtual Meeting"), + ], + string="Visit Type", + required=True, + default="office", + ) + + duration_minutes = fields.Integer( + string="Duration (Minutes)", + help="Duration of the visit in minutes", + ) + + purpose = fields.Char( + string="Purpose", + 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..4b23df4c --- /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..8e639bbf --- /dev/null +++ b/spp_case_base/security/compliance.yaml @@ -0,0 +1,505 @@ +# 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..f17be55a --- /dev/null +++ b/spp_case_base/security/groups.xml @@ -0,0 +1,60 @@ + + + + + + + + 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..275ed5e4 --- /dev/null +++ b/spp_case_base/security/privileges.xml @@ -0,0 +1,12 @@ + + + + + + + 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..2461202a --- /dev/null +++ b/spp_case_base/security/rules.xml @@ -0,0 +1,211 @@ + + + + + + + + + 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 0000000000000000000000000000000000000000..3d13149941d3bbbcaf923dd1fdef6051315849ca GIT binary patch literal 5069 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 Z*+HRVHg!+G)d3u}^Tt+2RR)+l{{bj}Gg$xt literal 0 HcmV?d00001 diff --git a/spp_case_base/static/description/icon.png b/spp_case_base/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 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 + + + + +OpenSPP Case Management Base + + + +
+

OpenSPP Case Management Base

+ + +

Alpha License: LGPL-3 OpenSPP/openspp-modules

+

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

+
+

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

+ +
+

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
  • +
+
+
+

Maintainers

+

This module is part of the OpenSPP/openspp-modules 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..96b1b332 --- /dev/null +++ b/spp_case_base/tests/__init__.py @@ -0,0 +1,6 @@ +"""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 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..ad89d139 --- /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..12c4ad52 --- /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_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..9a963852 --- /dev/null +++ b/spp_case_base/tests/test_compliance_generated.py @@ -0,0 +1,1690 @@ +# 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.""" + menu = self.env.ref(menu_xml_id, raise_if_not_found=False) + if not menu: + return None # Menu not found + # Get visible menus for user + visible_menus = self.env["ir.ui.menu"].with_user(user).search([]) + return menu in visible_menus + + 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.assertFalse(visible, "Viewer should NOT see Case Management") + 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.assertFalse(visible, "Viewer should NOT see Cases") + 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.assertFalse(visible, "Viewer should NOT see My Cases") + 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.assertFalse(visible, "Viewer should NOT see All Cases") + 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.assertFalse(visible, "Viewer should NOT see Activities") + 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.assertFalse(visible, "Viewer should NOT see Visits") + 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.assertFalse(visible, "Viewer should NOT see Notes") + 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.assertFalse(visible, "Viewer should NOT see Referrals") + 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.assertFalse(visible, "Viewer should NOT see Assessments") + 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.assertFalse(visible, "Viewer should NOT see Planning") + 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.assertFalse(visible, "Viewer should NOT see Intervention Plans") + 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.assertFalse(visible, "Viewer should NOT see Interventions") + 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.assertFalse(visible, "Viewer should NOT see Case Setup") + 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.assertFalse(visible, "Viewer should NOT see Case Types") + 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.assertFalse(visible, "Viewer should NOT see Stages") + 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.assertFalse(visible, "Viewer should NOT see Teams") + 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.assertFalse(visible, "Viewer should NOT see Assessment") + 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.assertFalse(visible, "Viewer should NOT see Risk Factors") + 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.assertFalse(visible, "Viewer should NOT see Vulnerabilities") + 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.assertFalse(visible, "Viewer should NOT see Closure") + 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.assertFalse(visible, "Viewer should NOT see Closure Reasons") + 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..efaa3efb --- /dev/null +++ b/spp_case_base/views/case_activity_views.xml @@ -0,0 +1,352 @@ + + + + + + + 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..b0d0368e --- /dev/null +++ b/spp_case_base/views/case_assessment_views.xml @@ -0,0 +1,165 @@ + + + + + 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..07a1e4f8 --- /dev/null +++ b/spp_case_base/views/case_config_views.xml @@ -0,0 +1,372 @@ + + + + + + + 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..9b62940f --- /dev/null +++ b/spp_case_base/views/case_views.xml @@ -0,0 +1,439 @@ + + + + + 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..f22edd36 --- /dev/null +++ b/spp_case_cel/README.rst @@ -0,0 +1,156 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================== +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-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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%2Fopenspp--modules-lightgray.png?logo=github + :target: https://github.com/OpenSPP/openspp-modules/tree/19.0/spp_case_cel + :alt: OpenSPP/openspp-modules + +|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/openspp-modules `_ project on GitHub. + +You are welcome to contribute. 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..0bdb10f1 --- /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": "Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/openspp-modules", + "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..634990e6 --- /dev/null +++ b/spp_case_cel/models/case_assignment_rule.py @@ -0,0 +1,245 @@ +# 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 '%s': %s") % (rule.name, 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, + ) + + 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..c87be04e --- /dev/null +++ b/spp_case_cel/models/case_triage_rule.py @@ -0,0 +1,236 @@ +# 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"), + ], + string="Set Priority", + 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 '%s': %s") % (rule.name, 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]}) + + 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 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 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 + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

OpenSPP Case Management: CEL Rules

+ +

Beta License: LGPL-3 OpenSPP/openspp-modules

+

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/openspp-modules 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..94bf0560 --- /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..e6a8ddaa --- /dev/null +++ b/spp_case_cel/views/case_assignment_rule_views.xml @@ -0,0 +1,120 @@ + + + + + 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..44c0fcb0 --- /dev/null +++ b/spp_case_cel/views/case_cel_menus.xml @@ -0,0 +1,21 @@ + + + + + + + + + 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..77dfe362 --- /dev/null +++ b/spp_case_cel/views/case_triage_rule_views.xml @@ -0,0 +1,117 @@ + + + + + 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..686b6f16 --- /dev/null +++ b/spp_case_demo/README.md @@ -0,0 +1,202 @@ +# 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..ad553eba --- /dev/null +++ b/spp_case_demo/README.rst @@ -0,0 +1,172 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================= +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-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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%2Fopenspp--modules-lightgray.png?logo=github + :target: https://github.com/OpenSPP/openspp-modules/tree/19.0/spp_case_demo + :alt: OpenSPP/openspp-modules + +|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/openspp-modules `_ project on GitHub. + +You are welcome to contribute. 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..c93d4bea --- /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/openspp-modules", + "license": "LGPL-3", + "development_status": "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..eccdebac --- /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..681761d6 --- /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..9fcb7efd --- /dev/null +++ b/spp_case_demo/models/generate_cases.py @@ -0,0 +1,555 @@ +# 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(string="Name", 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", + string="Locale Origin", + 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 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 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 + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

OpenSPP Case Management Demo Data

+ +

Beta License: LGPL-3 OpenSPP/openspp-modules

+

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/openspp-modules project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+
+ + 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..db12859e --- /dev/null +++ b/spp_case_demo/views/case_demo_wizard_view.xml @@ -0,0 +1,68 @@ + + + + + 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..2cdae421 --- /dev/null +++ b/spp_case_entitlements/README.rst @@ -0,0 +1,130 @@ +===================================== +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-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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%2Fopenspp--modules-lightgray.png?logo=github + :target: https://github.com/OpenSPP/openspp-modules/tree/19.0/spp_case_entitlements + :alt: OpenSPP/openspp-modules + +|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`` + +.. 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:: + :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 + +Maintainers +----------- + +This module is part of the `OpenSPP/openspp-modules `_ project on GitHub. + +You are welcome to contribute. 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..74c467b2 --- /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", + "website": "https://github.com/OpenSPP/openspp-modules", + "license": "LGPL-3", + "development_status": "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..d2fa295a --- /dev/null +++ b/spp_case_entitlements/models/case.py @@ -0,0 +1,143 @@ +"""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( + string="Entitlement Count", + compute="_compute_entitlement_info", + store=True, + help="Number of entitlements related to this case", + ) + + has_entitlements = fields.Boolean( + string="Has Entitlements", + 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 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 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 + + + + +OpenSPP Case Entitlements Integration + + + +
+

OpenSPP Case Entitlements Integration

+ + +

Alpha License: LGPL-3 OpenSPP/openspp-modules

+

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

+
+

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

+ +
+

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
  • +
+
+
+

Maintainers

+

This module is part of the OpenSPP/openspp-modules project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_case_entitlements/views/case_views.xml b/spp_case_entitlements/views/case_views.xml new file mode 100644 index 00000000..22369b99 --- /dev/null +++ b/spp_case_entitlements/views/case_views.xml @@ -0,0 +1,193 @@ + + + + + + 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..649ac8aa --- /dev/null +++ b/spp_case_session/README.rst @@ -0,0 +1,126 @@ +============================================ +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-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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%2Fopenspp--modules--v2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/openspp-modules-v2/tree/19.0/spp_case_session + :alt: OpenSPP/openspp-modules-v2 + +|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-emjay0921| image:: https://github.com/emjay0921.png?size=40px + :target: https://github.com/emjay0921 + :alt: emjay0921 + +Current maintainer: + +|maintainer-emjay0921| + +This module is part of the `OpenSPP/openspp-modules-v2 `_ project on GitHub. + +You are welcome to contribute. 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..d4e83fb5 --- /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": "Stable", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/openspp-modules", + "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..b3b298ef --- /dev/null +++ b/spp_case_session/models/case.py @@ -0,0 +1,120 @@ +# 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", + string="Session Count", + ) + + 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", + string="Session Compliance", + ) + + @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..66e3ce9c --- /dev/null +++ b/spp_case_session/models/session.py @@ -0,0 +1,44 @@ +# 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", + string="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 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 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 + + + + +OpenSPP Case Management: Session Integration + + + +
+

OpenSPP Case Management: Session Integration

+ + +

Alpha License: LGPL-3 OpenSPP/openspp-modules

+

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

+
+

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

+ +
+

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

+

This module is part of the OpenSPP/openspp-modules project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_case_session/views/case_views.xml b/spp_case_session/views/case_views.xml new file mode 100644 index 00000000..e2779b6c --- /dev/null +++ b/spp_case_session/views/case_views.xml @@ -0,0 +1,39 @@ + + + + + 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..be121167 --- /dev/null +++ b/spp_case_session/views/session_views.xml @@ -0,0 +1,19 @@ + + + + + spp.session.form.case + spp.session + + + +
+ +
+
+
+
+
diff --git a/spp_grm/README.rst b/spp_grm/README.rst index ff624b77..10e2a44f 100644 --- a/spp_grm/README.rst +++ b/spp_grm/README.rst @@ -10,17 +10,14 @@ OpenSPP - Grievance Redress Mechanism !! source digest: sha256:43265e32349a8a3ecd313501c4fcd953c80923fe27bba7dbb7c55d8ebc8e4540 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. |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 +.. |badge1| 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 - :alt: OpenSPP/OpenSPP2 +.. |badge2| image:: https://img.shields.io/badge/github-OpenSPP%2Fopenspp--modules-lightgray.png?logo=github + :target: https://github.com/OpenSPP/openspp-modules/tree/19.0/spp_grm + :alt: OpenSPP/openspp-modules -|badge1| |badge2| |badge3| +|badge1| |badge2| Centralized grievance and complaint management for social protection programs. Receives complaints through multiple channels (email, portal, @@ -55,33 +52,33 @@ Key Capabilities Key Models ~~~~~~~~~~ -+--------------------------------+-------------------------------------+ -| Model | Description | -+================================+=====================================+ -| ``spp.grm.ticket`` | Main complaint/grievance with SLA | -| | tracking and decision fields | -+--------------------------------+-------------------------------------+ -| ``spp.grm.ticket.stage`` | Workflow stage with access control | -| | and closure configuration | -+--------------------------------+-------------------------------------+ -| ``spp.grm.ticket.category`` | Primary classification with | -| | hierarchical structure | -+--------------------------------+-------------------------------------+ -| ``spp.grm.ticket.subcategory`` | Second-level classification under | -| | category | -+--------------------------------+-------------------------------------+ -| ``spp.grm.team`` | Team of handlers with manager and | -| | geographic areas | -+--------------------------------+-------------------------------------+ -| ``spp.grm.sla.rule`` | Conditional SLA rules with | -| | escalation targets | -+--------------------------------+-------------------------------------+ -| ``spp.grm.ticket.tag`` | Tags for flexible ticket | -| | classification | -+--------------------------------+-------------------------------------+ -| ``spp.grm.ticket.channel`` | Communication channel (email, | -| | phone, walk-in, portal, etc.) | -+--------------------------------+-------------------------------------+ ++--------------------------------+------------------------------------+ +| Model | Description | ++================================+====================================+ +| ``spp.grm.ticket`` | Main complaint/grievance with SLA | +| | tracking and decision fields | ++--------------------------------+------------------------------------+ +| ``spp.grm.ticket.stage`` | Workflow stage with access control | +| | and closure configuration | ++--------------------------------+------------------------------------+ +| ``spp.grm.ticket.category`` | Primary classification with | +| | hierarchical structure | ++--------------------------------+------------------------------------+ +| ``spp.grm.ticket.subcategory`` | Second-level classification under | +| | category | ++--------------------------------+------------------------------------+ +| ``spp.grm.team`` | Team of handlers with manager and | +| | geographic areas | ++--------------------------------+------------------------------------+ +| ``spp.grm.sla.rule`` | Conditional SLA rules with | +| | escalation targets | ++--------------------------------+------------------------------------+ +| ``spp.grm.ticket.tag`` | Tags for flexible ticket | +| | classification | ++--------------------------------+------------------------------------+ +| ``spp.grm.ticket.channel`` | Communication channel (email, | +| | phone, walk-in, portal, etc.) | ++--------------------------------+------------------------------------+ Configuration ~~~~~~~~~~~~~ @@ -153,10 +150,10 @@ Dependencies Bug Tracker =========== -Bugs are tracked on `GitHub Issues `_. +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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -182,6 +179,6 @@ Current maintainers: |maintainer-jeremi| |maintainer-gonzalesedwin1123| -This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. +This module is part of the `OpenSPP/openspp-modules `_ project on GitHub. -You are welcome to contribute. \ No newline at end of file +You are welcome to contribute. diff --git a/spp_grm/__manifest__.py b/spp_grm/__manifest__.py index ca70c5d1..1bb41e8f 100644 --- a/spp_grm/__manifest__.py +++ b/spp_grm/__manifest__.py @@ -3,15 +3,15 @@ { "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", + "website": "https://github.com/OpenSPP/openspp-modules", "license": "LGPL-3", - "development_status": "Production/Stable", + "development_status": "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..0cbf185b 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,12 @@ 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 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 - return request.redirect("/my/tickets") + return request.redirect( + "/my/tickets" + ) # nosemgrep: odoo-unvalidated-redirect - Redirect target is fixed internal '/my/tickets' URL. diff --git a/spp_grm/data/grm_data.xml b/spp_grm/data/grm_data.xml index ada68782..122feec3 100644 --- a/spp_grm/data/grm_data.xml +++ b/spp_grm/data/grm_data.xml @@ -3,9 +3,7 @@ Grievance Redress Mechanism - Grievance Redress Mechanism (GRM) for OpenSPP. + Grievance Redress Mechanism (GRM) for OpenSPP. 9 @@ -26,6 +24,7 @@ 1 New + new True False @@ -33,6 +32,7 @@ 2 In Progress + in_progress False False @@ -40,6 +40,7 @@ 3 Awaiting + waiting False False @@ -47,6 +48,7 @@ 4 Done + resolved False True True @@ -56,6 +58,7 @@ 5 Cancelled + cancelled False True True @@ -65,6 +68,7 @@ 6 Rejected + closed False True True diff --git a/spp_grm/data/mail_templates.xml b/spp_grm/data/mail_templates.xml index 2547f1cd..c831adb0 100644 --- a/spp_grm/data/mail_templates.xml +++ b/spp_grm/data/mail_templates.xml @@ -3,9 +3,7 @@ Ticket Submission Confirmation - Your Ticket: {{ object.number }} has been submitted + Your Ticket: {{ object.number }} has been submitted {{ (user.email or 'support@yourdomain.com') }} {{ object.partner_id.email }} @@ -13,44 +11,28 @@

- Dear Registrant Name, + Dear Registrant Name,

Thank you for reaching out to us. Your ticket has been successfully created with the following details:

+ >Thank you for reaching out to us. Your ticket has been successfully created with the following details:

    -
  • - Ticket No.: - 2024-00000 -
  • -
  • - Ticket Name: - Ticket Name -
  • -
  • - Description: - Description -
  • -
  • - Category: - Category -
  • -
  • - Channel: - Channel -
  • -
+
  • Ticket No.: 2024-00000
  • +
  • Ticket Name: Ticket Name
  • +
  • Description: Description
  • +
  • Category: Category
  • +
  • Channel: Channel
  • + -

    We will get back to you shortly. You can track the status of your ticket through the Ticket Portal.

    +

    We will get back to you shortly. You can track the status of your ticket through the Ticket Portal.

    Best regards,
    The Support Team

    diff --git a/spp_grm/data/user_roles.xml b/spp_grm/data/user_roles.xml index bba3c505..1cfb61c5 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,9 +47,10 @@ 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..2e92a629 100644 --- a/spp_grm/models/grm_sla_rule.py +++ b/spp_grm/models/grm_sla_rule.py @@ -147,7 +147,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.name, e) return False return True diff --git a/spp_grm/models/grm_ticket.py b/spp_grm/models/grm_ticket.py index f7fb5f6e..965a4890 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) @@ -139,6 +136,7 @@ def _default_stage_id(self): 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( @@ -293,9 +291,8 @@ def _default_stage_id(self): tracking=True, help="Final decision on the complaint", ) - resolution_summary = fields.Html( + resolution_summary = fields.Text( string="Resolution Summary", - sanitize_style=True, help="Detailed summary of the resolution", ) @@ -398,7 +395,10 @@ def write(self, vals): # For now, only supervisors/managers can move to approval-required stages if not self.env.user.has_group("spp_grm.group_grm_supervisor"): raise UserError( - _("Stage '%s' requires supervisor approval. Please request approval from your supervisor.") + _( + "Stage '%s' requires supervisor approval. " + "Please request approval from your supervisor." + ) % new_stage.name ) @@ -438,7 +438,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 +450,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 +557,7 @@ 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 + ticket.sudo()._on_sla_breach() def _on_sla_breach(self): """Called when ticket SLA status changes to breached. @@ -575,10 +576,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 +646,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 +696,7 @@ 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 + template.sudo().send_mail( ticket.id, force_send=True, email_values={ diff --git a/spp_grm/security/compliance.yaml b/spp_grm/security/compliance.yaml index 67bd1924..78e81ce6 100644 --- a/spp_grm/security/compliance.yaml +++ b/spp_grm/security/compliance.yaml @@ -50,16 +50,13 @@ groups: tier: 2 privilege_id: privilege_grm_supervisor implied_ids: [group_grm_officer] - comment: - "SPECIALIZED ROLE: Manage team tickets, approve resolutions. Between Officer and - Manager." + comment: "SPECIALIZED ROLE: Manage team tickets, approve resolutions. Between Officer and Manager." # --- Backward compatibility --- - id: group_grm_user tier: 2 implied_ids: [group_grm_viewer] - comment: - "Deprecated: Use group_grm_viewer instead. Kept for backward compatibility." + comment: "Deprecated: Use group_grm_viewer instead. Kept for backward compatibility." # Admin linkage - manager group links to spp_security.group_spp_admin admin_link_group: group_grm_manager diff --git a/spp_grm/security/grm_security.xml b/spp_grm/security/grm_security.xml index a1e74eae..af675e0b 100644 --- a/spp_grm/security/grm_security.xml +++ b/spp_grm/security/grm_security.xml @@ -1,4 +1,4 @@ - + diff --git a/spp_grm/security/groups.xml b/spp_grm/security/groups.xml index 7dde3083..b90139ce 100644 --- a/spp_grm/security/groups.xml +++ b/spp_grm/security/groups.xml @@ -1,4 +1,4 @@ - + @@ -12,62 +12,49 @@ GRM: Write Technical group for write access to GRM models. - + Viewer - - Can view assigned GRM tickets only. Cannot modify data. - + + Can view assigned GRM tickets only. Cannot modify data. + Officer - - Can create and edit own tickets, view team tickets. Cannot delete. - + + Can create and edit own tickets, view team tickets. Cannot delete. + Manager - - Full GRM access including configuration and delete operations. - + + Full GRM access including configuration and delete operations. + Supervisor - - GRM: Can manage team tickets, approve resolutions. Positioned between Officer and Manager in the permission hierarchy. Inherits officer permissions. - + + GRM: Can manage team tickets, approve resolutions. Positioned between Officer and Manager in the permission hierarchy. Inherits officer permissions. + - + GRM User (Deprecated) - Deprecated: Use group_grm_viewer instead. This group is kept for backward compatibility. - + Deprecated: Use group_grm_viewer instead. This group is kept for backward compatibility. + diff --git a/spp_grm/security/privileges.xml b/spp_grm/security/privileges.xml index 794e622e..b1ba8000 100644 --- a/spp_grm/security/privileges.xml +++ b/spp_grm/security/privileges.xml @@ -1,11 +1,12 @@ - + - + Grievance Management - + Access to grievance and ticket management + diff --git a/spp_grm/security/rules.xml b/spp_grm/security/rules.xml index db84edc2..6a593a70 100644 --- a/spp_grm/security/rules.xml +++ b/spp_grm/security/rules.xml @@ -1,95 +1,109 @@ - - - - + + + + + - - - GRM Ticket: Viewer Own Tickets Only - - [('user_id', '=', user.id)] - - - - - - + + + GRM Ticket: Viewer Own Tickets Only + + [('user_id', '=', user.id)] + + + + + + - - - GRM Ticket: Officer Own and Team Tickets - - [ + + + GRM Ticket: Officer Own and Team Tickets + + [ '|', ('user_id', '=', user.id), ('team_id.member_ids', 'in', [user.id]) ] - - - - - - + + + + + + - - - GRM Ticket: Supervisor Supervised Team Tickets - - [ + + + GRM Ticket: Officer Create + + [(1, '=', 1)] + + + + + + + + + + GRM Ticket: Supervisor Supervised Team Tickets + + [ '|', ('user_id', '=', user.id), '|', ('team_id.member_ids', 'in', [user.id]), ('team_id.manager_id', '=', user.id) ] - - - - - - + + + + + + - - - GRM Ticket: Manager All Tickets - - [(1, '=', 1)] - - - - - - + + + GRM Ticket: Manager All Tickets + + [(1, '=', 1)] + + + + + + - - - GRM Team: Manager Full Access - - [(1, '=', 1)] - - + + + GRM Team: Manager Full Access + + [(1, '=', 1)] + + - - - GRM Ticket Category: Manager Full Access - - [(1, '=', 1)] - - + + + GRM Ticket Category: Manager Full Access + + [(1, '=', 1)] + + - - - GRM Ticket Stage: Manager Full Access - - [(1, '=', 1)] - - + + + GRM Ticket Stage: Manager Full Access + + [(1, '=', 1)] + + - - - GRM SLA Rule: Manager Full Access - - [(1, '=', 1)] - - + + + GRM SLA Rule: Manager Full Access + + [(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 0000000000000000000000000000000000000000..5a3c2bca5d7b85299c9e174a27d9b4c1f9230cdc GIT binary patch literal 4311 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 LHrIcB2^;-Cjcn%G literal 0 HcmV?d00001 diff --git a/spp_grm/static/description/index.html b/spp_grm/static/description/index.html index b8254eeb..365ee8a6 100644 --- a/spp_grm/static/description/index.html +++ b/spp_grm/static/description/index.html @@ -369,7 +369,7 @@

    OpenSPP - Grievance Redress Mechanism

    !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:43265e32349a8a3ecd313501c4fcd953c80923fe27bba7dbb7c55d8ebc8e4540 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

    Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

    +

    License: LGPL-3 OpenSPP/openspp-modules

    Centralized grievance and complaint management for social protection programs. Receives complaints through multiple channels (email, portal, manual entry), tracks resolution through customizable workflow stages, @@ -404,8 +404,8 @@

    Key Capabilities

    Key Models

    --++ @@ -538,10 +538,10 @@

    Dependencies

    Bug Tracker

    -

    Bugs are tracked on GitHub Issues. +

    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.

    +feedback.

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

    @@ -556,7 +556,7 @@

    Authors

    Maintainers

    Current maintainers:

    jeremi gonzalesedwin1123

    -

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

    +

    This module is part of the OpenSPP/openspp-modules 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_portal_templates.xml b/spp_grm/views/grm_portal_templates.xml index 40a57a12..bbd105e1 100644 --- a/spp_grm/views/grm_portal_templates.xml +++ b/spp_grm/views/grm_portal_templates.xml @@ -12,10 +12,7 @@ t-if="page_name == 'tickets'" t-attf-class="breadcrumb-item #{'active ' if not ticket else ''}" > - Tickets + TicketsTickets @@ -60,10 +57,7 @@ - + @@ -114,9 +108,7 @@ - - + - +
    Model - -
    - +
    Select Category - +
    @@ -188,4 +174,5 @@
    + diff --git a/spp_grm/views/grm_sla_rule_views.xml b/spp_grm/views/grm_sla_rule_views.xml index 0027fa9e..ed2aeb3f 100644 --- a/spp_grm/views/grm_sla_rule_views.xml +++ b/spp_grm/views/grm_sla_rule_views.xml @@ -5,14 +5,10 @@ spp.grm.sla.rule
    -
    +
    +
    - +
    + +
    - +
    Select Category - +
    @@ -174,5 +188,4 @@
    - diff --git a/spp_grm/views/grm_sla_rule_views.xml b/spp_grm/views/grm_sla_rule_views.xml index ed2aeb3f..0027fa9e 100644 --- a/spp_grm/views/grm_sla_rule_views.xml +++ b/spp_grm/views/grm_sla_rule_views.xml @@ -5,10 +5,14 @@ spp.grm.sla.rule
    -
    -
    +
    - +