Hide keyboard shortcuts

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 

2 

3""" 

4camcops_server/tasks/hads.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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. 

16 

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. 

21 

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

24 

25=============================================================================== 

26 

27""" 

28 

29from abc import ABC, ABCMeta 

30import logging 

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

32 

33from cardinal_pythonlib.logs import BraceStyleAdapter 

34from cardinal_pythonlib.stringfunc import strseq 

35from sqlalchemy.ext.declarative import DeclarativeMeta 

36from sqlalchemy.sql.sqltypes import Integer 

37 

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 

55 

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

57 

58 

59# ============================================================================= 

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

61# ============================================================================= 

62 

63class HadsMetaclass(DeclarativeMeta, ABCMeta): 

64 """ 

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

66 

67 This works: 

68 

69 .. :code-block:: python 

70 

71 class MyTaskMetaclass(DeclarativeMeta): 

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

73 # do useful stuff 

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

75 

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

77 __tablename__ = "mytask" 

78 

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: 

83 

84 .. :code-block:: python 

85 

86 class OtherTaskMetaclass(DeclarativeMeta): 

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

88 # do useful stuff 

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

90 

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

92 

93 class OtherTask(Intermediate, Base): 

94 __tablename__ = "othertask" 

95 

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

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

98 

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 

102 

103 .. :code-block:: none 

104 

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

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

107 

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. 

111 

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

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

114 

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

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

117 

118 See also 

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

120 

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

122 from HadsMetaclass and DeclarativeMeta. 

123 

124 WENT WITH THIS ONE INITIALLY: 

125 

126 .. :code-block:: python 

127 

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

134 

135 class HadsBase(TaskHasPatientMixin, Task, # INTERMEDIATE 

136 metaclass=HadsMetaclass): 

137 ... 

138 

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

148 

149 class Hads(HadsBase, # ACTUAL TASK 

150 metaclass=HadsBlendedMetaclass): 

151 __tablename__ = "hads" 

152 

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

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

155 

156 IGNORE THIS, NO LONGER TRUE: 

157 

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. 

162 

163 SEE ALSO sqla_database_structure.txt 

164 

165 FINAL ANSWER: 

166 

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" 

170 

171 .. :code-block:: python 

172 

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

179 

180 class HadsBase(TaskHasPatientMixin, Task, # INTERMEDIATE 

181 metaclass=HadsMetaclass): 

182 __abstract__ = True 

183 

184 class Hads(HadsBase): 

185 __tablename__ = "hads" 

186 

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

191 

192 UPDATE 2019-07-28: 

193 

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

195 add "ABCMeta" to superclass list of HadsMetaclass. 

196 

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) 

214 

215 

216class HadsBase(TaskHasPatientMixin, Task, ABC, 

217 metaclass=HadsMetaclass): 

218 """ 

219 Server implementation of the HADS task. 

220 """ 

221 __abstract__ = True 

222 provides_trackers = True 

223 

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 

230 

231 def is_complete(self) -> bool: 

232 return ( 

233 self.field_contents_valid() and 

234 self.all_fields_not_none(self.TASK_FIELDS) 

235 ) 

236 

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 ] 

254 

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 ))] 

262 

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 ] 

274 

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

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

277 return self.sum_fields(fields) 

278 

279 def anxiety_score(self) -> int: 

280 return self.score(self.ANXIETY_QUESTIONS) 

281 

282 def depression_score(self) -> int: 

283 return self.score(self.DEPRESSION_QUESTIONS) 

284 

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 

334 

335 

336# ============================================================================= 

337# Hads 

338# ============================================================================= 

339 

340class Hads(HadsBase): 

341 __tablename__ = "hads" 

342 shortname = "HADS" 

343 

344 @staticmethod 

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

346 _ = req.gettext 

347 return _( 

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

349 

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 

361 

362 

363# ============================================================================= 

364# HadsRespondent 

365# ============================================================================= 

366 

367class HadsRespondent(TaskHasRespondentMixin, HadsBase): 

368 __tablename__ = "hads_respondent" 

369 shortname = "HADS-Respondent" 

370 extrastring_taskname = "hads" 

371 

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

377 

378 # No SNOMED codes; not for the patient!