Coverage for tasks/hads.py : 57%

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/hads.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 abc import ABC, ABCMeta
30import logging
31from typing import Any, Dict, List, Tuple, Type
33from cardinal_pythonlib.logs import BraceStyleAdapter
34from cardinal_pythonlib.stringfunc import strseq
35from sqlalchemy.ext.declarative import DeclarativeMeta
36from sqlalchemy.sql.sqltypes import Integer
38from camcops_server.cc_modules.cc_constants import (
39 CssClass,
40 DATA_COLLECTION_UNLESS_UPGRADED_DIV,
41)
42from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
43from camcops_server.cc_modules.cc_db import add_multiple_columns
44from camcops_server.cc_modules.cc_html import answer, tr_qa
45from camcops_server.cc_modules.cc_request import CamcopsRequest
46from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
47from camcops_server.cc_modules.cc_string import AS
48from camcops_server.cc_modules.cc_summaryelement import SummaryElement
49from camcops_server.cc_modules.cc_task import (
50 Task,
51 TaskHasPatientMixin,
52 TaskHasRespondentMixin,
53)
54from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
56log = BraceStyleAdapter(logging.getLogger(__name__))
59# =============================================================================
60# HADS (crippled unless upgraded locally) - base classes
61# =============================================================================
63class HadsMetaclass(DeclarativeMeta, ABCMeta):
64 """
65 We can't make this metaclass inherit from DeclarativeMeta.
67 This works:
69 .. :code-block:: python
71 class MyTaskMetaclass(DeclarativeMeta):
72 def __init__(cls, name, bases, classdict):
73 # do useful stuff
74 super().__init__(name, bases, classdict)
76 class MyTask(Task, Base, metaclass=MyTaskMetaclass):
77 __tablename__ = "mytask"
79 ... but at the point that MyTaskMetaclass calls DeclarativeMeta.__init__,
80 it registers "cls" (in this case MyTask) with the SQLAlchemy class
81 registry. In this example, that's fine, because MyTask wants to be
82 registered. But here it fails:
84 .. :code-block:: python
86 class OtherTaskMetaclass(DeclarativeMeta):
87 def __init__(cls, name, bases, classdict):
88 # do useful stuff
89 super().__init__(name, bases, classdict)
91 class Intermediate(Task, metaclass=OtherTaskMetaclass): pass
93 class OtherTask(Intermediate, Base):
94 __tablename__ = "othertask"
96 ... and it fails because OtherTaskMetaclass calls DeclarativeMeta.__init__
97 and this tries to register "Intermediate" with the SQLALchemy ORM.
99 So, it's clear that OtherTaskMetaclass shouldn't derive from
100 DeclarativeMeta. But if we make it derive from "object" instead, we get
101 the error
103 .. :code-block:: none
105 TypeError: metaclass conflict: the metaclass of a derived class must
106 be a (non-strict) subclass of the metaclasses of all its bases
108 because OtherTask inherits from Base, whose metaclass is DeclarativeMeta,
109 but there is another metaclass in the metaclass set that is incompatible
110 with this.
112 So, is solution that OtherTaskMetaclass should derive from "type" and then
113 to use CooperativeMeta (q.v.) for OtherTask?
115 No, that still seems to fail (and before any CooperativeMeta code is
116 called) -- possibly that framework is for Python 2 only.
118 See also
119 https://blog.ionelmc.ro/2015/02/09/understanding-python-metaclasses/
121 Alternative solution 1: make a new metaclass that pretends to inherit
122 from HadsMetaclass and DeclarativeMeta.
124 WENT WITH THIS ONE INITIALLY:
126 .. :code-block:: python
128 class HadsMetaclass(type): # METACLASS
129 def __init__(cls: Type['HadsBase'],
130 name: str,
131 bases: Tuple[Type, ...],
132 classdict: Dict[str, Any]) -> None:
133 add_multiple_columns(...)
135 class HadsBase(TaskHasPatientMixin, Task, # INTERMEDIATE
136 metaclass=HadsMetaclass):
137 ...
139 class HadsBlendedMetaclass(HadsMetaclass, DeclarativeMeta): # ODDITY
140 # noinspection PyInitNewSignature
141 def __init__(cls: Type[Union[HadsBase, DeclarativeMeta]],
142 name: str,
143 bases: Tuple[Type, ...],
144 classdict: Dict[str, Any]) -> None:
145 HadsMetaclass.__init__(cls, name, bases, classdict)
146 # ... will call DeclarativeMeta.__init__ via its
147 # super().__init__()
149 class Hads(HadsBase, # ACTUAL TASK
150 metaclass=HadsBlendedMetaclass):
151 __tablename__ = "hads"
153 Alternative solution 2: continue to have the HadsMetaclass deriving from
154 DeclarativeMeta, but add it in at the last stage.
156 IGNORE THIS, NO LONGER TRUE:
158 - ALL THIS SOMEWHAT REVISED to handle SQLAlchemy concrete inheritance
159 (q.v.), with the rule that "the only things that inherit from Task are
160 actual tasks"; Task then inherits from both AbstractConcreteBase and
161 Base.
163 SEE ALSO sqla_database_structure.txt
165 FINAL ANSWER:
167 - classes inherit in a neat chain from Base -> [+/- Task -> ...]
168 - metaclasses inherit in a neat chain from DeclarativeMeta
169 - abstract intermediates mark themselves with "__abstract__ = True"
171 .. :code-block:: python
173 class HadsMetaclass(DeclarativeMeta): # METACLASS
174 def __init__(cls: Type['HadsBase'],
175 name: str,
176 bases: Tuple[Type, ...],
177 classdict: Dict[str, Any]) -> None:
178 add_multiple_columns(...)
180 class HadsBase(TaskHasPatientMixin, Task, # INTERMEDIATE
181 metaclass=HadsMetaclass):
182 __abstract__ = True
184 class Hads(HadsBase):
185 __tablename__ = "hads"
187 Yes, that's it. (Note that if you erroneously also add
188 "metaclass=HadsMetaclass" on Hads, you get: "TypeError: metaclass conflict:
189 the metaclass of a derived class must be a (non-strict) subclass of the
190 metaclasses of all its bases.")
192 UPDATE 2019-07-28:
194 - To fix "class must implement all abstract methods" warning from PyCharm,
195 add "ABCMeta" to superclass list of HadsMetaclass.
197 """
198 # noinspection PyInitNewSignature
199 def __init__(cls: Type['HadsBase'],
200 name: str,
201 bases: Tuple[Type, ...],
202 classdict: Dict[str, Any]) -> None:
203 add_multiple_columns(
204 cls, "q", 1, cls.NQUESTIONS,
205 minimum=0, maximum=3,
206 comment_fmt="Q{n}: {s} (0-3)",
207 comment_strings=[
208 "tense", "enjoy usual", "apprehensive", "laugh", "worry",
209 "cheerful", "relaxed", "slow", "butterflies", "appearance",
210 "restless", "anticipate", "panic", "book/TV/radio"
211 ]
212 )
213 super().__init__(name, bases, classdict)
216class HadsBase(TaskHasPatientMixin, Task, ABC,
217 metaclass=HadsMetaclass):
218 """
219 Server implementation of the HADS task.
220 """
221 __abstract__ = True
222 provides_trackers = True
224 NQUESTIONS = 14
225 ANXIETY_QUESTIONS = [1, 3, 5, 7, 9, 11, 13]
226 DEPRESSION_QUESTIONS = [2, 4, 6, 8, 10, 12, 14]
227 TASK_FIELDS = strseq("q", 1, NQUESTIONS)
228 MAX_ANX_SCORE = 21
229 MAX_DEP_SCORE = 21
231 def is_complete(self) -> bool:
232 return (
233 self.field_contents_valid() and
234 self.all_fields_not_none(self.TASK_FIELDS)
235 )
237 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
238 return [
239 TrackerInfo(
240 value=self.anxiety_score(),
241 plot_label="HADS anxiety score",
242 axis_label=f"Anxiety score (out of {self.MAX_ANX_SCORE})",
243 axis_min=-0.5,
244 axis_max=self.MAX_ANX_SCORE + 0.5,
245 ),
246 TrackerInfo(
247 value=self.depression_score(),
248 plot_label="HADS depression score",
249 axis_label=f"Depression score (out of {self.MAX_DEP_SCORE})",
250 axis_min=-0.5,
251 axis_max=self.MAX_DEP_SCORE + 0.5
252 ),
253 ]
255 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
256 if not self.is_complete():
257 return CTV_INCOMPLETE
258 return [CtvInfo(content=(
259 f"anxiety score {self.anxiety_score()}/{self.MAX_ANX_SCORE}, "
260 f"depression score {self.depression_score()}/{self.MAX_DEP_SCORE}"
261 ))]
263 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
264 return self.standard_task_summary_fields() + [
265 SummaryElement(
266 name="anxiety", coltype=Integer(),
267 value=self.anxiety_score(),
268 comment=f"Anxiety score (/{self.MAX_ANX_SCORE})"),
269 SummaryElement(
270 name="depression", coltype=Integer(),
271 value=self.depression_score(),
272 comment=f"Depression score (/{self.MAX_DEP_SCORE})"),
273 ]
275 def score(self, questions: List[int]) -> int:
276 fields = self.fieldnames_from_list("q", questions)
277 return self.sum_fields(fields)
279 def anxiety_score(self) -> int:
280 return self.score(self.ANXIETY_QUESTIONS)
282 def depression_score(self) -> int:
283 return self.score(self.DEPRESSION_QUESTIONS)
285 def get_task_html(self, req: CamcopsRequest) -> str:
286 min_score = 0
287 max_score = 3
288 crippled = not self.extrastrings_exist(req)
289 a = self.anxiety_score()
290 d = self.depression_score()
291 h = f"""
292 <div class="{CssClass.SUMMARY}">
293 <table class="{CssClass.SUMMARY}">
294 {self.get_is_complete_tr(req)}
295 <tr>
296 <td>{req.wappstring(AS.HADS_ANXIETY_SCORE)}</td>
297 <td>{answer(a)} / {self.MAX_ANX_SCORE}</td>
298 </tr>
299 <tr>
300 <td>{req.wappstring(AS.HADS_DEPRESSION_SCORE)}</td>
301 <td>{answer(d)} / 21</td>
302 </tr>
303 </table>
304 </div>
305 <div class="{CssClass.EXPLANATION}">
306 All questions are scored from 0–3
307 (0 least symptomatic, 3 most symptomatic).
308 </div>
309 <table class="{CssClass.TASKDETAIL}">
310 <tr>
311 <th width="50%">Question</th>
312 <th width="50%">Answer</th>
313 </tr>
314 """
315 for n in range(1, self.NQUESTIONS + 1):
316 if crippled:
317 q = f"HADS: Q{n}"
318 else:
319 q = f"Q{n}. {self.wxstring(req, f'q{n}_stem')}"
320 if n in self.ANXIETY_QUESTIONS:
321 q += " (A)"
322 if n in self.DEPRESSION_QUESTIONS:
323 q += " (D)"
324 v = getattr(self, "q" + str(n))
325 if crippled or v is None or v < min_score or v > max_score:
326 a = v
327 else:
328 a = f"{v}: {self.wxstring(req, f'q{n}_a{v}')}"
329 h += tr_qa(q, a)
330 h += """
331 </table>
332 """ + DATA_COLLECTION_UNLESS_UPGRADED_DIV
333 return h
336# =============================================================================
337# Hads
338# =============================================================================
340class Hads(HadsBase):
341 __tablename__ = "hads"
342 shortname = "HADS"
344 @staticmethod
345 def longname(req: "CamcopsRequest") -> str:
346 _ = req.gettext
347 return _(
348 "Hospital Anxiety and Depression Scale (data collection only)")
350 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
351 codes = [SnomedExpression(req.snomed(SnomedLookup.HADS_PROCEDURE_ASSESSMENT))] # noqa
352 if self.is_complete():
353 codes.append(SnomedExpression(
354 req.snomed(SnomedLookup.HADS_SCALE),
355 {
356 req.snomed(SnomedLookup.HADS_ANXIETY_SCORE): self.anxiety_score(), # noqa
357 req.snomed(SnomedLookup.HADS_DEPRESSION_SCORE): self.depression_score(), # noqa
358 }
359 ))
360 return codes
363# =============================================================================
364# HadsRespondent
365# =============================================================================
367class HadsRespondent(TaskHasRespondentMixin, HadsBase):
368 __tablename__ = "hads_respondent"
369 shortname = "HADS-Respondent"
370 extrastring_taskname = "hads"
372 @staticmethod
373 def longname(req: "CamcopsRequest") -> str:
374 _ = req.gettext
375 return _("Hospital Anxiety and Depression Scale (data collection "
376 "only), non-patient respondent version")
378 # No SNOMED codes; not for the patient!