Coverage for tasks/hads.py: 58%
92 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/hads.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28"""
30from abc import ABC, ABCMeta
31import logging
32from typing import Any, Dict, List, Tuple, Type
34from cardinal_pythonlib.logs import BraceStyleAdapter
35from cardinal_pythonlib.stringfunc import strseq
36from sqlalchemy.ext.declarative import DeclarativeMeta
37from sqlalchemy.sql.sqltypes import Integer
39from camcops_server.cc_modules.cc_constants import (
40 CssClass,
41 DATA_COLLECTION_UNLESS_UPGRADED_DIV,
42)
43from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
44from camcops_server.cc_modules.cc_db import add_multiple_columns
45from camcops_server.cc_modules.cc_html import answer, tr_qa
46from camcops_server.cc_modules.cc_request import CamcopsRequest
47from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
48from camcops_server.cc_modules.cc_string import AS
49from camcops_server.cc_modules.cc_summaryelement import SummaryElement
50from camcops_server.cc_modules.cc_task import (
51 Task,
52 TaskHasPatientMixin,
53 TaskHasRespondentMixin,
54)
55from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
57log = BraceStyleAdapter(logging.getLogger(__name__))
60# =============================================================================
61# HADS (crippled unless upgraded locally) - base classes
62# =============================================================================
65class HadsMetaclass(DeclarativeMeta, ABCMeta):
66 """
67 We can't make this metaclass inherit from DeclarativeMeta.
69 This works:
71 .. :code-block:: python
73 class MyTaskMetaclass(DeclarativeMeta):
74 def __init__(cls, name, bases, classdict):
75 # do useful stuff
76 super().__init__(name, bases, classdict)
78 class MyTask(Task, Base, metaclass=MyTaskMetaclass):
79 __tablename__ = "mytask"
81 ... but at the point that MyTaskMetaclass calls DeclarativeMeta.__init__,
82 it registers "cls" (in this case MyTask) with the SQLAlchemy class
83 registry. In this example, that's fine, because MyTask wants to be
84 registered. But here it fails:
86 .. :code-block:: python
88 class OtherTaskMetaclass(DeclarativeMeta):
89 def __init__(cls, name, bases, classdict):
90 # do useful stuff
91 super().__init__(name, bases, classdict)
93 class Intermediate(Task, metaclass=OtherTaskMetaclass): pass
95 class OtherTask(Intermediate, Base):
96 __tablename__ = "othertask"
98 ... and it fails because OtherTaskMetaclass calls DeclarativeMeta.__init__
99 and this tries to register "Intermediate" with the SQLALchemy ORM.
101 So, it's clear that OtherTaskMetaclass shouldn't derive from
102 DeclarativeMeta. But if we make it derive from "object" instead, we get
103 the error
105 .. :code-block:: none
107 TypeError: metaclass conflict: the metaclass of a derived class must
108 be a (non-strict) subclass of the metaclasses of all its bases
110 because OtherTask inherits from Base, whose metaclass is DeclarativeMeta,
111 but there is another metaclass in the metaclass set that is incompatible
112 with this.
114 So, is solution that OtherTaskMetaclass should derive from "type" and then
115 to use CooperativeMeta (q.v.) for OtherTask?
117 No, that still seems to fail (and before any CooperativeMeta code is
118 called) -- possibly that framework is for Python 2 only.
120 See also
121 https://blog.ionelmc.ro/2015/02/09/understanding-python-metaclasses/
123 Alternative solution 1: make a new metaclass that pretends to inherit
124 from HadsMetaclass and DeclarativeMeta.
126 WENT WITH THIS ONE INITIALLY:
128 .. :code-block:: python
130 class HadsMetaclass(type): # METACLASS
131 def __init__(cls: Type['HadsBase'],
132 name: str,
133 bases: Tuple[Type, ...],
134 classdict: Dict[str, Any]) -> None:
135 add_multiple_columns(...)
137 class HadsBase(TaskHasPatientMixin, Task, # INTERMEDIATE
138 metaclass=HadsMetaclass):
139 ...
141 class HadsBlendedMetaclass(HadsMetaclass, DeclarativeMeta): # ODDITY
142 # noinspection PyInitNewSignature
143 def __init__(cls: Type[Union[HadsBase, DeclarativeMeta]],
144 name: str,
145 bases: Tuple[Type, ...],
146 classdict: Dict[str, Any]) -> None:
147 HadsMetaclass.__init__(cls, name, bases, classdict)
148 # ... will call DeclarativeMeta.__init__ via its
149 # super().__init__()
151 class Hads(HadsBase, # ACTUAL TASK
152 metaclass=HadsBlendedMetaclass):
153 __tablename__ = "hads"
155 Alternative solution 2: continue to have the HadsMetaclass deriving from
156 DeclarativeMeta, but add it in at the last stage.
158 IGNORE THIS, NO LONGER TRUE:
160 - ALL THIS SOMEWHAT REVISED to handle SQLAlchemy concrete inheritance
161 (q.v.), with the rule that "the only things that inherit from Task are
162 actual tasks"; Task then inherits from both AbstractConcreteBase and
163 Base.
165 SEE ALSO sqla_database_structure.txt
167 FINAL ANSWER:
169 - classes inherit in a neat chain from Base -> [+/- Task -> ...]
170 - metaclasses inherit in a neat chain from DeclarativeMeta
171 - abstract intermediates mark themselves with "__abstract__ = True"
173 .. :code-block:: python
175 class HadsMetaclass(DeclarativeMeta): # METACLASS
176 def __init__(cls: Type['HadsBase'],
177 name: str,
178 bases: Tuple[Type, ...],
179 classdict: Dict[str, Any]) -> None:
180 add_multiple_columns(...)
182 class HadsBase(TaskHasPatientMixin, Task, # INTERMEDIATE
183 metaclass=HadsMetaclass):
184 __abstract__ = True
186 class Hads(HadsBase):
187 __tablename__ = "hads"
189 Yes, that's it. (Note that if you erroneously also add
190 "metaclass=HadsMetaclass" on Hads, you get: "TypeError: metaclass conflict:
191 the metaclass of a derived class must be a (non-strict) subclass of the
192 metaclasses of all its bases.")
194 UPDATE 2019-07-28:
196 - To fix "class must implement all abstract methods" warning from PyCharm,
197 add "ABCMeta" to superclass list of HadsMetaclass.
199 """
201 # noinspection PyInitNewSignature
202 def __init__(
203 cls: Type["HadsBase"],
204 name: str,
205 bases: Tuple[Type, ...],
206 classdict: Dict[str, Any],
207 ) -> None:
208 add_multiple_columns(
209 cls,
210 "q",
211 1,
212 cls.NQUESTIONS,
213 minimum=0,
214 maximum=3,
215 comment_fmt="Q{n}: {s} (0-3)",
216 comment_strings=[
217 "tense",
218 "enjoy usual",
219 "apprehensive",
220 "laugh",
221 "worry",
222 "cheerful",
223 "relaxed",
224 "slow",
225 "butterflies",
226 "appearance",
227 "restless",
228 "anticipate",
229 "panic",
230 "book/TV/radio",
231 ],
232 )
233 super().__init__(name, bases, classdict)
236class HadsBase(TaskHasPatientMixin, Task, ABC, metaclass=HadsMetaclass):
237 """
238 Server implementation of the HADS task.
239 """
241 __abstract__ = True
242 provides_trackers = True
244 NQUESTIONS = 14
245 ANXIETY_QUESTIONS = [1, 3, 5, 7, 9, 11, 13]
246 DEPRESSION_QUESTIONS = [2, 4, 6, 8, 10, 12, 14]
247 TASK_FIELDS = strseq("q", 1, NQUESTIONS)
248 MAX_ANX_SCORE = 21
249 MAX_DEP_SCORE = 21
251 def is_complete(self) -> bool:
252 return self.field_contents_valid() and self.all_fields_not_none(
253 self.TASK_FIELDS
254 )
256 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
257 return [
258 TrackerInfo(
259 value=self.anxiety_score(),
260 plot_label="HADS anxiety score",
261 axis_label=f"Anxiety score (out of {self.MAX_ANX_SCORE})",
262 axis_min=-0.5,
263 axis_max=self.MAX_ANX_SCORE + 0.5,
264 ),
265 TrackerInfo(
266 value=self.depression_score(),
267 plot_label="HADS depression score",
268 axis_label=f"Depression score (out of {self.MAX_DEP_SCORE})",
269 axis_min=-0.5,
270 axis_max=self.MAX_DEP_SCORE + 0.5,
271 ),
272 ]
274 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
275 if not self.is_complete():
276 return CTV_INCOMPLETE
277 return [
278 CtvInfo(
279 content=(
280 f"anxiety score "
281 f"{self.anxiety_score()}/{self.MAX_ANX_SCORE}, "
282 f"depression score "
283 f"{self.depression_score()}/{self.MAX_DEP_SCORE}"
284 )
285 )
286 ]
288 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
289 return self.standard_task_summary_fields() + [
290 SummaryElement(
291 name="anxiety",
292 coltype=Integer(),
293 value=self.anxiety_score(),
294 comment=f"Anxiety score (/{self.MAX_ANX_SCORE})",
295 ),
296 SummaryElement(
297 name="depression",
298 coltype=Integer(),
299 value=self.depression_score(),
300 comment=f"Depression score (/{self.MAX_DEP_SCORE})",
301 ),
302 ]
304 def score(self, questions: List[int]) -> int:
305 fields = self.fieldnames_from_list("q", questions)
306 return self.sum_fields(fields)
308 def anxiety_score(self) -> int:
309 return self.score(self.ANXIETY_QUESTIONS)
311 def depression_score(self) -> int:
312 return self.score(self.DEPRESSION_QUESTIONS)
314 def get_task_html(self, req: CamcopsRequest) -> str:
315 min_score = 0
316 max_score = 3
317 crippled = not self.extrastrings_exist(req)
318 a = self.anxiety_score()
319 d = self.depression_score()
320 h = f"""
321 <div class="{CssClass.SUMMARY}">
322 <table class="{CssClass.SUMMARY}">
323 {self.get_is_complete_tr(req)}
324 <tr>
325 <td>{req.wappstring(AS.HADS_ANXIETY_SCORE)}</td>
326 <td>{answer(a)} / {self.MAX_ANX_SCORE}</td>
327 </tr>
328 <tr>
329 <td>{req.wappstring(AS.HADS_DEPRESSION_SCORE)}</td>
330 <td>{answer(d)} / 21</td>
331 </tr>
332 </table>
333 </div>
334 <div class="{CssClass.EXPLANATION}">
335 All questions are scored from 0–3
336 (0 least symptomatic, 3 most symptomatic).
337 </div>
338 <table class="{CssClass.TASKDETAIL}">
339 <tr>
340 <th width="50%">Question</th>
341 <th width="50%">Answer</th>
342 </tr>
343 """
344 for n in range(1, self.NQUESTIONS + 1):
345 if crippled:
346 q = f"HADS: Q{n}"
347 else:
348 q = f"Q{n}. {self.wxstring(req, f'q{n}_stem')}"
349 if n in self.ANXIETY_QUESTIONS:
350 q += " (A)"
351 if n in self.DEPRESSION_QUESTIONS:
352 q += " (D)"
353 v = getattr(self, "q" + str(n))
354 if crippled or v is None or v < min_score or v > max_score:
355 a = v
356 else:
357 a = f"{v}: {self.wxstring(req, f'q{n}_a{v}')}"
358 h += tr_qa(q, a)
359 h += (
360 """
361 </table>
362 """
363 + DATA_COLLECTION_UNLESS_UPGRADED_DIV
364 )
365 return h
368# =============================================================================
369# Hads
370# =============================================================================
373class Hads(HadsBase):
374 __tablename__ = "hads"
375 shortname = "HADS"
377 @staticmethod
378 def longname(req: "CamcopsRequest") -> str:
379 _ = req.gettext
380 return _(
381 "Hospital Anxiety and Depression Scale (data collection only)"
382 )
384 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
385 codes = [
386 SnomedExpression(
387 req.snomed(SnomedLookup.HADS_PROCEDURE_ASSESSMENT)
388 )
389 ]
390 if self.is_complete():
391 codes.append(
392 SnomedExpression(
393 req.snomed(SnomedLookup.HADS_SCALE),
394 {
395 req.snomed(
396 SnomedLookup.HADS_ANXIETY_SCORE
397 ): self.anxiety_score(), # noqa
398 req.snomed(
399 SnomedLookup.HADS_DEPRESSION_SCORE
400 ): self.depression_score(), # noqa
401 },
402 )
403 )
404 return codes
407# =============================================================================
408# HadsRespondent
409# =============================================================================
412class HadsRespondent(TaskHasRespondentMixin, HadsBase):
413 __tablename__ = "hads_respondent"
414 shortname = "HADS-Respondent"
415 extrastring_taskname = "hads"
416 info_filename_stem = extrastring_taskname
418 @staticmethod
419 def longname(req: "CamcopsRequest") -> str:
420 _ = req.gettext
421 return _(
422 "Hospital Anxiety and Depression Scale (data collection "
423 "only), non-patient respondent version"
424 )
426 # No SNOMED codes; not for the patient!