Coverage for tasks/hads.py: 58%

92 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/hads.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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/>. 

25 

26=============================================================================== 

27 

28""" 

29 

30from abc import ABC, ABCMeta 

31import logging 

32from typing import Any, Dict, List, Tuple, Type 

33 

34from cardinal_pythonlib.logs import BraceStyleAdapter 

35from cardinal_pythonlib.stringfunc import strseq 

36from sqlalchemy.ext.declarative import DeclarativeMeta 

37from sqlalchemy.sql.sqltypes import Integer 

38 

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 

56 

57log = BraceStyleAdapter(logging.getLogger(__name__)) 

58 

59 

60# ============================================================================= 

61# HADS (crippled unless upgraded locally) - base classes 

62# ============================================================================= 

63 

64 

65class HadsMetaclass(DeclarativeMeta, ABCMeta): 

66 """ 

67 We can't make this metaclass inherit from DeclarativeMeta. 

68 

69 This works: 

70 

71 .. :code-block:: python 

72 

73 class MyTaskMetaclass(DeclarativeMeta): 

74 def __init__(cls, name, bases, classdict): 

75 # do useful stuff 

76 super().__init__(name, bases, classdict) 

77 

78 class MyTask(Task, Base, metaclass=MyTaskMetaclass): 

79 __tablename__ = "mytask" 

80 

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: 

85 

86 .. :code-block:: python 

87 

88 class OtherTaskMetaclass(DeclarativeMeta): 

89 def __init__(cls, name, bases, classdict): 

90 # do useful stuff 

91 super().__init__(name, bases, classdict) 

92 

93 class Intermediate(Task, metaclass=OtherTaskMetaclass): pass 

94 

95 class OtherTask(Intermediate, Base): 

96 __tablename__ = "othertask" 

97 

98 ... and it fails because OtherTaskMetaclass calls DeclarativeMeta.__init__ 

99 and this tries to register "Intermediate" with the SQLALchemy ORM. 

100 

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 

104 

105 .. :code-block:: none 

106 

107 TypeError: metaclass conflict: the metaclass of a derived class must 

108 be a (non-strict) subclass of the metaclasses of all its bases 

109 

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. 

113 

114 So, is solution that OtherTaskMetaclass should derive from "type" and then 

115 to use CooperativeMeta (q.v.) for OtherTask? 

116 

117 No, that still seems to fail (and before any CooperativeMeta code is 

118 called) -- possibly that framework is for Python 2 only. 

119 

120 See also 

121 https://blog.ionelmc.ro/2015/02/09/understanding-python-metaclasses/ 

122 

123 Alternative solution 1: make a new metaclass that pretends to inherit 

124 from HadsMetaclass and DeclarativeMeta. 

125 

126 WENT WITH THIS ONE INITIALLY: 

127 

128 .. :code-block:: python 

129 

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(...) 

136 

137 class HadsBase(TaskHasPatientMixin, Task, # INTERMEDIATE 

138 metaclass=HadsMetaclass): 

139 ... 

140 

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__() 

150 

151 class Hads(HadsBase, # ACTUAL TASK 

152 metaclass=HadsBlendedMetaclass): 

153 __tablename__ = "hads" 

154 

155 Alternative solution 2: continue to have the HadsMetaclass deriving from 

156 DeclarativeMeta, but add it in at the last stage. 

157 

158 IGNORE THIS, NO LONGER TRUE: 

159 

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. 

164 

165 SEE ALSO sqla_database_structure.txt 

166 

167 FINAL ANSWER: 

168 

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" 

172 

173 .. :code-block:: python 

174 

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(...) 

181 

182 class HadsBase(TaskHasPatientMixin, Task, # INTERMEDIATE 

183 metaclass=HadsMetaclass): 

184 __abstract__ = True 

185 

186 class Hads(HadsBase): 

187 __tablename__ = "hads" 

188 

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.") 

193 

194 UPDATE 2019-07-28: 

195 

196 - To fix "class must implement all abstract methods" warning from PyCharm, 

197 add "ABCMeta" to superclass list of HadsMetaclass. 

198 

199 """ 

200 

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) 

234 

235 

236class HadsBase(TaskHasPatientMixin, Task, ABC, metaclass=HadsMetaclass): 

237 """ 

238 Server implementation of the HADS task. 

239 """ 

240 

241 __abstract__ = True 

242 provides_trackers = True 

243 

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 

250 

251 def is_complete(self) -> bool: 

252 return self.field_contents_valid() and self.all_fields_not_none( 

253 self.TASK_FIELDS 

254 ) 

255 

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 ] 

273 

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 ] 

287 

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 ] 

303 

304 def score(self, questions: List[int]) -> int: 

305 fields = self.fieldnames_from_list("q", questions) 

306 return self.sum_fields(fields) 

307 

308 def anxiety_score(self) -> int: 

309 return self.score(self.ANXIETY_QUESTIONS) 

310 

311 def depression_score(self) -> int: 

312 return self.score(self.DEPRESSION_QUESTIONS) 

313 

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 

366 

367 

368# ============================================================================= 

369# Hads 

370# ============================================================================= 

371 

372 

373class Hads(HadsBase): 

374 __tablename__ = "hads" 

375 shortname = "HADS" 

376 

377 @staticmethod 

378 def longname(req: "CamcopsRequest") -> str: 

379 _ = req.gettext 

380 return _( 

381 "Hospital Anxiety and Depression Scale (data collection only)" 

382 ) 

383 

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 

405 

406 

407# ============================================================================= 

408# HadsRespondent 

409# ============================================================================= 

410 

411 

412class HadsRespondent(TaskHasRespondentMixin, HadsBase): 

413 __tablename__ = "hads_respondent" 

414 shortname = "HADS-Respondent" 

415 extrastring_taskname = "hads" 

416 info_filename_stem = extrastring_taskname 

417 

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 ) 

425 

426 # No SNOMED codes; not for the patient!