Coverage for tasks/phq15.py : 44%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/tasks/phq15.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27"""
29from typing import Any, Dict, List, Tuple, Type
31from cardinal_pythonlib.stringfunc import strseq
32from sqlalchemy.ext.declarative import DeclarativeMeta
33from sqlalchemy.sql.sqltypes import Integer
35from camcops_server.cc_modules.cc_constants import CssClass
36from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
37from camcops_server.cc_modules.cc_db import add_multiple_columns
38from camcops_server.cc_modules.cc_html import answer, get_yes_no, tr, tr_qa
39from camcops_server.cc_modules.cc_request import CamcopsRequest
40from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
41from camcops_server.cc_modules.cc_sqla_coltypes import SummaryCategoryColType
42from camcops_server.cc_modules.cc_summaryelement import SummaryElement
43from camcops_server.cc_modules.cc_task import (
44 get_from_dict,
45 Task,
46 TaskHasPatientMixin,
47)
48from camcops_server.cc_modules.cc_text import SS
49from camcops_server.cc_modules.cc_trackerhelpers import (
50 TrackerInfo,
51 TrackerLabel,
52)
55# =============================================================================
56# PHQ-15
57# =============================================================================
59class Phq15Metaclass(DeclarativeMeta):
60 # noinspection PyInitNewSignature
61 def __init__(cls: Type['Phq15'],
62 name: str,
63 bases: Tuple[Type, ...],
64 classdict: Dict[str, Any]) -> None:
65 add_multiple_columns(
66 cls, "q", 1, cls.NQUESTIONS,
67 minimum=0, maximum=2,
68 comment_fmt="Q{n} ({s}) (0 not bothered at all - "
69 "2 bothered a lot)",
70 comment_strings=[
71 "stomach pain",
72 "back pain",
73 "limb/joint pain",
74 "F - menstrual",
75 "headaches",
76 "chest pain",
77 "dizziness",
78 "fainting",
79 "palpitations",
80 "breathless",
81 "sex",
82 "constipation/diarrhoea",
83 "nausea/indigestion",
84 "energy",
85 "sleep",
86 ]
87 )
88 super().__init__(name, bases, classdict)
91class Phq15(TaskHasPatientMixin, Task,
92 metaclass=Phq15Metaclass):
93 """
94 Server implementation of the PHQ-15 task.
95 """
96 __tablename__ = "phq15"
97 shortname = "PHQ-15"
98 provides_trackers = True
100 NQUESTIONS = 15
101 MAX_TOTAL = 30
103 ONE_TO_THREE = strseq("q", 1, 3)
104 FIVE_TO_END = strseq("q", 5, NQUESTIONS)
105 TASK_FIELDS = strseq("q", 1, NQUESTIONS)
107 @staticmethod
108 def longname(req: "CamcopsRequest") -> str:
109 _ = req.gettext
110 return _("Patient Health Questionnaire-15")
112 # noinspection PyUnresolvedReferences
113 def is_complete(self) -> bool:
114 if not self.field_contents_valid():
115 return False
116 if self.any_fields_none(self.ONE_TO_THREE):
117 return False
118 if self.any_fields_none(self.FIVE_TO_END):
119 return False
120 if self.is_female():
121 return self.q4 is not None
122 else:
123 return True
125 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
126 return [TrackerInfo(
127 value=self.total_score(),
128 plot_label="PHQ-15 total score (rating somatic symptoms)",
129 axis_label=f"Score for Q1-15 (out of {self.MAX_TOTAL})",
130 axis_min=-0.5,
131 axis_max=self.MAX_TOTAL + 0.5,
132 horizontal_lines=[14.5, 9.5, 4.5],
133 horizontal_labels=[
134 TrackerLabel(22, req.sstring(SS.SEVERE)),
135 TrackerLabel(12, req.sstring(SS.MODERATE)),
136 TrackerLabel(7, req.sstring(SS.MILD)),
137 TrackerLabel(2.25, req.sstring(SS.NONE)),
138 ]
139 )]
141 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
142 if not self.is_complete():
143 return CTV_INCOMPLETE
144 return [CtvInfo(content=(
145 f"PHQ-15 total score {self.total_score()}/{self.MAX_TOTAL} "
146 f"({self.severity( req)})"
147 ))]
149 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
150 return self.standard_task_summary_fields() + [
151 SummaryElement(name="total",
152 coltype=Integer(),
153 value=self.total_score(),
154 comment=f"Total score (/{self.MAX_TOTAL})"),
155 SummaryElement(name="severity",
156 coltype=SummaryCategoryColType,
157 value=self.severity(req),
158 comment="Severity"),
159 ]
161 def total_score(self) -> int:
162 return self.sum_fields(self.TASK_FIELDS)
164 def num_severe(self) -> int:
165 n = 0
166 for i in range(1, self.NQUESTIONS + 1):
167 value = getattr(self, "q" + str(i))
168 if value is not None and value >= 2:
169 n += 1
170 return n
172 def severity(self, req: CamcopsRequest) -> str:
173 score = self.total_score()
174 if score >= 15:
175 return req.sstring(SS.SEVERE)
176 elif score >= 10:
177 return req.sstring(SS.MODERATE)
178 elif score >= 5:
179 return req.sstring(SS.MILD)
180 else:
181 return req.sstring(SS.NONE)
183 def get_task_html(self, req: CamcopsRequest) -> str:
184 score = self.total_score()
185 nsevere = self.num_severe()
186 somatoform_likely = nsevere >= 3
187 severity = self.severity(req)
188 answer_dict = {None: None}
189 for option in range(0, 3):
190 answer_dict[option] = str(option) + " – " + \
191 self.wxstring(req, "a" + str(option))
192 q_a = ""
193 for q in range(1, self.NQUESTIONS + 1):
194 q_a += tr_qa(
195 self.wxstring(req, "q" + str(q)),
196 get_from_dict(answer_dict, getattr(self, "q" + str(q)))
197 )
198 h = """
199 <div class="{CssClass.SUMMARY}">
200 <table class="{CssClass.SUMMARY}">
201 {tr_is_complete}
202 {total_score}
203 {n_severe_symptoms}
204 {exceeds_somatoform_cutoff}
205 {symptom_severity}
206 </table>
207 </div>
208 <table class="{CssClass.TASKDETAIL}">
209 <tr>
210 <th width="70%">Question</th>
211 <th width="30%">Answer</th>
212 </tr>
213 {q_a}
214 </table>
215 <div class="{CssClass.FOOTNOTES}">
216 [1] In males, maximum score is actually 28.
217 [2] Questions with scores ≥2 are considered severe.
218 [3] ≥3 severe symptoms.
219 [4] Total score ≥15 severe, ≥10 moderate, ≥5 mild,
220 otherwise none.
221 </div>
222 """.format(
223 CssClass=CssClass,
224 tr_is_complete=self.get_is_complete_tr(req),
225 total_score=tr(
226 req.sstring(SS.TOTAL_SCORE) + " <sup>[1]</sup>",
227 answer(score) + f" / {self.MAX_TOTAL}"
228 ),
229 n_severe_symptoms=tr_qa(
230 self.wxstring(req, "n_severe_symptoms") + " <sup>[2]</sup>",
231 nsevere
232 ),
233 exceeds_somatoform_cutoff=tr_qa(
234 self.wxstring(req, "exceeds_somatoform_cutoff") +
235 " <sup>[3]</sup>",
236 get_yes_no(req, somatoform_likely)
237 ),
238 symptom_severity=tr_qa(
239 self.wxstring(req, "symptom_severity") + " <sup>[4]</sup>",
240 severity
241 ),
242 q_a=q_a,
243 )
244 return h
246 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
247 procedure = req.snomed(SnomedLookup.PHQ15_PROCEDURE)
248 codes = [SnomedExpression(procedure)]
249 if self.is_complete():
250 scale = req.snomed(SnomedLookup.PHQ15_SCALE)
251 score = req.snomed(SnomedLookup.PHQ15_SCORE)
252 codes.append(SnomedExpression(scale, {score: self.total_score()}))
253 return codes