Coverage for tasks/audit.py: 44%
119 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/audit.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
11 CamCOPS is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation, either version 3 of the License, or
14 (at your option) any later version.
16 CamCOPS is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
24===============================================================================
26"""
28from typing import Any, cast, List, Optional, Type
30from cardinal_pythonlib.stringfunc import strseq
31from sqlalchemy.sql.sqltypes import Integer
33from camcops_server.cc_modules.cc_constants import CssClass
34from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
35from camcops_server.cc_modules.cc_db import add_multiple_columns
36from camcops_server.cc_modules.cc_html import answer, get_yes_no, tr, tr_qa
37from camcops_server.cc_modules.cc_request import CamcopsRequest
38from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
39from camcops_server.cc_modules.cc_summaryelement import SummaryElement
40from camcops_server.cc_modules.cc_task import (
41 get_from_dict,
42 Task,
43 TaskHasPatientMixin,
44)
45from camcops_server.cc_modules.cc_text import SS
46from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
49# =============================================================================
50# AUDIT
51# =============================================================================
54class Audit( # type: ignore[misc]
55 TaskHasPatientMixin,
56 Task,
57):
58 """
59 Server implementation of the AUDIT task.
60 """
62 __tablename__ = "audit"
63 shortname = "AUDIT"
64 provides_trackers = True
66 prohibits_commercial = True
68 NQUESTIONS = 10
70 @classmethod
71 def extend_columns(cls: Type["Audit"], **kwargs: Any) -> None:
72 add_multiple_columns(
73 cls,
74 "q",
75 1,
76 cls.NQUESTIONS,
77 minimum=0,
78 maximum=4,
79 comment_fmt="Q{n}, {s} (0-4, higher worse)",
80 comment_strings=[
81 "how often drink",
82 "drinks per day",
83 "how often six drinks",
84 "unable to stop",
85 "unable to do what was expected",
86 "eye opener",
87 "guilt",
88 "unable to remember",
89 "injuries",
90 "others concerned",
91 ],
92 )
94 TASK_FIELDS = strseq("q", 1, NQUESTIONS)
96 @staticmethod
97 def longname(req: "CamcopsRequest") -> str:
98 _ = req.gettext
99 return _("WHO Alcohol Use Disorders Identification Test")
101 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
102 return [
103 TrackerInfo(
104 value=self.total_score(),
105 plot_label="AUDIT total score",
106 axis_label="Total score (out of 40)",
107 axis_min=-0.5,
108 axis_max=40.5,
109 horizontal_lines=[7.5],
110 )
111 ]
113 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
114 if not self.is_complete():
115 return CTV_INCOMPLETE
116 return [CtvInfo(content=f"AUDIT total score {self.total_score()}/40")]
118 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
119 return self.standard_task_summary_fields() + [
120 SummaryElement(
121 name="total",
122 coltype=Integer(),
123 value=self.total_score(),
124 comment="Total score (/40)",
125 )
126 ]
128 # noinspection PyUnresolvedReferences
129 def is_complete(self) -> bool:
130 if not self.field_contents_valid():
131 return False
132 if (
133 self.q1 is None or self.q9 is None or self.q10 is None # type: ignore[attr-defined] # noqa: E501
134 ):
135 return False
136 if self.q1 == 0: # type: ignore[attr-defined]
137 # Special limited-information completeness
138 return True
139 if (
140 self.q2 is not None # type: ignore[attr-defined]
141 and self.q3 is not None # type: ignore[attr-defined]
142 and (self.q2 + self.q3 == 0) # type: ignore[attr-defined]
143 ):
144 # Special limited-information completeness
145 return True
146 # Otherwise, any null values cause problems
147 return self.all_fields_not_none(self.TASK_FIELDS)
149 def total_score(self) -> int:
150 return cast(int, self.sum_fields(self.TASK_FIELDS))
152 # noinspection PyUnresolvedReferences
153 def get_task_html(self, req: CamcopsRequest) -> str:
154 score = self.total_score()
155 exceeds_cutoff = score >= 8
156 q1_dict: dict[Optional[int], Optional[str]] = {None: None}
157 q2_dict: dict[Optional[int], Optional[str]] = {None: None}
158 q3_to_8_dict: dict[Optional[int], Optional[str]] = {None: None}
159 q9_to_10_dict: dict[Optional[int], Optional[str]] = {None: None}
160 for option in range(0, 5):
161 q1_dict[option] = (
162 str(option)
163 + " – "
164 + self.wxstring(req, "q1_option" + str(option))
165 )
166 q2_dict[option] = (
167 str(option)
168 + " – "
169 + self.wxstring(req, "q2_option" + str(option))
170 )
171 q3_to_8_dict[option] = (
172 str(option)
173 + " – "
174 + self.wxstring(req, "q3to8_option" + str(option))
175 )
176 if option != 1 and option != 3:
177 q9_to_10_dict[option] = (
178 str(option)
179 + " – "
180 + self.wxstring(req, "q9to10_option" + str(option))
181 )
183 q_a = tr_qa(
184 self.wxstring(req, "q1_s"),
185 get_from_dict(q1_dict, self.q1), # type: ignore[attr-defined]
186 )
187 q_a += tr_qa(
188 self.wxstring(req, "q2_s"),
189 get_from_dict(q2_dict, self.q2), # type: ignore[attr-defined]
190 )
191 for q in range(3, 8 + 1):
192 q_a += tr_qa(
193 self.wxstring(req, "q" + str(q) + "_s"),
194 get_from_dict(q3_to_8_dict, getattr(self, "q" + str(q))),
195 )
196 q_a += tr_qa(
197 self.wxstring(req, "q9_s"),
198 get_from_dict(
199 q9_to_10_dict, self.q9 # type: ignore[attr-defined]
200 ),
201 )
202 q_a += tr_qa(
203 self.wxstring(req, "q10_s"),
204 get_from_dict(
205 q9_to_10_dict, self.q10 # type: ignore[attr-defined]
206 ),
207 )
209 return f"""
210 <div class="{CssClass.SUMMARY}">
211 <table class="{CssClass.SUMMARY}">
212 {self.get_is_complete_tr(req)}
213 {tr(req.wsstring(SS.TOTAL_SCORE),
214 answer(score) + " / 40")}
215 {tr_qa(self.wxstring(req, "exceeds_standard_cutoff"),
216 get_yes_no(req, exceeds_cutoff))}
217 </table>
218 </div>
219 <table class="{CssClass.TASKDETAIL}">
220 <tr>
221 <th width="50%">Question</th>
222 <th width="50%">Answer</th>
223 </tr>
224 {q_a}
225 </table>
226 <div class="{CssClass.COPYRIGHT}">
227 AUDIT: Copyright © World Health Organization.
228 Reproduced here under the permissions granted for
229 NON-COMMERCIAL use only. You must obtain permission from the
230 copyright holder for any other use.
231 </div>
232 """
234 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
235 codes = [
236 SnomedExpression(
237 req.snomed(SnomedLookup.AUDIT_PROCEDURE_ASSESSMENT)
238 )
239 ]
240 if self.is_complete():
241 codes.append(
242 SnomedExpression(
243 req.snomed(SnomedLookup.AUDIT_SCALE),
244 {req.snomed(SnomedLookup.AUDIT_SCORE): self.total_score()},
245 )
246 )
247 return codes
250# =============================================================================
251# AUDIT-C
252# =============================================================================
255class AuditC(TaskHasPatientMixin, Task): # type: ignore[misc]
256 __tablename__ = "audit_c"
257 shortname = "AUDIT-C"
258 extrastring_taskname = "audit" # shares strings with AUDIT
259 info_filename_stem = extrastring_taskname
261 prohibits_commercial = True
263 NQUESTIONS = 3
265 @classmethod
266 def extend_columns(cls: Type["AuditC"], **kwargs: Any) -> None:
267 add_multiple_columns(
268 cls,
269 "q",
270 1,
271 cls.NQUESTIONS,
272 minimum=0,
273 maximum=4,
274 comment_fmt="Q{n}, {s} (0-4, higher worse)",
275 comment_strings=[
276 "how often drink",
277 "drinks per day",
278 "how often six drinks",
279 ],
280 )
282 TASK_FIELDS = strseq("q", 1, NQUESTIONS)
284 @staticmethod
285 def longname(req: "CamcopsRequest") -> str:
286 _ = req.gettext
287 return _("AUDIT Alcohol Consumption Questions")
289 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
290 return [
291 TrackerInfo(
292 value=self.total_score(),
293 plot_label="AUDIT-C total score",
294 axis_label="Total score (out of 12)",
295 axis_min=-0.5,
296 axis_max=12.5,
297 )
298 ]
300 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
301 if not self.is_complete():
302 return CTV_INCOMPLETE
303 return [
304 CtvInfo(content=f"AUDIT-C total score {self.total_score()}/12")
305 ]
307 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
308 return self.standard_task_summary_fields() + [
309 SummaryElement(
310 name="total",
311 coltype=Integer(),
312 value=self.total_score(),
313 comment="Total score (/12)",
314 )
315 ]
317 def is_complete(self) -> bool:
318 return self.all_fields_not_none(self.TASK_FIELDS)
320 def total_score(self) -> int:
321 return cast(int, self.sum_fields(self.TASK_FIELDS))
323 def get_task_html(self, req: CamcopsRequest) -> str:
324 score = self.total_score()
325 q1_dict: dict[Optional[int], Optional[str]] = {None: None}
326 q2_dict: dict[Optional[int], Optional[str]] = {None: None}
327 q3_dict: dict[Optional[int], Optional[str]] = {None: None}
328 for option in range(0, 5):
329 q1_dict[option] = (
330 str(option)
331 + " – "
332 + self.wxstring(req, "q1_option" + str(option))
333 )
334 if option == 0: # special!
335 q2_dict[option] = (
336 str(option) + " – " + self.wxstring(req, "c_q2_option0")
337 )
338 else:
339 q2_dict[option] = (
340 str(option)
341 + " – "
342 + self.wxstring(req, "q2_option" + str(option))
343 )
344 q3_dict[option] = (
345 str(option)
346 + " – "
347 + self.wxstring(req, "q3to8_option" + str(option))
348 )
350 row_1 = tr_qa(
351 self.wxstring(req, "c_q1_question"),
352 get_from_dict(q1_dict, self.q1), # type: ignore[attr-defined]
353 )
354 row_2 = tr_qa(
355 self.wxstring(req, "c_q2_question"),
356 get_from_dict(q2_dict, self.q2), # type: ignore[attr-defined]
357 )
358 row_3 = tr_qa(
359 self.wxstring(req, "c_q3_question"),
360 get_from_dict(q3_dict, self.q3), # type: ignore[attr-defined]
361 )
363 # noinspection PyUnresolvedReferences
364 return f"""
365 <div class="{CssClass.SUMMARY}">
366 <table class="{CssClass.SUMMARY}">
367 {self.get_is_complete_tr(req)}
368 {tr(req.sstring(SS.TOTAL_SCORE),
369 answer(score) + " / 12")}
370 </table>
371 </div>
372 <table class="{CssClass.TASKDETAIL}">
373 <tr>
374 <th width="50%">Question</th>
375 <th width="50%">Answer</th>
376 </tr>
377 {row_1}
378 {row_2}
379 {row_3}
380 </table>
381 <div class="{CssClass.COPYRIGHT}">
382 AUDIT: Copyright © World Health Organization.
383 Reproduced here under the permissions granted for
384 NON-COMMERCIAL use only. You must obtain permission from the
385 copyright holder for any other use.
387 AUDIT-C: presumed to have the same restrictions.
388 </div>
389 """
391 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
392 codes = [
393 SnomedExpression(
394 req.snomed(SnomedLookup.AUDITC_PROCEDURE_ASSESSMENT)
395 )
396 ]
397 if self.is_complete():
398 codes.append(
399 SnomedExpression(
400 req.snomed(SnomedLookup.AUDITC_SCALE),
401 {
402 req.snomed(
403 SnomedLookup.AUDITC_SCORE
404 ): self.total_score()
405 },
406 )
407 )
408 return codes