Coverage for tasks/ybocs.py: 61%

128 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/ybocs.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 typing import Any, Dict, List, Tuple, Type 

31 

32from cardinal_pythonlib.stringfunc import strseq 

33from sqlalchemy.ext.declarative import DeclarativeMeta 

34from sqlalchemy.sql.schema import Column 

35from sqlalchemy.sql.sqltypes import Boolean, Integer, UnicodeText 

36 

37from camcops_server.cc_modules.cc_constants import ( 

38 CssClass, 

39 DATA_COLLECTION_UNLESS_UPGRADED_DIV, 

40) 

41from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

42from camcops_server.cc_modules.cc_html import ( 

43 answer, 

44 get_ternary, 

45 subheading_spanning_four_columns, 

46 tr, 

47) 

48from camcops_server.cc_modules.cc_request import CamcopsRequest 

49from camcops_server.cc_modules.cc_sqla_coltypes import ( 

50 BIT_CHECKER, 

51 CamcopsColumn, 

52 PermittedValueChecker, 

53) 

54from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

55from camcops_server.cc_modules.cc_task import ( 

56 Task, 

57 TaskHasClinicianMixin, 

58 TaskHasPatientMixin, 

59) 

60from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

61 

62 

63# ============================================================================= 

64# Y-BOCS 

65# ============================================================================= 

66 

67 

68class YbocsMetaclass(DeclarativeMeta): 

69 # noinspection PyInitNewSignature 

70 def __init__( 

71 cls: Type["Ybocs"], 

72 name: str, 

73 bases: Tuple[Type, ...], 

74 classdict: Dict[str, Any], 

75 ) -> None: 

76 cls.TARGET_COLUMNS = [] # type: List[Column] 

77 for target in ("obsession", "compulsion", "avoidance"): 

78 for n in range(1, cls.NTARGETS + 1): 

79 fname = f"target_{target}_{n}" 

80 col = Column( 

81 fname, 

82 UnicodeText, 

83 comment=f"Target symptoms: {target} {n}", 

84 ) 

85 setattr(cls, fname, col) 

86 cls.TARGET_COLUMNS.append(col) 

87 for qnumstr, maxscore, comment in cls.QINFO: 

88 fname = "q" + qnumstr 

89 setattr( 

90 cls, 

91 fname, 

92 CamcopsColumn( 

93 fname, 

94 Integer, 

95 permitted_value_checker=PermittedValueChecker( 

96 minimum=0, maximum=maxscore 

97 ), 

98 comment=f"Q{qnumstr}, {comment} " 

99 f"(0-{maxscore}, higher worse)", 

100 ), 

101 ) 

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

103 

104 

105class Ybocs( 

106 TaskHasClinicianMixin, TaskHasPatientMixin, Task, metaclass=YbocsMetaclass 

107): 

108 """ 

109 Server implementation of the Y-BOCS task. 

110 """ 

111 

112 __tablename__ = "ybocs" 

113 shortname = "Y-BOCS" 

114 provides_trackers = True 

115 

116 NTARGETS = 3 

117 QINFO = [ # number, max score, minimal comment 

118 ("1", 4, "obsessions: time"), 

119 ("1b", 4, "obsessions: obsession-free interval"), 

120 ("2", 4, "obsessions: interference"), 

121 ("3", 4, "obsessions: distress"), 

122 ("4", 4, "obsessions: resistance"), 

123 ("5", 4, "obsessions: control"), 

124 ("6", 4, "compulsions: time"), 

125 ("6b", 4, "compulsions: compulsion-free interval"), 

126 ("7", 4, "compulsions: interference"), 

127 ("8", 4, "compulsions: distress"), 

128 ("9", 4, "compulsions: resistance"), 

129 ("10", 4, "compulsions: control"), 

130 ("11", 4, "insight"), 

131 ("12", 4, "avoidance"), 

132 ("13", 4, "indecisiveness"), 

133 ("14", 4, "overvalued responsibility"), 

134 ("15", 4, "slowness"), 

135 ("16", 4, "doubting"), 

136 ("17", 6, "global severity"), 

137 ("18", 6, "global improvement"), 

138 ("19", 3, "reliability"), 

139 ] 

140 QUESTION_FIELDS = ["q" + x[0] for x in QINFO] 

141 SCORED_QUESTIONS = strseq("q", 1, 10) 

142 OBSESSION_QUESTIONS = strseq("q", 1, 5) 

143 COMPULSION_QUESTIONS = strseq("q", 6, 10) 

144 MAX_TOTAL = 40 

145 MAX_OBS = 20 

146 MAX_COM = 20 

147 

148 @staticmethod 

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

150 _ = req.gettext 

151 return _("Yale–Brown Obsessive Compulsive Scale") 

152 

153 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

154 return [ 

155 TrackerInfo( 

156 value=self.total_score(), 

157 plot_label="Y-BOCS total score (lower is better)", 

158 axis_label=f"Total score (out of {self.MAX_TOTAL})", 

159 axis_min=-0.5, 

160 axis_max=self.MAX_TOTAL + 0.5, 

161 ), 

162 TrackerInfo( 

163 value=self.obsession_score(), 

164 plot_label="Y-BOCS obsession score (lower is better)", 

165 axis_label=f"Total score (out of {self.MAX_OBS})", 

166 axis_min=-0.5, 

167 axis_max=self.MAX_OBS + 0.5, 

168 ), 

169 TrackerInfo( 

170 value=self.compulsion_score(), 

171 plot_label="Y-BOCS compulsion score (lower is better)", 

172 axis_label=f"Total score (out of {self.MAX_COM})", 

173 axis_min=-0.5, 

174 axis_max=self.MAX_COM + 0.5, 

175 ), 

176 ] 

177 

178 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

179 return self.standard_task_summary_fields() + [ 

180 SummaryElement( 

181 name="total_score", 

182 coltype=Integer(), 

183 value=self.total_score(), 

184 comment=f"Total score (/ {self.MAX_TOTAL})", 

185 ), 

186 SummaryElement( 

187 name="obsession_score", 

188 coltype=Integer(), 

189 value=self.obsession_score(), 

190 comment=f"Obsession score (/ {self.MAX_OBS})", 

191 ), 

192 SummaryElement( 

193 name="compulsion_score", 

194 coltype=Integer(), 

195 value=self.compulsion_score(), 

196 comment=f"Compulsion score (/ {self.MAX_COM})", 

197 ), 

198 ] 

199 

200 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

201 if not self.is_complete(): 

202 return CTV_INCOMPLETE 

203 t = self.total_score() 

204 o = self.obsession_score() 

205 c = self.compulsion_score() 

206 return [ 

207 CtvInfo( 

208 content=( 

209 "Y-BOCS total score {t}/{mt} (obsession {o}/{mo}, " 

210 "compulsion {c}/{mc})".format( 

211 t=t, 

212 mt=self.MAX_TOTAL, 

213 o=o, 

214 mo=self.MAX_OBS, 

215 c=c, 

216 mc=self.MAX_COM, 

217 ) 

218 ) 

219 ) 

220 ] 

221 

222 def total_score(self) -> int: 

223 return self.sum_fields(self.SCORED_QUESTIONS) 

224 

225 def obsession_score(self) -> int: 

226 return self.sum_fields(self.OBSESSION_QUESTIONS) 

227 

228 def compulsion_score(self) -> int: 

229 return self.sum_fields(self.COMPULSION_QUESTIONS) 

230 

231 def is_complete(self) -> bool: 

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

233 self.SCORED_QUESTIONS 

234 ) 

235 

236 def get_task_html(self, req: CamcopsRequest) -> str: 

237 target_symptoms = "" 

238 for col in self.TARGET_COLUMNS: 

239 target_symptoms += tr(col.comment, answer(getattr(self, col.name))) 

240 q_a = "" 

241 for qi in self.QINFO: 

242 fieldname = "q" + qi[0] 

243 value = getattr(self, fieldname) 

244 q_a += tr( 

245 self.wxstring(req, fieldname + "_title"), 

246 answer( 

247 self.wxstring(req, fieldname + "_a" + str(value), value) 

248 if value is not None 

249 else None 

250 ), 

251 ) 

252 return f""" 

253 <div class="{CssClass.SUMMARY}"> 

254 <table class="{CssClass.SUMMARY}"> 

255 {self.get_is_complete_tr(req)} 

256 <tr> 

257 <td>Total score</td> 

258 <td>{answer(self.total_score())} / 

259 {self.MAX_TOTAL}</td> 

260 </td> 

261 <tr> 

262 <td>Obsession score</td> 

263 <td>{answer(self.obsession_score())} / 

264 {self.MAX_OBS}</td> 

265 </td> 

266 <tr> 

267 <td>Compulsion score</td> 

268 <td>{answer(self.compulsion_score())} / 

269 {self.MAX_COM}</td> 

270 </td> 

271 </table> 

272 </div> 

273 <table class="{CssClass.TASKDETAIL}"> 

274 <tr> 

275 <th width="50%">Target symptom</th> 

276 <th width="50%">Detail</th> 

277 </tr> 

278 {target_symptoms} 

279 </table> 

280 <table class="{CssClass.TASKDETAIL}"> 

281 <tr> 

282 <th width="50%">Question</th> 

283 <th width="50%">Answer</th> 

284 </tr> 

285 {q_a} 

286 </table> 

287 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

288 """ 

289 

290 

291# ============================================================================= 

292# Y-BOCS-SC 

293# ============================================================================= 

294 

295 

296class YbocsScMetaclass(DeclarativeMeta): 

297 # noinspection PyInitNewSignature 

298 def __init__( 

299 cls: Type["YbocsSc"], 

300 name: str, 

301 bases: Tuple[Type, ...], 

302 classdict: Dict[str, Any], 

303 ) -> None: 

304 for item in cls.ITEMS: 

305 setattr( 

306 cls, 

307 item + cls.SUFFIX_CURRENT, 

308 CamcopsColumn( 

309 item + cls.SUFFIX_CURRENT, 

310 Boolean, 

311 permitted_value_checker=BIT_CHECKER, 

312 comment=item + " (current symptom)", 

313 ), 

314 ) 

315 setattr( 

316 cls, 

317 item + cls.SUFFIX_PAST, 

318 CamcopsColumn( 

319 item + cls.SUFFIX_PAST, 

320 Boolean, 

321 permitted_value_checker=BIT_CHECKER, 

322 comment=item + " (past symptom)", 

323 ), 

324 ) 

325 setattr( 

326 cls, 

327 item + cls.SUFFIX_PRINCIPAL, 

328 CamcopsColumn( 

329 item + cls.SUFFIX_PRINCIPAL, 

330 Boolean, 

331 permitted_value_checker=BIT_CHECKER, 

332 comment=item + " (principal symptom)", 

333 ), 

334 ) 

335 if item.endswith(cls.SUFFIX_OTHER): 

336 setattr( 

337 cls, 

338 item + cls.SUFFIX_DETAIL, 

339 Column( 

340 item + cls.SUFFIX_DETAIL, 

341 UnicodeText, 

342 comment=item + " (details)", 

343 ), 

344 ) 

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

346 

347 

348class YbocsSc( 

349 TaskHasClinicianMixin, 

350 TaskHasPatientMixin, 

351 Task, 

352 metaclass=YbocsScMetaclass, 

353): 

354 """ 

355 Server implementation of the Y-BOCS-SC task. 

356 """ 

357 

358 __tablename__ = "ybocssc" 

359 shortname = "Y-BOCS-SC" 

360 extrastring_taskname = "ybocs" # shares with Y-BOCS 

361 info_filename_stem = extrastring_taskname 

362 

363 SC_PREFIX = "sc_" 

364 SUFFIX_CURRENT = "_current" 

365 SUFFIX_PAST = "_past" 

366 SUFFIX_PRINCIPAL = "_principal" 

367 SUFFIX_OTHER = "_other" 

368 SUFFIX_DETAIL = "_detail" 

369 GROUPS = [ 

370 "obs_aggressive", 

371 "obs_contamination", 

372 "obs_sexual", 

373 "obs_hoarding", 

374 "obs_religious", 

375 "obs_symmetry", 

376 "obs_misc", 

377 "obs_somatic", 

378 "com_cleaning", 

379 "com_checking", 

380 "com_repeat", 

381 "com_counting", 

382 "com_arranging", 

383 "com_hoarding", 

384 "com_misc", 

385 ] 

386 ITEMS = [ 

387 "obs_aggressive_harm_self", 

388 "obs_aggressive_harm_others", 

389 "obs_aggressive_imagery", 

390 "obs_aggressive_obscenities", 

391 "obs_aggressive_embarrassing", 

392 "obs_aggressive_impulses", 

393 "obs_aggressive_steal", 

394 "obs_aggressive_accident", 

395 "obs_aggressive_responsible", 

396 "obs_aggressive_other", 

397 "obs_contamination_bodily_waste", 

398 "obs_contamination_dirt", 

399 "obs_contamination_environmental", 

400 "obs_contamination_household", 

401 "obs_contamination_animals", 

402 "obs_contamination_sticky", 

403 "obs_contamination_ill", 

404 "obs_contamination_others_ill", 

405 "obs_contamination_feeling", 

406 "obs_contamination_other", 

407 "obs_sexual_forbidden", 

408 "obs_sexual_children_incest", 

409 "obs_sexual_homosexuality", 

410 "obs_sexual_to_others", 

411 "obs_sexual_other", 

412 "obs_hoarding_other", 

413 "obs_religious_sacrilege", 

414 "obs_religious_morality", 

415 "obs_religious_other", 

416 "obs_symmetry_with_magical", 

417 "obs_symmetry_without_magical", 

418 "obs_misc_know_remember", 

419 "obs_misc_fear_saying", 

420 "obs_misc_fear_not_saying", 

421 "obs_misc_fear_losing", 

422 "obs_misc_intrusive_nonviolent_images", 

423 "obs_misc_intrusive_sounds", 

424 "obs_misc_bothered_sounds", 

425 "obs_misc_numbers", 

426 "obs_misc_colours", 

427 "obs_misc_superstitious", 

428 "obs_misc_other", 

429 "obs_somatic_illness", 

430 "obs_somatic_appearance", 

431 "obs_somatic_other", 

432 "com_cleaning_handwashing", 

433 "com_cleaning_toileting", 

434 "com_cleaning_cleaning_items", 

435 "com_cleaning_other_contaminant_avoidance", 

436 "com_cleaning_other", 

437 "com_checking_locks_appliances", 

438 "com_checking_not_harm_others", 

439 "com_checking_not_harm_self", 

440 "com_checking_nothing_bad_happens", 

441 "com_checking_no_mistake", 

442 "com_checking_somatic", 

443 "com_checking_other", 

444 "com_repeat_reread_rewrite", 

445 "com_repeat_routine", 

446 "com_repeat_other", 

447 "com_counting_other", 

448 "com_arranging_other", 

449 "com_hoarding_other", 

450 "com_misc_mental_rituals", 

451 "com_misc_lists", 

452 "com_misc_tell_ask", 

453 "com_misc_touch", 

454 "com_misc_blink_stare", 

455 "com_misc_prevent_harm_self", 

456 "com_misc_prevent_harm_others", 

457 "com_misc_prevent_terrible", 

458 "com_misc_eating_ritual", 

459 "com_misc_superstitious", 

460 "com_misc_trichotillomania", 

461 "com_misc_self_harm", 

462 "com_misc_other", 

463 ] 

464 

465 @staticmethod 

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

467 _ = req.gettext 

468 return _("Y-BOCS Symptom Checklist") 

469 

470 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

471 if not self.is_complete(): 

472 return CTV_INCOMPLETE 

473 current_list = [] 

474 past_list = [] 

475 principal_list = [] 

476 for item in self.ITEMS: 

477 if getattr(self, item + self.SUFFIX_CURRENT): 

478 current_list.append(item) 

479 if getattr(self, item + self.SUFFIX_PAST): 

480 past_list.append(item) 

481 if getattr(self, item + self.SUFFIX_PRINCIPAL): 

482 principal_list.append(item) 

483 return [ 

484 CtvInfo(content=f"Current symptoms: {', '.join(current_list)}"), 

485 CtvInfo(content=f"Past symptoms: {', '.join(past_list)}"), 

486 CtvInfo( 

487 content=f"Principal symptoms: {', '.join(principal_list)}" 

488 ), 

489 ] 

490 

491 # noinspection PyMethodOverriding 

492 @staticmethod 

493 def is_complete() -> bool: 

494 return True 

495 

496 def get_task_html(self, req: CamcopsRequest) -> str: 

497 h = f""" 

498 <table class="{CssClass.TASKDETAIL}"> 

499 <tr> 

500 <th width="55%">Symptom</th> 

501 <th width="15%">Current</th> 

502 <th width="15%">Past</th> 

503 <th width="15%">Principal</th> 

504 </tr> 

505 """ 

506 for group in self.GROUPS: 

507 h += subheading_spanning_four_columns( 

508 self.wxstring(req, self.SC_PREFIX + group) 

509 ) 

510 for item in self.ITEMS: 

511 if not item.startswith(group): 

512 continue 

513 h += tr( 

514 self.wxstring(req, self.SC_PREFIX + item), 

515 answer( 

516 get_ternary( 

517 getattr(self, item + self.SUFFIX_CURRENT), 

518 value_true="Current", 

519 value_false="", 

520 value_none="", 

521 ) 

522 ), 

523 answer( 

524 get_ternary( 

525 getattr(self, item + self.SUFFIX_PAST), 

526 value_true="Past", 

527 value_false="", 

528 value_none="", 

529 ) 

530 ), 

531 answer( 

532 get_ternary( 

533 getattr(self, item + self.SUFFIX_PRINCIPAL), 

534 value_true="Principal", 

535 value_false="", 

536 value_none="", 

537 ) 

538 ), 

539 ) 

540 if item.endswith(self.SUFFIX_OTHER): 

541 h += f""" 

542 <tr> 

543 <td><i>Specify:</i></td> 

544 <td colspan="3">{ 

545 answer(getattr(self, item + self.SUFFIX_DETAIL), "")}</td> 

546 </tr> 

547 """ 

548 h += f""" 

549 </table> 

550 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

551 """ 

552 return h