diff --git a/spp_disability_registry/README.rst b/spp_disability_registry/README.rst index 408cb994e..3030a353a 100644 --- a/spp_disability_registry/README.rst +++ b/spp_disability_registry/README.rst @@ -40,8 +40,7 @@ Key Features scheduling - Impairment type, cause, and severity classifications using DCI vocabularies -- Assistive device management with status workflow (needed, requested, - provided) +- Assistive device management with status (needed, requested, provided) - Proxy response tracking for children - CEL function integration for program eligibility targeting @@ -53,6 +52,22 @@ Key Features Changelog ========= +19.0.3.0.0 +~~~~~~~~~~ + +- feat(disability_registry): age-driven assessment type selection with + manual override (#1050) +- feat(disability_registry): CFM 2-4 and CFM 5-17 questionnaires (#1048, + #1049) +- feat(disability_registry): configurable assessment approval workflow + (#1060) +- feat(disability_registry): impairment classification on its own + multi-row tab (#1054) +- feat(disability_registry): improved assistive-device management + + proxy response by assessment type (#1052, #1053) +- fix(disability_registry): recognise approved assessments in the + registry (#1022) + 19.0.2.0.1 ~~~~~~~~~~ diff --git a/spp_disability_registry/__manifest__.py b/spp_disability_registry/__manifest__.py index ea1779c54..fabf5bf9c 100644 --- a/spp_disability_registry/__manifest__.py +++ b/spp_disability_registry/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP Disability Registry", - "version": "19.0.2.0.1", + "version": "19.0.3.0.0", "category": "OpenSPP", "summary": "Disability assessment and registry management for social protection", "author": "OpenSPP.org", @@ -31,6 +31,7 @@ "views/assessment_views.xml", "views/assistive_device_views.xml", "views/registrant_views.xml", + "views/res_config_settings_views.xml", "views/menus.xml", ], "demo": [ diff --git a/spp_disability_registry/data/vocabulary_device.xml b/spp_disability_registry/data/vocabulary_device.xml index c1e355af0..c240f8f7d 100644 --- a/spp_disability_registry/data/vocabulary_device.xml +++ b/spp_disability_registry/data/vocabulary_device.xml @@ -2,15 +2,12 @@ Assistive Device Type urn:dci:cd:dr:04 2024 - Types of assistive devices aligned with ISO 9999 classification + Types of assistive devices True disability diff --git a/spp_disability_registry/demo/demo.xml b/spp_disability_registry/demo/demo.xml index 0ba91aa49..25f018a8b 100644 --- a/spp_disability_registry/demo/demo.xml +++ b/spp_disability_registry/demo/demo.xml @@ -39,10 +39,20 @@ a_lot some - mine none a_lot - mip True parent diff --git a/spp_disability_registry/models/__init__.py b/spp_disability_registry/models/__init__.py index dd404f708..c92b55a5a 100644 --- a/spp_disability_registry/models/__init__.py +++ b/spp_disability_registry/models/__init__.py @@ -1,4 +1,6 @@ from . import assessment from . import assistive_device +from . import impairment from . import cel_disability_functions from . import registrant +from . import res_config_settings diff --git a/spp_disability_registry/models/assessment.py b/spp_disability_registry/models/assessment.py index ff117d8e9..bab1610e3 100644 --- a/spp_disability_registry/models/assessment.py +++ b/spp_disability_registry/models/assessment.py @@ -1,9 +1,10 @@ import logging from dateutil.relativedelta import relativedelta +from markupsafe import Markup from odoo import _, api, fields, models -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) @@ -18,6 +19,54 @@ # Severe difficulty levels that indicate disability per WG standard WG_SEVERE_DIFFICULTY_LEVELS = ("a_lot", "cannot") +# WG/UNICEF Child Functioning Module (CFM) response scales. +# Standard difficulty scale (shared by CFM 2-4 and CFM 5-17), including the +# survey non-response codes; "a_lot"/"cannot" reuse WG_SEVERE_DIFFICULTY_LEVELS. +CFM_DIFFICULTY_LEVELS = [ + ("none", "No difficulty"), + ("some", "Some difficulty"), + ("a_lot", "A lot of difficulty"), + ("cannot", "Cannot do at all"), + ("refused", "Refused"), + ("dont_know", "Don't know"), +] +# Same scale without "No difficulty" — used by the "without his/her equipment or +# assistance" walking questions, where "no difficulty" unaided wouldn't warrant +# the aid in the first place (BM reword). +CFM_DIFFICULTY_LEVELS_NO_NONE = [ + ("some", "Some difficulty"), + ("a_lot", "A lot of difficulty"), + ("cannot", "Cannot do at all"), + ("refused", "Refused"), + ("dont_know", "Don't know"), +] +# Behaviour-frequency scale used by CFM 2-4 CF16 (controlling behaviour). +# Disability threshold is "a lot more". +CFM_BEHAVIOR_LEVELS = [ + ("not_at_all", "Not at all"), + ("same_or_less", "The same or less"), + ("more", "More"), + ("a_lot_more", "A lot more"), + ("refused", "Refused"), + ("dont_know", "Don't know"), +] +# Frequency scale used by CFM 5-17 CF23 (anxiety) and CF24 (depression). +# Disability threshold is "daily". +CFM_FREQUENCY_LEVELS = [ + ("daily", "Daily"), + ("weekly", "Weekly"), + ("monthly", "Monthly"), + ("few_times_year", "A few times a year"), + ("never", "Never"), + ("refused", "Refused"), + ("dont_know", "Don't know"), +] +# Yes/No gate questions in the CFM instruments (glasses, hearing aid, walking aid). +CFM_YES_NO = [ + ("yes", "Yes"), + ("no", "No"), +] + # Review category to months mapping REVIEW_CATEGORY_MONTHS = { "mie": 12, # Medical Improvement Expected: 6-18 months (using 12) @@ -77,6 +126,30 @@ class SppDisabilityAssessment(models.Model): compute="_compute_age_at_assessment", store=True, ) + # Surfaced so the form can hide "Age at Assessment" when there is no date of birth. + registrant_birthdate = fields.Date( + related="registrant_id.birthdate", + string="Registrant Date of Birth", + ) + age_restriction_enforced = fields.Boolean( + string="Age Restriction Enforced", + compute="_compute_age_restriction_enforced", + help="Technical flag driving the form: True when the assessment type is " + "auto-determined by age (default). Toggled by the 'Allow manual assessment " + "type' setting in Disability Registry configuration.", + ) + has_approval_definition = fields.Boolean( + string="Approval Workflow Configured", + compute="_compute_has_approval_definition", + help="Technical flag: whether an approval workflow is configured for " + "disability assessments. The Submit button is hidden until one exists.", + ) + questionnaire_complete = fields.Boolean( + string="Questionnaire Complete", + compute="_compute_questionnaire_complete", + help="Technical flag: whether the WG/CFM questionnaire has enough answers to " + "compute a disability result for the assessment type. Required to submit.", + ) # === WG-SS Responses (6 domains) === wg_seeing = fields.Selection( @@ -107,30 +180,275 @@ class SppDisabilityAssessment(models.Model): wg_communicating = fields.Selection( WG_DIFFICULTY_LEVELS, string="Communicating", - help="Do you have difficulty communicating (understanding or being understood)?", + help="Do you have difficulty communicating (understanding or being understood) in their usual language?", + ) + + # === CFM 2-4 Responses (children aged 2-4, CF1-CF16) === + # Vision + cfm24_glasses = fields.Selection( # CF1 + CFM_YES_NO, + string="Does the child wear glasses?", + ) + cfm24_vision_aided = fields.Selection( # CF2 (asked when glasses are worn) + CFM_DIFFICULTY_LEVELS, + string="When wearing his/her glasses, does the child have difficulty seeing?", + ) + cfm24_vision = fields.Selection( # CF3 (asked when no glasses) + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty seeing?", + ) + # Hearing + cfm24_hearing_aid = fields.Selection( # CF4 + CFM_YES_NO, + string="Does the child use a hearing aid?", + ) + cfm24_hearing_aided = fields.Selection( # CF5 + CFM_DIFFICULTY_LEVELS, + string="When using his/her hearing aid, does the child have difficulty hearing sounds " + "like people's voices or music?", + ) + cfm24_hearing = fields.Selection( # CF6 + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty hearing sounds like people's voices or music?", + ) + # Mobility + cfm24_walk_equipment = fields.Selection( # CF7 + CFM_YES_NO, + string="Does the child use any equipment or receive assistance for walking?", + ) + cfm24_walk_unaided = fields.Selection( # CF8 (without equipment) + CFM_DIFFICULTY_LEVELS_NO_NONE, + string="Without his/her equipment or assistance, does the child have difficulty walking?", + ) + cfm24_walk_aided = fields.Selection( # CF9 (with equipment) - concluding when equipment used + CFM_DIFFICULTY_LEVELS, + string="With his/her equipment or assistance, does the child have difficulty walking?", + ) + cfm24_walk_compare = fields.Selection( # CF10 (no equipment) - concluding when no equipment + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty walking?", + ) + # Dexterity + cfm24_dexterity = fields.Selection( # CF11 + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty picking " + "up small objects with his/her hand?", + ) + # Communication + cfm24_understand_you = fields.Selection( # CF12 + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty understanding you?", + ) + cfm24_understood = fields.Selection( # CF13 + CFM_DIFFICULTY_LEVELS, + string="When the child speaks, do you have difficulty understanding him/her?", + ) + # Learning + cfm24_learning = fields.Selection( # CF14 + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty learning things?", + ) + # Playing + cfm24_playing = fields.Selection( # CF15 + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty playing?", + ) + # Controlling behaviour + cfm24_behavior = fields.Selection( # CF16 (behaviour scale, threshold "a lot more") + CFM_BEHAVIOR_LEVELS, + string="Compared with children of the same age, how much does the child kick, bite or hit " + "other children or adults?", ) - # === Impairment Classification (DCI DO.DR.02) === + # === CFM 5-17 Responses (children aged 5-17, CF1-CF24) === + # Vision + cfm517_glasses = fields.Selection( # CF1 + CFM_YES_NO, + string="Does the child wear glasses?", + ) + cfm517_vision_aided = fields.Selection( # CF2 + CFM_DIFFICULTY_LEVELS, + string="When wearing his/her glasses, does the child have difficulty seeing?", + ) + cfm517_vision = fields.Selection( # CF3 + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty seeing?", + ) + # Hearing + cfm517_hearing_aid = fields.Selection( # CF4 + CFM_YES_NO, + string="Does the child use a hearing aid?", + ) + cfm517_hearing_aided = fields.Selection( # CF5 + CFM_DIFFICULTY_LEVELS, + string="When using his/her hearing aid, does the child have difficulty hearing sounds " + "like people's voices or music?", + ) + cfm517_hearing = fields.Selection( # CF6 + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty hearing sounds like people's voices or music?", + ) + # Mobility (100/500 yards-meters, with and without equipment; concluding = the + # "with aid" answer when equipment is used, otherwise the "compared" answer) + cfm517_walk_equipment = fields.Selection( # CF7 + CFM_YES_NO, + string="Does the child use any equipment or receive assistance for walking?", + ) + cfm517_walk_unaided_100 = fields.Selection( # CF8 (without equipment, 100) + CFM_DIFFICULTY_LEVELS_NO_NONE, + string="Without his/her equipment or assistance, does the child have difficulty walking " + "100 yards/meters on level ground?", + ) + cfm517_walk_unaided_500 = fields.Selection( # CF9 (without equipment, 500) + CFM_DIFFICULTY_LEVELS_NO_NONE, + string="Without his/her equipment or assistance, does the child have difficulty walking " + "500 yards/meters on level ground?", + ) + cfm517_walk_aided_100 = fields.Selection( # CF10 (with equipment, 100) - concluding + CFM_DIFFICULTY_LEVELS, + string="With his/her equipment or assistance, does the child have difficulty walking " + "100 yards/meters on level ground?", + ) + cfm517_walk_aided_500 = fields.Selection( # CF11 (with equipment, 500) - concluding + CFM_DIFFICULTY_LEVELS, + string="With his/her equipment or assistance, does the child have difficulty walking " + "500 yards/meters on level ground?", + ) + cfm517_walk_compare_100 = fields.Selection( # CF12 (no equipment, compared, 100) - concluding + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty walking " + "100 yards/meters on level ground?", + ) + cfm517_walk_compare_500 = fields.Selection( # CF13 (no equipment, compared, 500) - concluding + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty walking " + "500 yards/meters on level ground?", + ) + # Self-care + cfm517_selfcare = fields.Selection( # CF14 + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty with self-care such as feeding or dressing him/herself?", + ) + # Communication + cfm517_comm_inside = fields.Selection( # CF15 + CFM_DIFFICULTY_LEVELS, + string="When the child speaks, does he/she have difficulty being understood by people inside this household?", + ) + cfm517_comm_outside = fields.Selection( # CF16 + CFM_DIFFICULTY_LEVELS, + string="When the child speaks, does he/she have difficulty being understood by people outside this household?", + ) + # Learning + cfm517_learning = fields.Selection( # CF17 + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty learning things?", + ) + # Remembering + cfm517_remembering = fields.Selection( # CF18 + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty remembering things?", + ) + # Concentrating + cfm517_concentrating = fields.Selection( # CF19 + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty concentrating on an activity that he/she enjoys doing?", + ) + # Accepting change + cfm517_accepting_change = fields.Selection( # CF20 + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty accepting changes in his/her routine?", + ) + # Controlling behaviour (standard difficulty scale in CFM 5-17) + cfm517_behavior = fields.Selection( # CF21 + CFM_DIFFICULTY_LEVELS, + string="Compared with children of the same age, does the child have difficulty controlling his/her behaviour?", + ) + # Making friends + cfm517_friends = fields.Selection( # CF22 + CFM_DIFFICULTY_LEVELS, + string="Does the child have difficulty making friends?", + ) + # Anxiety / Depression (frequency scale, threshold "daily") + cfm517_anxiety = fields.Selection( # CF23 + CFM_FREQUENCY_LEVELS, + string="How often does the child seem very anxious, nervous or worried?", + ) + cfm517_depression = fields.Selection( # CF24 + CFM_FREQUENCY_LEVELS, + string="How often does the child seem very sad or depressed?", + ) + + # === Impairment Classification (DCI vocabularies) === + # Gate question (OP#1068): "No" marks the tab complete with no lines; + # "Yes" reveals the list and requires at least one row before submission. + has_impairments_to_record = fields.Selection( + [("yes", "Yes"), ("no", "No")], + string="Does the registrant have any impairments or functional limitations to record?", + help="If Yes, classify each impairment below. If No, this tab is considered complete.", + ) + # One row per impairment type, each with its own cause and severity. + impairment_line_ids = fields.One2many( + "spp.disability.impairment", + "assessment_id", + string="Impairment Classification", + ) + # Assessment-level summaries derived from the lines, kept for the registrant + # propagation, list/badge decorations, search filters and CEL functions. impairment_type_ids = fields.Many2many( "spp.vocabulary.code", "spp_disability_assessment_impairment_type_rel", "assessment_id", "code_id", string="Impairment Types", - domain="[('vocabulary_id.namespace_uri', '=', 'urn:dci:cd:dr:01')]", - ) - impairment_cause_id = fields.Many2one( - "spp.vocabulary.code", - string="Impairment Cause", - domain="[('vocabulary_id.namespace_uri', '=', 'urn:dci:cd:dr:03')]", + compute="_compute_impairment_summary", + store=True, ) severity_level_id = fields.Many2one( "spp.vocabulary.code", string="Severity Level", - domain="[('vocabulary_id.namespace_uri', '=', 'urn:dci:cd:dr:02')]", + compute="_compute_impairment_summary", + store=True, tracking=True, + help="Overall severity: the most severe level across the impairment classification lines.", + ) + # Per-line "Type — Severity" rendering for the registrant overview table (OP#1068): + # each impairment type shows its own severity on a separate line. + impairment_severity_display = fields.Html( + string="Impairments", + compute="_compute_impairment_severity_display", + help="Each classified impairment type with its severity, one per line.", ) + @api.depends( + "impairment_line_ids.impairment_type_id", + "impairment_line_ids.severity_level_id", + ) + def _compute_impairment_severity_display(self): + for rec in self: + rows = [] + for line in rec.impairment_line_ids: + label = line.impairment_type_id.display or line.impairment_type_id.code or "" + severity = line.severity_level_id.display or line.severity_level_id.code or "" + text = f"{label} — {severity}" if severity else label + if text: + rows.append(Markup("
%s
") % text) + rec.impairment_severity_display = Markup("").join(rows) if rows else False + + @api.depends( + "impairment_line_ids.impairment_type_id", + "impairment_line_ids.severity_level_id", + "impairment_line_ids.severity_sequence", + ) + def _compute_impairment_summary(self): + for rec in self: + rec.impairment_type_ids = rec.impairment_line_ids.impairment_type_id + severe_lines = rec.impairment_line_ids.filtered("severity_level_id") + if severe_lines: + top = max(severe_lines, key=lambda line: line.severity_sequence or 0) + rec.severity_level_id = top.severity_level_id + else: + rec.severity_level_id = False + # === Review Schedule (categorical) === review_category = fields.Selection( [ @@ -153,16 +471,117 @@ class SppDisabilityAssessment(models.Model): string="Support Needs", help="Free-text description of support needs. Countries can extend with structured fields.", ) + # Assistive-device requests (OP#1068). On approval each becomes a real + # spp.assistive.device (status "needed") on the registrant. + device_request_ids = fields.One2many( + "spp.disability.assessment.device.request", + "assessment_id", + string="Assistive Device Requests", + ) + + # === Assessment-tab configuration mirrors (OP#1068) === + # Read from ir.config_parameter so the form (tab visibility) and the submit + # gate can react to the Disability Registry settings. + cfg_display_impairment = fields.Boolean(compute="_compute_tab_config") + cfg_display_wg = fields.Boolean(compute="_compute_tab_config") + cfg_display_support = fields.Boolean(compute="_compute_tab_config") + cfg_require_impairment = fields.Boolean(compute="_compute_tab_config") + cfg_require_wg = fields.Boolean(compute="_compute_tab_config") + cfg_require_support = fields.Boolean(compute="_compute_tab_config") + cfg_support_show_devices = fields.Boolean(compute="_compute_tab_config") + cfg_display_review = fields.Boolean(compute="_compute_tab_config") + cfg_require_review = fields.Boolean(compute="_compute_tab_config") + cfg_require_proxy_details = fields.Boolean(compute="_compute_tab_config") + + @api.depends_context("uid") + def _compute_tab_config(self): + # nosemgrep: odoo-sudo-without-context — read configuration parameters + icp = self.env["ir.config_parameter"].sudo() + + def flag(key): + return icp.get_param("spp_disability_registry." + key, "True") == "True" + + values = { + "cfg_display_impairment": flag("display_impairment"), + "cfg_display_wg": flag("display_wg"), + "cfg_display_support": flag("display_support"), + "cfg_require_impairment": flag("require_impairment"), + "cfg_require_wg": flag("require_wg"), + "cfg_require_support": flag("require_support"), + "cfg_support_show_devices": flag("support_show_devices"), + "cfg_display_review": flag("display_review"), + "cfg_require_review": flag("require_review"), + "cfg_require_proxy_details": flag("require_proxy_details"), + } + for rec in self: + rec.update(values) + + # === Submission completeness (OP#1068) === + impairment_tab_complete = fields.Boolean( + compute="_compute_assessment_complete", + help="Impairment tab complete: 'No' to the gate question, or 'Yes' with at least one row.", + ) + assessment_complete = fields.Boolean( + compute="_compute_assessment_complete", + help="True when every displayed and required assessment tab is complete.", + ) + + @api.depends( + "has_impairments_to_record", + "impairment_line_ids", + "questionnaire_complete", + "review_category", + "is_proxy_response", + "proxy_respondent_id", + "proxy_relationship", + "cfg_display_impairment", + "cfg_display_wg", + "cfg_display_review", + "cfg_require_impairment", + "cfg_require_wg", + "cfg_require_review", + "cfg_require_proxy_details", + ) + def _compute_assessment_complete(self): + for rec in self: + # Impairment tab: "No" → complete; "Yes" → at least one classified row. + rec.impairment_tab_complete = rec.has_impairments_to_record == "no" or ( + rec.has_impairments_to_record == "yes" and bool(rec.impairment_line_ids) + ) + checks = [] + if rec.cfg_display_impairment and rec.cfg_require_impairment: + checks.append(rec.impairment_tab_complete) + if rec.cfg_display_wg and rec.cfg_require_wg: + checks.append(rec.questionnaire_complete) + # Review schedule: when shown and required, a review category must be set (OP#1068). + if rec.cfg_display_review and rec.cfg_require_review: + checks.append(bool(rec.review_category)) + # Proxy details: when a proxy responded and details are required, the + # respondent and their relationship must be recorded (OP#1053). + if rec.cfg_require_proxy_details and rec.is_proxy_response: + checks.append(bool(rec.proxy_respondent_id) and bool(rec.proxy_relationship)) + # Support Needs has no content gate (OP#1068), so it never blocks. + rec.assessment_complete = all(checks) # === Proxy Response Tracking === is_proxy_response = fields.Boolean( string="Proxy Response", - default=False, - help="True if responses provided by proxy (always true for children)", + compute="_compute_is_proxy_response", + store=True, + readonly=False, + help="True if responses were provided by a proxy. Forced on for CFM 2-4 and " + "(unless self-report is enabled) CFM 5-17; optional for WG-SS.", + ) + proxy_locked = fields.Boolean( + string="Proxy Locked", + compute="_compute_proxy_locked", + help="Technical flag: True when the proxy response checkbox is forced and " + "must not be edited (driven by assessment type, age and configuration).", ) proxy_respondent_id = fields.Many2one( "res.partner", string="Proxy Respondent", + domain="[('id', '!=', registrant_id)]", help="Person who provided responses on behalf of the registrant", ) proxy_relationship = fields.Selection( @@ -175,6 +594,19 @@ class SppDisabilityAssessment(models.Model): ], string="Proxy Relationship", ) + proxy_reason = fields.Selection( + [ + ( + "functional_limitation", + "Unable to respond due to functional limitation (hearing, communication, cognitive, etc.)", + ), + ("caregiver", "Individual not present - caregiver responding"), + ("household_head", "Individual not present - household head responding"), + ("other", "Other"), + ], + string="Reason for Proxy", + help="Why a proxy responded instead of the individual (WG-SS).", + ) # === Computed Disability Indicator === has_disability = fields.Boolean( @@ -227,18 +659,300 @@ def _compute_age_at_assessment(self): delta = relativedelta(rec.assessment_date, rec.registrant_id.birthdate) rec.age_at_assessment = delta.years + @api.model + def _disability_config(self): + """Read the Disability Registry configuration parameters with defaults.""" + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + icp = self.env["ir.config_parameter"].sudo() + get = icp.get_param + return { + "disregard_age": get("spp_disability_registry.disregard_age", "False") == "True", + "allow_self_report_cfm": get("spp_disability_registry.allow_self_report_cfm_5_17", "False") == "True", + "self_report_min_age": int(get("spp_disability_registry.self_report_min_age", "0") or 0), + "allow_proxy_wg_ss": get("spp_disability_registry.allow_proxy_wg_ss", "True") == "True", + } + + @api.model + def _disability_disregard_age(self): + """Read the 'disregard age for assessment type' configuration flag.""" + return self._disability_config()["disregard_age"] + + def _proxy_is_locked(self): + """Whether the proxy response flag is forced (non-editable) for this record. + + - CFM 2-4: always locked (proxy mandatory). + - CFM 5-17: locked unless self-report is enabled and the child meets the + optional minimum self-report age. + - WG-SS: locked only when proxy reporting is disabled by configuration. + """ + self.ensure_one() + cfg = self._disability_config() + if self.assessment_type == "cfm_2_4": + return True + if self.assessment_type == "cfm_5_17": + min_age = cfg["self_report_min_age"] + old_enough = (not min_age) or (self.age_at_assessment >= min_age) + return not (cfg["allow_self_report_cfm"] and old_enough) + # WG-SS (adult) and any fallback. + return not cfg["allow_proxy_wg_ss"] + + @api.depends("assessment_type") + def _compute_is_proxy_response(self): + """Seed the proxy flag from the assessment type (children = proxy by default, + adults = self-report). This default also matches the forced value in every + locked case, so locked records always carry the correct flag. Editable for + unlocked types — user toggles persist until the assessment type changes. + """ + for rec in self: + rec.is_proxy_response = rec.assessment_type in ("cfm_2_4", "cfm_5_17") + + @api.depends("assessment_type", "age_at_assessment") + def _compute_proxy_locked(self): + for rec in self: + rec.proxy_locked = rec._proxy_is_locked() + + def _assessment_type_for_age(self): + """Return the WG/CFM assessment type implied by the age at assessment.""" + self.ensure_one() + age = self.age_at_assessment + if age >= 18: + return "wg_ss" + elif age >= 5: + return "cfm_5_17" + # Under 5 (including under 2) defaults to the CFM 2-4 instrument. + return "cfm_2_4" + + def _compute_age_restriction_enforced(self): + enforced = not self._disability_disregard_age() + for rec in self: + rec.age_restriction_enforced = enforced + + def _compute_has_approval_definition(self): + for rec in self: + rec.has_approval_definition = bool(rec._resolve_approval_definition()) + + def _get_approval_definition(self): + """Return the approval workflow configured for disability assessments + (Settings > Disability Registry), or an empty recordset if none is set. + """ + definition = self.env["spp.approval.definition"] + # nosemgrep: odoo-sudo-without-context — read approval definition id from config + def_id = self.env["ir.config_parameter"].sudo().get_param("spp_disability_registry.approval_definition_id") + return definition.browse(int(def_id)).exists() if def_id else definition + + def _resolve_approval_definition(self): + """Use only the configured definition (no model-wide fallback), so the + Submit button stays hidden until an admin selects a workflow. + """ + self.ensure_one() + return self._get_approval_definition() + + def _questionnaire_answers(self): + """The answers that feed the disability result for this assessment type + (the concluding answer per branched domain). The questionnaire is + 'complete enough to compute a result' when all of these are answered. + """ + self.ensure_one() + if self.assessment_type == "cfm_2_4": + vision = self.cfm24_vision_aided if self.cfm24_glasses == "yes" else self.cfm24_vision + hearing = self.cfm24_hearing_aided if self.cfm24_hearing_aid == "yes" else self.cfm24_hearing + mobility = self.cfm24_walk_aided if self.cfm24_walk_equipment == "yes" else self.cfm24_walk_compare + return [ + vision, + hearing, + mobility, + self.cfm24_dexterity, + self.cfm24_understand_you, + self.cfm24_understood, + self.cfm24_learning, + self.cfm24_playing, + self.cfm24_behavior, + ] + if self.assessment_type == "cfm_5_17": + vision = self.cfm517_vision_aided if self.cfm517_glasses == "yes" else self.cfm517_vision + hearing = self.cfm517_hearing_aided if self.cfm517_hearing_aid == "yes" else self.cfm517_hearing + if self.cfm517_walk_equipment == "yes": + mobility = [self.cfm517_walk_aided_100, self.cfm517_walk_aided_500] + else: + mobility = [self.cfm517_walk_compare_100, self.cfm517_walk_compare_500] + return [ + vision, + hearing, + *mobility, + self.cfm517_selfcare, + self.cfm517_comm_inside, + self.cfm517_comm_outside, + self.cfm517_learning, + self.cfm517_remembering, + self.cfm517_concentrating, + self.cfm517_accepting_change, + self.cfm517_behavior, + self.cfm517_friends, + self.cfm517_anxiety, + self.cfm517_depression, + ] + # WG-SS (adult) and any fallback. + return [ + self.wg_seeing, + self.wg_hearing, + self.wg_walking, + self.wg_remembering, + self.wg_selfcare, + self.wg_communicating, + ] + + @api.depends( + "assessment_type", + "wg_seeing", + "wg_hearing", + "wg_walking", + "wg_remembering", + "wg_selfcare", + "wg_communicating", + "cfm24_glasses", + "cfm24_vision_aided", + "cfm24_vision", + "cfm24_hearing_aid", + "cfm24_hearing_aided", + "cfm24_hearing", + "cfm24_walk_equipment", + "cfm24_walk_aided", + "cfm24_walk_compare", + "cfm24_dexterity", + "cfm24_understand_you", + "cfm24_understood", + "cfm24_learning", + "cfm24_playing", + "cfm24_behavior", + "cfm517_glasses", + "cfm517_vision_aided", + "cfm517_vision", + "cfm517_hearing_aid", + "cfm517_hearing_aided", + "cfm517_hearing", + "cfm517_walk_equipment", + "cfm517_walk_aided_100", + "cfm517_walk_aided_500", + "cfm517_walk_compare_100", + "cfm517_walk_compare_500", + "cfm517_selfcare", + "cfm517_comm_inside", + "cfm517_comm_outside", + "cfm517_learning", + "cfm517_remembering", + "cfm517_concentrating", + "cfm517_accepting_change", + "cfm517_behavior", + "cfm517_friends", + "cfm517_anxiety", + "cfm517_depression", + ) + def _compute_questionnaire_complete(self): + for rec in self: + answers = rec._questionnaire_answers() + rec.questionnaire_complete = bool(answers) and all(answers) + + def _check_can_submit(self): + """Require every displayed + required tab to be complete before submitting.""" + super()._check_can_submit() + if not self.assessment_complete: + raise UserError( + _( + "Complete all required parts of the assessment before submitting for " + "approval: the impairment question, the WG/CFM questionnaire, the review " + "schedule, and - when a proxy responded - the proxy respondent and " + "relationship (whichever are required in the Disability Registry settings)." + ) + ) + + def _sync_registrant_disability_status(self): + """Notify the ORM that approval_state changed so the registrant's + disability status (current_disability_assessment_id, has_disability, ...) + recomputes. The approval mixin writes approval_state via raw SQL for + optimistic locking, which bypasses ORM dependency tracking, so the + registrant would otherwise show a stale status (#1022). + """ + self.modified(["approval_state"]) + + def _on_approve(self): + res = super()._on_approve() + self._sync_registrant_disability_status() + self._materialize_device_requests() + return res + + def _materialize_device_requests(self): + """On approval, turn each Support-Needs device request into a real + spp.assistive.device (status 'needed') on the registrant so it shows on + the Overview / registrant (OP#1068). Skips a device type that already + has a pending 'needed' record to avoid duplicates on re-approval. + """ + Device = self.env["spp.assistive.device"] + for rec in self: + for req in rec.device_request_ids: + already = Device.search_count( + [ + ("registrant_id", "=", rec.registrant_id.id), + ("device_type_id", "=", req.device_type_id.id), + ("status", "=", "needed"), + ] + ) + if not already: + Device.create( + { + "registrant_id": rec.registrant_id.id, + "device_type_id": req.device_type_id.id, + "status": "needed", + } + ) + + def _on_reject(self, reason): + res = super()._on_reject(reason) + self._sync_registrant_disability_status() + return res + + def _on_reset_to_draft(self): + res = super()._on_reset_to_draft() + self._sync_registrant_disability_status() + return res + @api.depends("age_at_assessment") def _compute_assessment_type(self): + disregard_age = self._disability_disregard_age() for rec in self: - if rec.age_at_assessment >= 18: - rec.assessment_type = "wg_ss" - elif rec.age_at_assessment >= 5: - rec.assessment_type = "cfm_5_17" - elif rec.age_at_assessment >= 2: - rec.assessment_type = "cfm_2_4" - else: - # Under 2 - default to CFM 2-4 but flag for manual review - rec.assessment_type = "cfm_2_4" + if disregard_age: + # Manual mode: preserve the user's selection; only seed a + # sensible default when nothing has been chosen yet. + if not rec.assessment_type: + rec.assessment_type = rec._assessment_type_for_age() + continue + rec.assessment_type = rec._assessment_type_for_age() + + @api.constrains("registrant_id", "assessment_date") + def _check_birthdate_required_for_age(self): + """When the assessment type is age-driven, require a date of birth and an + age of at least 2 (the youngest CFM instrument covers ages 2-4).""" + if self._disability_disregard_age(): + return + for rec in self: + if not rec.registrant_id: + continue + if not rec.registrant_id.birthdate: + raise ValidationError( + _( + "A date of birth is required for %s to determine the assessment type by age. " + "Set the registrant's date of birth, or enable 'Allow manual assessment type' " + "in the Disability Registry settings.", + rec.registrant_id.display_name, + ) + ) + if rec.age_at_assessment < 2: + raise ValidationError( + _( + "Disability assessments are only available for individuals aged 2 years or " + "older. %s is younger than 2 at the assessment date.", + rec.registrant_id.display_name, + ) + ) @api.depends("review_category", "assessment_date") def _compute_next_review_date(self): @@ -249,36 +963,144 @@ def _compute_next_review_date(self): months = REVIEW_CATEGORY_MONTHS.get(rec.review_category, REVIEW_CATEGORY_MONTHS["mip"]) rec.next_review_date = rec.assessment_date + relativedelta(months=months) + def _wg_ss_domain_count(self): + """Number of WG-SS domains with 'a lot of difficulty' or 'cannot do at all'.""" + self.ensure_one() + responses = [ + self.wg_seeing, + self.wg_hearing, + self.wg_walking, + self.wg_remembering, + self.wg_selfcare, + self.wg_communicating, + ] + return sum(1 for r in responses if r in WG_SEVERE_DIFFICULTY_LEVELS) + + def _cfm_2_4_domain_count(self): + """Number of CFM 2-4 domains meeting the disability threshold. + + Standard domains use 'a lot of difficulty'/'cannot do at all' on the + concluding answer; controlling behaviour (CF16) uses 'a lot more'. + """ + self.ensure_one() + # Concluding answer per branched domain (gate answered "yes" = aided path). + vision = self.cfm24_vision_aided if self.cfm24_glasses == "yes" else self.cfm24_vision + hearing = self.cfm24_hearing_aided if self.cfm24_hearing_aid == "yes" else self.cfm24_hearing + mobility = self.cfm24_walk_aided if self.cfm24_walk_equipment == "yes" else self.cfm24_walk_compare + standard = [ + vision, + hearing, + mobility, + self.cfm24_dexterity, + self.cfm24_understand_you, + self.cfm24_understood, + self.cfm24_learning, + self.cfm24_playing, + ] + count = sum(1 for r in standard if r in WG_SEVERE_DIFFICULTY_LEVELS) + if self.cfm24_behavior == "a_lot_more": + count += 1 + return count + + def _cfm_5_17_domain_count(self): + """Number of CFM 5-17 domains meeting the disability threshold. + + Standard domains use 'a lot of difficulty'/'cannot do at all' on the + concluding answer; mobility uses the 'with aid' answer (either distance) + when equipment is used, otherwise the 'compared with peers' answer; + anxiety (CF23) and depression (CF24) use a 'daily' frequency threshold. + """ + self.ensure_one() + severe = WG_SEVERE_DIFFICULTY_LEVELS + vision = self.cfm517_vision_aided if self.cfm517_glasses == "yes" else self.cfm517_vision + hearing = self.cfm517_hearing_aided if self.cfm517_hearing_aid == "yes" else self.cfm517_hearing + if self.cfm517_walk_equipment == "yes": + mobility_severe = self.cfm517_walk_aided_100 in severe or self.cfm517_walk_aided_500 in severe + else: + mobility_severe = self.cfm517_walk_compare_100 in severe or self.cfm517_walk_compare_500 in severe + standard = [ + vision, + hearing, + self.cfm517_selfcare, + self.cfm517_comm_inside, + self.cfm517_comm_outside, + self.cfm517_learning, + self.cfm517_remembering, + self.cfm517_concentrating, + self.cfm517_accepting_change, + self.cfm517_behavior, + self.cfm517_friends, + ] + count = sum(1 for r in standard if r in severe) + if mobility_severe: + count += 1 + if self.cfm517_anxiety == "daily": + count += 1 + if self.cfm517_depression == "daily": + count += 1 + return count + @api.depends( + "assessment_type", "wg_seeing", "wg_hearing", "wg_walking", "wg_remembering", "wg_selfcare", "wg_communicating", + "cfm24_glasses", + "cfm24_vision_aided", + "cfm24_vision", + "cfm24_hearing_aid", + "cfm24_hearing_aided", + "cfm24_hearing", + "cfm24_walk_equipment", + "cfm24_walk_aided", + "cfm24_walk_compare", + "cfm24_dexterity", + "cfm24_understand_you", + "cfm24_understood", + "cfm24_learning", + "cfm24_playing", + "cfm24_behavior", + "cfm517_glasses", + "cfm517_vision_aided", + "cfm517_vision", + "cfm517_hearing_aid", + "cfm517_hearing_aided", + "cfm517_hearing", + "cfm517_walk_equipment", + "cfm517_walk_aided_100", + "cfm517_walk_aided_500", + "cfm517_walk_compare_100", + "cfm517_walk_compare_500", + "cfm517_selfcare", + "cfm517_comm_inside", + "cfm517_comm_outside", + "cfm517_learning", + "cfm517_remembering", + "cfm517_concentrating", + "cfm517_accepting_change", + "cfm517_behavior", + "cfm517_friends", + "cfm517_anxiety", + "cfm517_depression", ) def _compute_disability_indicator(self): - """WG standard: any domain with 'a_lot' or 'cannot' indicates disability.""" + """Count domains meeting the disability threshold for the active instrument. + + Any domain at/above threshold marks the person as having a disability. + """ for rec in self: - responses = [ - rec.wg_seeing, - rec.wg_hearing, - rec.wg_walking, - rec.wg_remembering, - rec.wg_selfcare, - rec.wg_communicating, - ] - # Count domains with severe difficulty - domain_count = sum(1 for r in responses if r in WG_SEVERE_DIFFICULTY_LEVELS) + if rec.assessment_type == "cfm_2_4": + domain_count = rec._cfm_2_4_domain_count() + elif rec.assessment_type == "cfm_5_17": + domain_count = rec._cfm_5_17_domain_count() + else: + domain_count = rec._wg_ss_domain_count() rec.wg_domain_count = domain_count rec.has_disability = domain_count > 0 - @api.onchange("assessment_type") - def _onchange_assessment_type(self): - """Set proxy response flag automatically for child assessments.""" - if self.assessment_type in ("cfm_2_4", "cfm_5_17"): - self.is_proxy_response = True - def action_view_registrant(self): """Open the registrant form.""" self.ensure_one() diff --git a/spp_disability_registry/models/assistive_device.py b/spp_disability_registry/models/assistive_device.py index 27cf1b017..691e1bf67 100644 --- a/spp_disability_registry/models/assistive_device.py +++ b/spp_disability_registry/models/assistive_device.py @@ -77,3 +77,38 @@ def action_view_registrant(self): "view_mode": "form", "target": "current", } + + +class SppDisabilityAssessmentDeviceRequest(models.Model): + """Assistive-device request captured on an assessment's Support Needs tab. + + Status is always "Needed" (read-only). On assessment approval each row + creates a real ``spp.assistive.device`` (status "needed") on the registrant, + so it surfaces on the Overview / registrant (OP#1068). + """ + + _name = "spp.disability.assessment.device.request" + _description = "Assessment Assistive Device Request" + + assessment_id = fields.Many2one( + "spp.disability.assessment", + string="Assessment", + required=True, + index=True, + ondelete="cascade", + ) + device_type_id = fields.Many2one( + "spp.vocabulary.code", + string="Device Type", + required=True, + domain="[('vocabulary_id.namespace_uri', '=', 'urn:dci:cd:dr:04')]", + ) + # Always "Needed" — these are requests; the registrant's device record is + # what later moves to requested/provided. + status = fields.Selection( + [("needed", "Needed")], + string="Status", + default="needed", + readonly=True, + ) + notes = fields.Text(string="Notes") diff --git a/spp_disability_registry/models/impairment.py b/spp_disability_registry/models/impairment.py new file mode 100644 index 000000000..f60a5d633 --- /dev/null +++ b/spp_disability_registry/models/impairment.py @@ -0,0 +1,38 @@ +from odoo import fields, models + + +class SppDisabilityImpairment(models.Model): + _name = "spp.disability.impairment" + _description = "Disability Impairment Classification" + _rec_name = "impairment_type_id" + _order = "severity_sequence desc, id" + + assessment_id = fields.Many2one( + "spp.disability.assessment", + string="Assessment", + required=True, + ondelete="cascade", + index=True, + ) + impairment_type_id = fields.Many2one( + "spp.vocabulary.code", + string="Impairment Type", + required=True, + domain="[('vocabulary_id.namespace_uri', '=', 'urn:dci:cd:dr:01')]", + ) + impairment_cause_id = fields.Many2one( + "spp.vocabulary.code", + string="Impairment Cause", + domain="[('vocabulary_id.namespace_uri', '=', 'urn:dci:cd:dr:03')]", + ) + severity_level_id = fields.Many2one( + "spp.vocabulary.code", + string="Severity Level", + domain="[('vocabulary_id.namespace_uri', '=', 'urn:dci:cd:dr:02')]", + ) + # Stored so the assessment can roll up the "most severe" line and the list + # can order by severity. + severity_sequence = fields.Integer( + related="severity_level_id.sequence", + store=True, + ) diff --git a/spp_disability_registry/models/registrant.py b/spp_disability_registry/models/registrant.py index 7686b28c9..5ceb36635 100644 --- a/spp_disability_registry/models/registrant.py +++ b/spp_disability_registry/models/registrant.py @@ -1,6 +1,8 @@ import logging -from odoo import api, fields, models +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models _logger = logging.getLogger(__name__) @@ -67,6 +69,38 @@ class ResPartner(models.Model): store=True, ) + # === Assessment Eligibility === + can_create_disability_assessment = fields.Boolean( + string="Can Create Disability Assessment", + compute="_compute_can_create_disability_assessment", + ) + disability_no_create_reason = fields.Char( + compute="_compute_can_create_disability_assessment", + ) + + @api.depends("birthdate") + def _compute_can_create_disability_assessment(self): + # When the assessment type is age-driven, an assessment needs a date of birth + # and an age of at least 2 (the youngest CFM instrument covers ages 2-4). + # nosemgrep: odoo-sudo-without-context — read configuration parameter + icp = self.env["ir.config_parameter"].sudo() + disregard_age = icp.get_param("spp_disability_registry.disregard_age", "False") == "True" + today = fields.Date.today() + for rec in self: + reason = False + if disregard_age: + can_create = True + elif not rec.birthdate: + can_create = False + reason = _("Add the registrant's date of birth to create a disability assessment.") + elif relativedelta(today, rec.birthdate).years < 2: + can_create = False + reason = _("Disability assessments are not available for children under 2 years old.") + else: + can_create = True + rec.can_create_disability_assessment = can_create + rec.disability_no_create_reason = reason + def _compute_disability_assessment_count(self): for rec in self: rec.disability_assessment_count = len(rec.disability_assessment_ids) diff --git a/spp_disability_registry/models/res_config_settings.py b/spp_disability_registry/models/res_config_settings.py new file mode 100644 index 000000000..da35f439f --- /dev/null +++ b/spp_disability_registry/models/res_config_settings.py @@ -0,0 +1,157 @@ +from odoo import _, fields, models +from odoo.exceptions import ValidationError + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + # === Assessment Type === + disability_disregard_age = fields.Boolean( + string="Allow manual assessment type", + config_parameter="spp_disability_registry.disregard_age", + help="When enabled, the assessment type can be selected manually and the " + "registrant's date of birth is not required. When disabled, the assessment " + "type is determined automatically from the registrant's age and a date of " + "birth is required.", + ) + + # === Proxy Response === + disability_allow_self_report_cfm = fields.Boolean( + string="Allow self-report on CFM 5-17", + config_parameter="spp_disability_registry.allow_self_report_cfm_5_17", + help="When enabled, the proxy response flag can be unticked on CFM 5-17 " + "assessments (subject to the minimum self-report age below).", + ) + # Char (not Integer) + managed manually so it is genuinely blank by default + # rather than showing "0" or a stale value (BM reword). Stored as a plain + # number string; readers parse it with int(value or 0). + disability_self_report_min_age = fields.Char( + string="Minimum age for self-report (CFM 5-17)", + help="Minimum age at assessment at which self-report is allowed on CFM 5-17. " + "Required when self-report is enabled; must be between 5 and 17. Blank by default.", + ) + # NB: managed manually below (not via config_parameter). A config_parameter + # Boolean defaulting to True cannot persist a False value: set_param(key, False) + # DELETES the parameter, and get_values then falls back to the field default + # (True), so the box re-ticks itself on save. Storing an explicit "True"/"False" + # string avoids the delete. + disability_allow_proxy_wg_ss = fields.Boolean( + string="Allow proxy report on WG-SS", + default=True, + help="When enabled, the proxy response flag can be ticked on adult WG-SS " + "assessments. When disabled, WG-SS assessments are self-report only.", + ) + # When enabled, recording who responded is required before an assessment that + # uses a proxy response can be submitted for approval (OP#1053). + disability_require_proxy_details = fields.Boolean( + string="Require proxy details on proxy responses", + default=True, + help="When enabled, an assessment answered by a proxy cannot be submitted " + "until the proxy respondent and relationship are recorded.", + ) + + # === Approval === + # The approval workflow applied to disability assessments. Create the workflow in + # Approvals > Approval Definitions (Model = Disability Assessment), then select it + # here. The assessment reads it via _get_approval_definition(). + disability_approval_definition_id = fields.Many2one( + "spp.approval.definition", + string="Assessment approval workflow", + domain="[('model', '=', 'spp.disability.assessment')]", + config_parameter="spp_disability_registry.approval_definition_id", + help="Approval workflow applied to disability assessments. Create it under " + "Approvals > Approval Definitions (with Model = Disability Assessment), then " + "select it here. Until one is selected, assessments cannot be submitted for " + "approval.", + ) + + # === Assessment Tabs (OP#1068) === + # Which of the three assessment tabs are displayed, and which are required + # before an assessment can be submitted for approval. All default to True. + disability_display_impairment = fields.Boolean( + string="Show Impairment Classification tab", + default=True, + ) + disability_display_wg = fields.Boolean( + string="Show WG/CFM Assessment tab", + default=True, + ) + disability_display_support = fields.Boolean( + string="Show Support Needs tab", + default=True, + ) + disability_require_impairment = fields.Boolean( + string="Require Impairment Classification to submit", + default=True, + ) + disability_require_wg = fields.Boolean( + string="Require WG/CFM Assessment to submit", + default=True, + ) + disability_require_support = fields.Boolean( + string="Require Support Needs to submit", + default=True, + ) + disability_support_show_devices = fields.Boolean( + string="Show Assistive Devices on Support Needs", + default=True, + ) + disability_display_review = fields.Boolean( + string="Show Review Schedule", + default=True, + ) + disability_require_review = fields.Boolean( + string="Require Review Schedule to submit", + default=True, + ) + + # field name -> ir.config_parameter key, for default-True booleans that must + # round-trip a False value (a config_parameter boolean cannot — see the note + # on disability_allow_proxy_wg_ss above). + _DEFAULT_TRUE_PARAMS = { + "disability_allow_proxy_wg_ss": "spp_disability_registry.allow_proxy_wg_ss", + "disability_require_proxy_details": "spp_disability_registry.require_proxy_details", + "disability_display_impairment": "spp_disability_registry.display_impairment", + "disability_display_wg": "spp_disability_registry.display_wg", + "disability_display_support": "spp_disability_registry.display_support", + "disability_display_review": "spp_disability_registry.display_review", + "disability_require_impairment": "spp_disability_registry.require_impairment", + "disability_require_wg": "spp_disability_registry.require_wg", + "disability_require_support": "spp_disability_registry.require_support", + "disability_require_review": "spp_disability_registry.require_review", + } + + def get_values(self): + res = super().get_values() + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + icp = self.env["ir.config_parameter"].sudo() + for field_name, key in self._DEFAULT_TRUE_PARAMS.items(): + res[field_name] = icp.get_param(key, "True") == "True" + # Blank by default (never "0"); stored only when self-report is enabled. + res["disability_self_report_min_age"] = icp.get_param("spp_disability_registry.self_report_min_age", "") + return res + + def set_values(self): + # When self-report on CFM 5-17 is enabled, a minimum age between 5 and 17 + # must be provided. + min_age = 0 + if self.disability_allow_self_report_cfm: + try: + min_age = int(self.disability_self_report_min_age or 0) + except (TypeError, ValueError): + min_age = 0 + if not (5 <= min_age <= 17): + raise ValidationError( + _("Enter a minimum self-report age between 5 and 17 to allow self-report on CFM 5-17.") + ) + super().set_values() + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + icp = self.env["ir.config_parameter"].sudo() + for field_name, key in self._DEFAULT_TRUE_PARAMS.items(): + icp.set_param(key, "True" if self[field_name] else "False") + # Store the validated age only while self-report is on; otherwise clear it + # so the field is blank by default next time. + icp.set_param( + "spp_disability_registry.self_report_min_age", + str(min_age) if self.disability_allow_self_report_cfm else "", + ) diff --git a/spp_disability_registry/readme/DESCRIPTION.md b/spp_disability_registry/readme/DESCRIPTION.md index 4e209323f..3ff8a72f5 100644 --- a/spp_disability_registry/readme/DESCRIPTION.md +++ b/spp_disability_registry/readme/DESCRIPTION.md @@ -7,6 +7,6 @@ Comprehensive disability assessment and registry management for social protectio - Disability indicator computation per WG standard (any domain with "a lot of difficulty" or "cannot do at all") - Review category system (MIE/MIP/MINE) with automatic next-review-date scheduling - Impairment type, cause, and severity classifications using DCI vocabularies -- Assistive device management with status workflow (needed, requested, provided) +- Assistive device management with status (needed, requested, provided) - Proxy response tracking for children - CEL function integration for program eligibility targeting diff --git a/spp_disability_registry/readme/HISTORY.md b/spp_disability_registry/readme/HISTORY.md index 96a0cd813..0593aeeb0 100644 --- a/spp_disability_registry/readme/HISTORY.md +++ b/spp_disability_registry/readme/HISTORY.md @@ -1,3 +1,12 @@ +### 19.0.3.0.0 + +- feat(disability_registry): age-driven assessment type selection with manual override (#1050) +- feat(disability_registry): CFM 2-4 and CFM 5-17 questionnaires (#1048, #1049) +- feat(disability_registry): configurable assessment approval workflow (#1060) +- feat(disability_registry): impairment classification on its own multi-row tab (#1054) +- feat(disability_registry): improved assistive-device management + proxy response by assessment type (#1052, #1053) +- fix(disability_registry): recognise approved assessments in the registry (#1022) + ### 19.0.2.0.1 - fix(views): apply `spp_registry.x2many_no_padding` widget to the disability assessments list on registrant forms, and hide the table when empty (showing a muted info line instead) (#943). diff --git a/spp_disability_registry/security/ir.model.access.csv b/spp_disability_registry/security/ir.model.access.csv index c2ca0a345..48a34a4f0 100644 --- a/spp_disability_registry/security/ir.model.access.csv +++ b/spp_disability_registry/security/ir.model.access.csv @@ -7,3 +7,11 @@ access_spp_assistive_device_viewer,spp.assistive.device viewer,model_spp_assisti access_spp_assistive_device_assessor,spp.assistive.device assessor,model_spp_assistive_device,group_disability_assessor,1,1,1,0 access_spp_assistive_device_validator,spp.assistive.device validator,model_spp_assistive_device,group_disability_validator,1,1,1,0 access_spp_assistive_device_manager,spp.assistive.device manager,model_spp_assistive_device,group_disability_manager,1,1,1,1 +access_spp_disability_impairment_viewer,spp.disability.impairment viewer,model_spp_disability_impairment,group_disability_viewer,1,0,0,0 +access_spp_disability_impairment_assessor,spp.disability.impairment assessor,model_spp_disability_impairment,group_disability_assessor,1,1,1,1 +access_spp_disability_impairment_validator,spp.disability.impairment validator,model_spp_disability_impairment,group_disability_validator,1,1,1,1 +access_spp_disability_impairment_manager,spp.disability.impairment manager,model_spp_disability_impairment,group_disability_manager,1,1,1,1 +access_spp_disability_assessment_device_request_viewer,spp.disability.assessment.device.request viewer,model_spp_disability_assessment_device_request,group_disability_viewer,1,0,0,0 +access_spp_disability_assessment_device_request_assessor,spp.disability.assessment.device.request assessor,model_spp_disability_assessment_device_request,group_disability_assessor,1,1,1,1 +access_spp_disability_assessment_device_request_validator,spp.disability.assessment.device.request validator,model_spp_disability_assessment_device_request,group_disability_validator,1,1,1,1 +access_spp_disability_assessment_device_request_manager,spp.disability.assessment.device.request manager,model_spp_disability_assessment_device_request,group_disability_manager,1,1,1,1 diff --git a/spp_disability_registry/static/description/index.html b/spp_disability_registry/static/description/index.html index 27db6080d..5a3db2269 100644 --- a/spp_disability_registry/static/description/index.html +++ b/spp_disability_registry/static/description/index.html @@ -387,8 +387,7 @@

Key Features

scheduling
  • Impairment type, cause, and severity classifications using DCI vocabularies
  • -
  • Assistive device management with status workflow (needed, requested, -provided)
  • +
  • Assistive device management with status (needed, requested, provided)
  • Proxy response tracking for children
  • CEL function integration for program eligibility targeting
  • @@ -403,6 +402,23 @@

    Changelog

    +

    19.0.3.0.0

    +
      +
    • feat(disability_registry): age-driven assessment type selection with +manual override (#1050)
    • +
    • feat(disability_registry): CFM 2-4 and CFM 5-17 questionnaires (#1048, +#1049)
    • +
    • feat(disability_registry): configurable assessment approval workflow +(#1060)
    • +
    • feat(disability_registry): impairment classification on its own +multi-row tab (#1054)
    • +
    • feat(disability_registry): improved assistive-device management + +proxy response by assessment type (#1052, #1053)
    • +
    • fix(disability_registry): recognise approved assessments in the +registry (#1022)
    • +
    +
    +

    19.0.2.0.1

    • fix(views): apply spp_registry.x2many_no_padding widget to the @@ -410,7 +426,7 @@

      19.0.2.0.1

      when empty (showing a muted info line instead) (#943).
    -
    +

    19.0.2.0.0

    • Initial migration to OpenSPP2
    • diff --git a/spp_disability_registry/tests/__init__.py b/spp_disability_registry/tests/__init__.py index c095b34b6..05aa9e197 100644 --- a/spp_disability_registry/tests/__init__.py +++ b/spp_disability_registry/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_assessment from . import test_cel_functions +from . import test_coverage_gaps from . import test_registrant diff --git a/spp_disability_registry/tests/test_assessment.py b/spp_disability_registry/tests/test_assessment.py index e1b8fa290..c46f0daf2 100644 --- a/spp_disability_registry/tests/test_assessment.py +++ b/spp_disability_registry/tests/test_assessment.py @@ -3,7 +3,7 @@ from dateutil.relativedelta import relativedelta from odoo import Command -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError, ValidationError from odoo.tests import TransactionCase, tagged @@ -281,17 +281,214 @@ def test_assessment_name_computed(self): # === Proxy Response Tests === def test_proxy_flag_set_for_child(self): - """Test that proxy response flag is set for child assessments.""" + """Proxy response flag is set automatically for child (CFM) assessments.""" assessment = self.env["spp.disability.assessment"].create( { "registrant_id": self.child_registrant.id, "assessment_date": date.today(), } ) - # Trigger onchange - assessment._onchange_assessment_type() + # is_proxy_response is computed from the (age-derived) assessment type. + self.assertIn(assessment.assessment_type, ("cfm_2_4", "cfm_5_17")) self.assertTrue(assessment.is_proxy_response) + def test_questionnaire_required_before_submit(self): + """A blank questionnaire blocks submission; completing it lifts the gate.""" + assessment = self.env["spp.disability.assessment"].create( + { + "registrant_id": self.adult_registrant.id, + "assessment_date": date.today(), + } + ) + # WG-SS with no answers cannot be submitted. + self.assertFalse(assessment.questionnaire_complete) + with self.assertRaises(UserError): + assessment.action_submit_for_approval() + # Answering all six WG-SS domains makes it complete (even "no difficulty"). + assessment.write( + { + "wg_seeing": "none", + "wg_hearing": "none", + "wg_walking": "none", + "wg_remembering": "none", + "wg_selfcare": "none", + "wg_communicating": "none", + } + ) + self.assertTrue(assessment.questionnaire_complete) + + def test_approval_propagates_to_registrant(self): + """Approving updates the registrant's disability status (#1022). + + The approval mixin writes approval_state via raw SQL, so the registrant's + computed status must be re-synced via the _on_approve hook. + """ + assessment = self.env["spp.disability.assessment"].create( + { + "registrant_id": self.adult_registrant.id, + "assessment_date": date.today(), + "wg_seeing": "cannot", + "wg_hearing": "none", + "wg_walking": "none", + "wg_remembering": "none", + "wg_selfcare": "none", + "wg_communicating": "none", + } + ) + self.assertTrue(assessment.has_disability) + self.assertFalse(self.adult_registrant.has_disability) + # Simulate a submitted record and approve via the mixin's SQL path. + assessment.write({"approval_state": "pending"}) + assessment._do_approve() + self.assertEqual(assessment.approval_state, "approved") + self.assertTrue(self.adult_registrant.has_disability) + self.assertEqual(self.adult_registrant.current_disability_assessment_id, assessment) + + # === OP#1068: tab config, impairment gate, device requests === + def _full_wg(self): + return { + "wg_seeing": "none", + "wg_hearing": "none", + "wg_walking": "none", + "wg_remembering": "none", + "wg_selfcare": "none", + "wg_communicating": "none", + } + + def test_assessment_complete_requires_impairment_answer(self): + """With the default config (impairment + WG required), a complete + questionnaire alone isn't enough — the impairment question must be + answered (OP#1068).""" + vals = {"registrant_id": self.adult_registrant.id, "assessment_date": date.today()} + vals.update(self._full_wg()) + a = self.env["spp.disability.assessment"].create(vals) + # Review schedule is required by default (OP#1068); set it so this test + # isolates the impairment-answer gate. + a.review_category = "mie" + self.assertTrue(a.questionnaire_complete) + self.assertFalse(a.assessment_complete) # impairment question unanswered + # "No" → impairment tab complete → assessment complete (support never gates). + a.has_impairments_to_record = "no" + self.assertTrue(a.impairment_tab_complete) + self.assertTrue(a.assessment_complete) + # "Yes" with no rows → incomplete again. + a.has_impairments_to_record = "yes" + self.assertFalse(a.impairment_tab_complete) + self.assertFalse(a.assessment_complete) + + def test_gate_is_config_driven(self): + """When WG/CFM is configured as not required, a blank questionnaire no + longer blocks completion (OP#1068).""" + icp = self.env["ir.config_parameter"].sudo() + icp.set_param("spp_disability_registry.require_wg", "False") + a = self.env["spp.disability.assessment"].create( + { + "registrant_id": self.adult_registrant.id, + "assessment_date": date.today(), + "has_impairments_to_record": "no", + # Review schedule is required by default (OP#1068); set it so the + # WG config gate is the only variable under test. + "review_category": "mie", + } + ) + a.invalidate_recordset() + self.assertFalse(a.questionnaire_complete) + self.assertFalse(a.cfg_require_wg) + self.assertTrue(a.assessment_complete) + + def test_review_schedule_gates_submission(self): + """Review schedule is required by default (OP#1068): a complete WG + + impairment answer is not enough until a review category is set, and + disabling the requirement removes the gate.""" + vals = { + "registrant_id": self.adult_registrant.id, + "assessment_date": date.today(), + "has_impairments_to_record": "no", + } + vals.update(self._full_wg()) + a = self.env["spp.disability.assessment"].create(vals) + # impairment + WG satisfied, but review category missing -> blocked + self.assertFalse(a.assessment_complete) + a.review_category = "mie" + self.assertTrue(a.assessment_complete) + # When review is not required, a missing category no longer blocks. + a.review_category = False + self.env["ir.config_parameter"].sudo().set_param("spp_disability_registry.require_review", "False") + a.invalidate_recordset() + self.assertFalse(a.cfg_require_review) + self.assertTrue(a.assessment_complete) + + def test_proxy_details_required_when_proxy(self): + """When a proxy responded and proxy details are required (default), + the assessment cannot be completed until the respondent and the + relationship are recorded (OP#1053).""" + vals = { + "registrant_id": self.adult_registrant.id, + "assessment_date": date.today(), + "has_impairments_to_record": "no", + "review_category": "mie", + } + vals.update(self._full_wg()) + a = self.env["spp.disability.assessment"].create(vals) + self.assertTrue(a.assessment_complete) # baseline complete, no proxy in use + # Mark as a proxy response -> proxy details now required to complete. + a.is_proxy_response = True + self.assertTrue(a.cfg_require_proxy_details) + self.assertFalse(a.assessment_complete) + a.proxy_respondent_id = self.child_registrant.id + a.proxy_relationship = "parent" + self.assertTrue(a.assessment_complete) + + def test_impairment_severity_display(self): + """Each impairment line renders its type with its own severity on a + separate line in the overview display (OP#1068).""" + imp_types = self.env["spp.vocabulary.code"].search( + [("vocabulary_id.namespace_uri", "=", "urn:dci:cd:dr:01")], limit=2 + ) + severities = self.env["spp.vocabulary.code"].search( + [("vocabulary_id.namespace_uri", "=", "urn:dci:cd:dr:02")], limit=2 + ) + self.assertTrue(len(imp_types) >= 2 and len(severities) >= 2, "need 2 impairment types + 2 severities") + a = self.env["spp.disability.assessment"].create( + { + "registrant_id": self.adult_registrant.id, + "assessment_date": date.today(), + "has_impairments_to_record": "yes", + "impairment_line_ids": [ + (0, 0, {"impairment_type_id": imp_types[0].id, "severity_level_id": severities[0].id}), + (0, 0, {"impairment_type_id": imp_types[1].id, "severity_level_id": severities[1].id}), + ], + } + ) + html = str(a.impairment_severity_display or "") + for code in (imp_types[0], imp_types[1], severities[0], severities[1]): + self.assertIn(code.display, html) + # One
      per impairment line -> two separate lines. + self.assertEqual(html.count("
      "), 2) + + def test_device_requests_materialize_on_approve(self): + """Support-Needs device requests become spp.assistive.device (status + 'needed') on the registrant when the assessment is approved (OP#1068).""" + device_type = self.env["spp.vocabulary.code"].search( + [("vocabulary_id.namespace_uri", "=", "urn:dci:cd:dr:04")], limit=1 + ) + if not device_type: + self.skipTest("no assistive-device type vocabulary code present") + a = self.env["spp.disability.assessment"].create( + { + "registrant_id": self.adult_registrant.id, + "assessment_date": date.today(), + "device_request_ids": [(0, 0, {"device_type_id": device_type.id})], + } + ) + Device = self.env["spp.assistive.device"] + self.assertFalse(Device.search_count([("registrant_id", "=", self.adult_registrant.id)])) + a.write({"approval_state": "pending"}) + a._do_approve() + dev = Device.search([("registrant_id", "=", self.adult_registrant.id), ("device_type_id", "=", device_type.id)]) + self.assertEqual(len(dev), 1) + self.assertEqual(dev.status, "needed") + # === Date Validation Tests === def test_future_assessment_date_rejected(self): diff --git a/spp_disability_registry/tests/test_cel_functions.py b/spp_disability_registry/tests/test_cel_functions.py index c0541ae1a..2cfc06fc3 100644 --- a/spp_disability_registry/tests/test_cel_functions.py +++ b/spp_disability_registry/tests/test_cel_functions.py @@ -95,6 +95,11 @@ def setUpClass(cls): ], limit=1, ) + # Severity is recorded on impairment lines; grab an impairment type. + cls.impairment_type = cls.env["spp.vocabulary.code"].search( + [("vocabulary_id.namespace_uri", "=", "urn:dci:cd:dr:01")], + limit=1, + ) # Create approved assessment for member_with_disability (mild) assessment1 = cls.env["spp.disability.assessment"].create( @@ -102,7 +107,9 @@ def setUpClass(cls): "registrant_id": cls.member_with_disability.id, "assessment_date": date.today(), "wg_walking": "a_lot", - "severity_level_id": cls.severity_mild.id if cls.severity_mild else False, + "impairment_line_ids": [ + (0, 0, {"impairment_type_id": cls.impairment_type.id, "severity_level_id": cls.severity_mild.id}) + ], } ) assessment1.write({"approval_state": "approved"}) @@ -114,7 +121,9 @@ def setUpClass(cls): "assessment_date": date.today(), "wg_seeing": "cannot", "wg_hearing": "cannot", - "severity_level_id": cls.severity_severe.id if cls.severity_severe else False, + "impairment_line_ids": [ + (0, 0, {"impairment_type_id": cls.impairment_type.id, "severity_level_id": cls.severity_severe.id}) + ], "review_category": "mine", } ) @@ -300,7 +309,9 @@ def test_needs_reassessment_true_when_due(self): "registrant_id": overdue_registrant.id, "assessment_date": date.today() - relativedelta(years=2), "wg_walking": "a_lot", - "severity_level_id": self.severity_mild.id if self.severity_mild else False, + "impairment_line_ids": [ + (0, 0, {"impairment_type_id": self.impairment_type.id, "severity_level_id": self.severity_mild.id}) + ], "review_category": "mie", # 12 months, so overdue } ) diff --git a/spp_disability_registry/tests/test_coverage_gaps.py b/spp_disability_registry/tests/test_coverage_gaps.py new file mode 100644 index 000000000..4da2d81ce --- /dev/null +++ b/spp_disability_registry/tests/test_coverage_gaps.py @@ -0,0 +1,145 @@ +from datetime import date + +from dateutil.relativedelta import relativedelta + +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestDisabilityConfigAndModels(TransactionCase): + """Covers configuration settings, registrant eligibility/rollups, assistive + devices, impairment lines and assessment device requests.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.code = cls.env["spp.vocabulary.code"] + cls.registrant = cls.env["res.partner"].create( + { + "name": "Coverage Adult", + "is_registrant": True, + "is_group": False, + "birthdate": date.today() - relativedelta(years=30), + } + ) + + def _code(self, namespace): + return self.code.search([("vocabulary_id.namespace_uri", "=", namespace)], limit=1) + + # === res.config.settings === + def test_config_settings_roundtrip(self): + Settings = self.env["res.config.settings"] + settings = Settings.create( + { + "disability_allow_proxy_wg_ss": False, + "disability_display_impairment": False, + "disability_require_support": False, + "disability_allow_self_report_cfm": True, + "disability_self_report_min_age": "10", + } + ) + settings.set_values() + + icp = self.env["ir.config_parameter"].sudo() + self.assertEqual(icp.get_param("spp_disability_registry.allow_proxy_wg_ss"), "False") + self.assertEqual(icp.get_param("spp_disability_registry.display_impairment"), "False") + self.assertEqual(icp.get_param("spp_disability_registry.self_report_min_age"), "10") + + vals = settings.get_values() + self.assertFalse(vals["disability_allow_proxy_wg_ss"]) + self.assertFalse(vals["disability_display_impairment"]) + self.assertTrue(vals["disability_require_wg"]) # untouched default stays True + self.assertEqual(vals["disability_self_report_min_age"], "10") + + def test_config_self_report_min_age_out_of_range(self): + Settings = self.env["res.config.settings"] + settings = Settings.create( + { + "disability_allow_self_report_cfm": True, + "disability_self_report_min_age": "3", + } + ) + with self.assertRaises(ValidationError): + settings.set_values() + + # === Registrant eligibility + actions === + def test_can_create_assessment_by_age(self): + # Adult with a birthdate can create. + self.assertTrue(self.registrant.can_create_disability_assessment) + + # No birthdate -> blocked, with a reason. + no_bd = self.env["res.partner"].create({"name": "No Birthdate", "is_registrant": True, "is_group": False}) + self.assertFalse(no_bd.can_create_disability_assessment) + self.assertTrue(no_bd.disability_no_create_reason) + + # Under 2 -> blocked. + baby = self.env["res.partner"].create( + { + "name": "Under Two", + "is_registrant": True, + "is_group": False, + "birthdate": date.today() - relativedelta(years=1), + } + ) + self.assertFalse(baby.can_create_disability_assessment) + + def test_registrant_action_helpers(self): + self.assertEqual(self.registrant.action_view_disability_assessments()["res_model"], "spp.disability.assessment") + self.assertEqual(self.registrant.action_view_assistive_devices()["res_model"], "spp.assistive.device") + self.assertEqual( + self.registrant.action_create_disability_assessment()["res_model"], "spp.disability.assessment" + ) + + # === Assistive device === + def test_assistive_device_name_onchange_and_rollup(self): + device_type = self._code("urn:dci:cd:dr:04") + if not device_type: + self.skipTest("no device-type vocabulary codes present") + device = self.env["spp.assistive.device"].create( + { + "registrant_id": self.registrant.id, + "device_type_id": device_type.id, + "status": "provided", + "provision_date": date.today(), + } + ) + self.assertIn(self.registrant.name, device.name) + self.assertIn(device_type.display, device.name) + + # Changing away from "provided" clears the provision date. + device.status = "needed" + device._onchange_status() + self.assertFalse(device.provision_date) + + action = device.action_view_registrant() + self.assertEqual(action["res_id"], self.registrant.id) + + self.registrant.invalidate_recordset() + self.assertEqual(self.registrant.assistive_device_count, 1) + self.assertTrue(self.registrant.has_unmet_device_need) + + # === Impairment line + assessment device request === + def test_impairment_and_device_request_on_assessment(self): + assessment = self.env["spp.disability.assessment"].create( + {"registrant_id": self.registrant.id, "assessment_date": date.today()} + ) + imp_type = self._code("urn:dci:cd:dr:01") + severity = self._code("urn:dci:cd:dr:02") + device_type = self._code("urn:dci:cd:dr:04") + if not (imp_type and severity and device_type): + self.skipTest("disability vocabulary codes not present") + + impairment = self.env["spp.disability.impairment"].create( + { + "assessment_id": assessment.id, + "impairment_type_id": imp_type.id, + "severity_level_id": severity.id, + } + ) + self.assertEqual(impairment.severity_sequence, severity.sequence) + + request = self.env["spp.disability.assessment.device.request"].create( + {"assessment_id": assessment.id, "device_type_id": device_type.id} + ) + self.assertEqual(request.status, "needed") diff --git a/spp_disability_registry/tests/test_registrant.py b/spp_disability_registry/tests/test_registrant.py index 03d5d93b9..dbe1b3c0d 100644 --- a/spp_disability_registry/tests/test_registrant.py +++ b/spp_disability_registry/tests/test_registrant.py @@ -112,6 +112,12 @@ def setUpClass(cls): limit=1, ) + # Get an impairment type (severity is recorded on impairment lines) + cls.impairment_type = cls.env["spp.vocabulary.code"].search( + [("vocabulary_id.namespace_uri", "=", "urn:dci:cd:dr:01")], + limit=1, + ) + # Get device type cls.device_wheelchair = cls.env["spp.vocabulary.code"].search( [ @@ -135,7 +141,9 @@ def test_no_disability_with_draft_assessment(self): "registrant_id": self.registrant.id, "assessment_date": date.today(), "wg_seeing": "a_lot", - "severity_level_id": self.severity_mild.id if self.severity_mild else False, + "impairment_line_ids": [ + (0, 0, {"impairment_type_id": self.impairment_type.id, "severity_level_id": self.severity_mild.id}) + ], } ) # Recompute @@ -149,7 +157,13 @@ def test_disability_with_approved_assessment(self): "registrant_id": self.registrant.id, "assessment_date": date.today(), "wg_seeing": "a_lot", - "severity_level_id": self.severity_severe.id if self.severity_severe else False, + "impairment_line_ids": [ + ( + 0, + 0, + {"impairment_type_id": self.impairment_type.id, "severity_level_id": self.severity_severe.id}, + ) + ], "review_category": "mip", } ) @@ -172,7 +186,9 @@ def test_latest_approved_assessment_used(self): "registrant_id": self.registrant.id, "assessment_date": date.today() - relativedelta(months=6), "wg_seeing": "a_lot", - "severity_level_id": self.severity_mild.id if self.severity_mild else False, + "impairment_line_ids": [ + (0, 0, {"impairment_type_id": self.impairment_type.id, "severity_level_id": self.severity_mild.id}) + ], } ) old_assessment.write({"approval_state": "approved"}) @@ -183,7 +199,13 @@ def test_latest_approved_assessment_used(self): "registrant_id": self.registrant.id, "assessment_date": date.today(), "wg_seeing": "cannot", - "severity_level_id": self.severity_severe.id if self.severity_severe else False, + "impairment_line_ids": [ + ( + 0, + 0, + {"impairment_type_id": self.impairment_type.id, "severity_level_id": self.severity_severe.id}, + ) + ], } ) new_assessment.write({"approval_state": "approved"}) @@ -306,7 +328,13 @@ def test_household_member_disability_independent(self): "registrant_id": self.member1.id, "assessment_date": date.today(), "wg_walking": "cannot", - "severity_level_id": self.severity_severe.id if self.severity_severe else False, + "impairment_line_ids": [ + ( + 0, + 0, + {"impairment_type_id": self.impairment_type.id, "severity_level_id": self.severity_severe.id}, + ) + ], } ) assessment.write({"approval_state": "approved"}) diff --git a/spp_disability_registry/views/assessment_views.xml b/spp_disability_registry/views/assessment_views.xml index 105874e6b..1ab4715a4 100644 --- a/spp_disability_registry/views/assessment_views.xml +++ b/spp_disability_registry/views/assessment_views.xml @@ -5,14 +5,31 @@ spp.disability.assessment.form spp.disability.assessment -
      +
      + + + + + + + + + + + +
      + +