Coverage for tasks/ybocs.py: 60%

124 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/tasks/ybocs.py 

3 

4=============================================================================== 

5 

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

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

8 

9 This file is part of CamCOPS. 

10 

11 CamCOPS is free software: you can redistribute it and/or modify 

12 it under the terms of the GNU General Public License as published by 

13 the Free Software Foundation, either version 3 of the License, or 

14 (at your option) any later version. 

15 

16 CamCOPS is distributed in the hope that it will be useful, 

17 but WITHOUT ANY WARRANTY; without even the implied warranty of 

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

21 You should have received a copy of the GNU General Public License 

22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

23 

24=============================================================================== 

25 

26""" 

27 

28from typing import Any, cast, List, Type 

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.sql.schema import Column 

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

33 

34from camcops_server.cc_modules.cc_constants import ( 

35 CssClass, 

36 DATA_COLLECTION_UNLESS_UPGRADED_DIV, 

37) 

38from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

39from camcops_server.cc_modules.cc_html import ( 

40 answer, 

41 get_ternary, 

42 subheading_spanning_four_columns, 

43 tr, 

44) 

45from camcops_server.cc_modules.cc_request import CamcopsRequest 

46from camcops_server.cc_modules.cc_sqla_coltypes import ( 

47 BIT_CHECKER, 

48 camcops_column, 

49 PermittedValueChecker, 

50) 

51from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

52from camcops_server.cc_modules.cc_task import ( 

53 Task, 

54 TaskHasClinicianMixin, 

55 TaskHasPatientMixin, 

56) 

57from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

58 

59 

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

61# Y-BOCS 

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

63 

64 

65class Ybocs( # type: ignore[misc] 

66 TaskHasClinicianMixin, 

67 TaskHasPatientMixin, 

68 Task, 

69): 

70 """ 

71 Server implementation of the Y-BOCS task. 

72 """ 

73 

74 __tablename__ = "ybocs" 

75 shortname = "Y-BOCS" 

76 provides_trackers = True 

77 

78 NTARGETS = 3 

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

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

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

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

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

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

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

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

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

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

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

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

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

92 ("11", 4, "insight"), 

93 ("12", 4, "avoidance"), 

94 ("13", 4, "indecisiveness"), 

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

96 ("15", 4, "slowness"), 

97 ("16", 4, "doubting"), 

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

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

100 ("19", 3, "reliability"), 

101 ] 

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

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

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

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

106 MAX_TOTAL = 40 

107 MAX_OBS = 20 

108 MAX_COM = 20 

109 TARGET_COLUMNS: list[Column] = [] 

110 

111 @classmethod 

112 def extend_columns(cls: Type["Ybocs"], **kwargs: Any) -> None: 

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

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

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

116 col = Column( 

117 fname, 

118 UnicodeText, 

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

120 ) 

121 setattr(cls, fname, col) 

122 cls.TARGET_COLUMNS.append(col) 

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

124 fname = "q" + qnumstr 

125 setattr( 

126 cls, 

127 fname, 

128 camcops_column( 

129 fname, 

130 Integer, 

131 permitted_value_checker=PermittedValueChecker( 

132 minimum=0, maximum=maxscore 

133 ), 

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

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

136 ), 

137 ) 

138 

139 @staticmethod 

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

141 _ = req.gettext 

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

143 

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

145 return [ 

146 TrackerInfo( 

147 value=self.total_score(), 

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

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

150 axis_min=-0.5, 

151 axis_max=self.MAX_TOTAL + 0.5, 

152 ), 

153 TrackerInfo( 

154 value=self.obsession_score(), 

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

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

157 axis_min=-0.5, 

158 axis_max=self.MAX_OBS + 0.5, 

159 ), 

160 TrackerInfo( 

161 value=self.compulsion_score(), 

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

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

164 axis_min=-0.5, 

165 axis_max=self.MAX_COM + 0.5, 

166 ), 

167 ] 

168 

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

170 return self.standard_task_summary_fields() + [ 

171 SummaryElement( 

172 name="total_score", 

173 coltype=Integer(), 

174 value=self.total_score(), 

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

176 ), 

177 SummaryElement( 

178 name="obsession_score", 

179 coltype=Integer(), 

180 value=self.obsession_score(), 

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

182 ), 

183 SummaryElement( 

184 name="compulsion_score", 

185 coltype=Integer(), 

186 value=self.compulsion_score(), 

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

188 ), 

189 ] 

190 

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

192 if not self.is_complete(): 

193 return CTV_INCOMPLETE 

194 t = self.total_score() 

195 o = self.obsession_score() 

196 c = self.compulsion_score() 

197 return [ 

198 CtvInfo( 

199 content=( 

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

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

202 t=t, 

203 mt=self.MAX_TOTAL, 

204 o=o, 

205 mo=self.MAX_OBS, 

206 c=c, 

207 mc=self.MAX_COM, 

208 ) 

209 ) 

210 ) 

211 ] 

212 

213 def total_score(self) -> int: 

214 return cast(int, self.sum_fields(self.SCORED_QUESTIONS)) 

215 

216 def obsession_score(self) -> int: 

217 return cast(int, self.sum_fields(self.OBSESSION_QUESTIONS)) 

218 

219 def compulsion_score(self) -> int: 

220 return cast(int, self.sum_fields(self.COMPULSION_QUESTIONS)) 

221 

222 def is_complete(self) -> bool: 

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

224 self.SCORED_QUESTIONS 

225 ) 

226 

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

228 target_symptoms = "" 

229 for col in self.TARGET_COLUMNS: 

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

231 q_a = "" 

232 for qi in self.QINFO: 

233 fieldname = "q" + qi[0] 

234 value = getattr(self, fieldname) 

235 q_a += tr( 

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

237 answer( 

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

239 if value is not None 

240 else None 

241 ), 

242 ) 

243 return f""" 

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

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

246 {self.get_is_complete_tr(req)} 

247 <tr> 

248 <td>Total score</td> 

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

250 {self.MAX_TOTAL}</td> 

251 </td> 

252 <tr> 

253 <td>Obsession score</td> 

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

255 {self.MAX_OBS}</td> 

256 </td> 

257 <tr> 

258 <td>Compulsion score</td> 

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

260 {self.MAX_COM}</td> 

261 </td> 

262 </table> 

263 </div> 

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

265 <tr> 

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

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

268 </tr> 

269 {target_symptoms} 

270 </table> 

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

272 <tr> 

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

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

275 </tr> 

276 {q_a} 

277 </table> 

278 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

279 """ 

280 

281 

282# ============================================================================= 

283# Y-BOCS-SC 

284# ============================================================================= 

285 

286 

287class YbocsSc( # type: ignore[misc] 

288 TaskHasClinicianMixin, 

289 TaskHasPatientMixin, 

290 Task, 

291): 

292 """ 

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

294 """ 

295 

296 __tablename__ = "ybocssc" 

297 shortname = "Y-BOCS-SC" 

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

299 info_filename_stem = extrastring_taskname 

300 

301 SC_PREFIX = "sc_" 

302 SUFFIX_CURRENT = "_current" 

303 SUFFIX_PAST = "_past" 

304 SUFFIX_PRINCIPAL = "_principal" 

305 SUFFIX_OTHER = "_other" 

306 SUFFIX_DETAIL = "_detail" 

307 GROUPS = [ 

308 "obs_aggressive", 

309 "obs_contamination", 

310 "obs_sexual", 

311 "obs_hoarding", 

312 "obs_religious", 

313 "obs_symmetry", 

314 "obs_misc", 

315 "obs_somatic", 

316 "com_cleaning", 

317 "com_checking", 

318 "com_repeat", 

319 "com_counting", 

320 "com_arranging", 

321 "com_hoarding", 

322 "com_misc", 

323 ] 

324 ITEMS = [ 

325 "obs_aggressive_harm_self", 

326 "obs_aggressive_harm_others", 

327 "obs_aggressive_imagery", 

328 "obs_aggressive_obscenities", 

329 "obs_aggressive_embarrassing", 

330 "obs_aggressive_impulses", 

331 "obs_aggressive_steal", 

332 "obs_aggressive_accident", 

333 "obs_aggressive_responsible", 

334 "obs_aggressive_other", 

335 "obs_contamination_bodily_waste", 

336 "obs_contamination_dirt", 

337 "obs_contamination_environmental", 

338 "obs_contamination_household", 

339 "obs_contamination_animals", 

340 "obs_contamination_sticky", 

341 "obs_contamination_ill", 

342 "obs_contamination_others_ill", 

343 "obs_contamination_feeling", 

344 "obs_contamination_other", 

345 "obs_sexual_forbidden", 

346 "obs_sexual_children_incest", 

347 "obs_sexual_homosexuality", 

348 "obs_sexual_to_others", 

349 "obs_sexual_other", 

350 "obs_hoarding_other", 

351 "obs_religious_sacrilege", 

352 "obs_religious_morality", 

353 "obs_religious_other", 

354 "obs_symmetry_with_magical", 

355 "obs_symmetry_without_magical", 

356 "obs_misc_know_remember", 

357 "obs_misc_fear_saying", 

358 "obs_misc_fear_not_saying", 

359 "obs_misc_fear_losing", 

360 "obs_misc_intrusive_nonviolent_images", 

361 "obs_misc_intrusive_sounds", 

362 "obs_misc_bothered_sounds", 

363 "obs_misc_numbers", 

364 "obs_misc_colours", 

365 "obs_misc_superstitious", 

366 "obs_misc_other", 

367 "obs_somatic_illness", 

368 "obs_somatic_appearance", 

369 "obs_somatic_other", 

370 "com_cleaning_handwashing", 

371 "com_cleaning_toileting", 

372 "com_cleaning_cleaning_items", 

373 "com_cleaning_other_contaminant_avoidance", 

374 "com_cleaning_other", 

375 "com_checking_locks_appliances", 

376 "com_checking_not_harm_others", 

377 "com_checking_not_harm_self", 

378 "com_checking_nothing_bad_happens", 

379 "com_checking_no_mistake", 

380 "com_checking_somatic", 

381 "com_checking_other", 

382 "com_repeat_reread_rewrite", 

383 "com_repeat_routine", 

384 "com_repeat_other", 

385 "com_counting_other", 

386 "com_arranging_other", 

387 "com_hoarding_other", 

388 "com_misc_mental_rituals", 

389 "com_misc_lists", 

390 "com_misc_tell_ask", 

391 "com_misc_touch", 

392 "com_misc_blink_stare", 

393 "com_misc_prevent_harm_self", 

394 "com_misc_prevent_harm_others", 

395 "com_misc_prevent_terrible", 

396 "com_misc_eating_ritual", 

397 "com_misc_superstitious", 

398 "com_misc_trichotillomania", 

399 "com_misc_self_harm", 

400 "com_misc_other", 

401 ] 

402 

403 @classmethod 

404 def extend_columns(cls: Type["YbocsSc"], **kwargs: Any) -> None: 

405 for item in cls.ITEMS: 

406 setattr( 

407 cls, 

408 item + cls.SUFFIX_CURRENT, 

409 camcops_column( 

410 item + cls.SUFFIX_CURRENT, 

411 Boolean, 

412 permitted_value_checker=BIT_CHECKER, 

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

414 ), 

415 ) 

416 setattr( 

417 cls, 

418 item + cls.SUFFIX_PAST, 

419 camcops_column( 

420 item + cls.SUFFIX_PAST, 

421 Boolean, 

422 permitted_value_checker=BIT_CHECKER, 

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

424 ), 

425 ) 

426 setattr( 

427 cls, 

428 item + cls.SUFFIX_PRINCIPAL, 

429 camcops_column( 

430 item + cls.SUFFIX_PRINCIPAL, 

431 Boolean, 

432 permitted_value_checker=BIT_CHECKER, 

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

434 ), 

435 ) 

436 if item.endswith(cls.SUFFIX_OTHER): 

437 setattr( 

438 cls, 

439 item + cls.SUFFIX_DETAIL, 

440 Column( 

441 item + cls.SUFFIX_DETAIL, 

442 UnicodeText, 

443 comment=item + " (details)", 

444 ), 

445 ) 

446 

447 @staticmethod 

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

449 _ = req.gettext 

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

451 

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

453 if not self.is_complete(): 

454 return CTV_INCOMPLETE 

455 current_list = [] 

456 past_list = [] 

457 principal_list = [] 

458 for item in self.ITEMS: 

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

460 current_list.append(item) 

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

462 past_list.append(item) 

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

464 principal_list.append(item) 

465 return [ 

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

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

468 CtvInfo( 

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

470 ), 

471 ] 

472 

473 # noinspection PyMethodOverriding 

474 @staticmethod 

475 def is_complete() -> bool: 

476 return True 

477 

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

479 h = f""" 

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

481 <tr> 

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

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

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

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

486 </tr> 

487 """ 

488 for group in self.GROUPS: 

489 h += subheading_spanning_four_columns( 

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

491 ) 

492 for item in self.ITEMS: 

493 if not item.startswith(group): 

494 continue 

495 h += tr( 

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

497 answer( 

498 get_ternary( 

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

500 value_true="Current", 

501 value_false="", 

502 value_none="", 

503 ) 

504 ), 

505 answer( 

506 get_ternary( 

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

508 value_true="Past", 

509 value_false="", 

510 value_none="", 

511 ) 

512 ), 

513 answer( 

514 get_ternary( 

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

516 value_true="Principal", 

517 value_false="", 

518 value_none="", 

519 ) 

520 ), 

521 ) 

522 if item.endswith(self.SUFFIX_OTHER): 

523 h += f""" 

524 <tr> 

525 <td><i>Specify:</i></td> 

526 <td colspan="3">{ 

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

528 </tr> 

529 """ 

530 h += f""" 

531 </table> 

532 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

533 """ 

534 return h