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

30 

31from cardinal_pythonlib.stringfunc import strseq 

32from sqlalchemy.ext.declarative import DeclarativeMeta 

33from sqlalchemy.sql.schema import Column 

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

35 

36from camcops_server.cc_modules.cc_constants import ( 

37 CssClass, 

38 DATA_COLLECTION_UNLESS_UPGRADED_DIV, 

39) 

40from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

41from camcops_server.cc_modules.cc_html import ( 

42 answer, 

43 get_ternary, 

44 subheading_spanning_four_columns, 

45 tr, 

46) 

47from camcops_server.cc_modules.cc_request import CamcopsRequest 

48from camcops_server.cc_modules.cc_sqla_coltypes import ( 

49 BIT_CHECKER, 

50 CamcopsColumn, 

51 PermittedValueChecker, 

52) 

53from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

54from camcops_server.cc_modules.cc_task import ( 

55 Task, 

56 TaskHasClinicianMixin, 

57 TaskHasPatientMixin, 

58) 

59from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

60 

61 

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

63# Y-BOCS 

64# ============================================================================= 

65 

66class YbocsMetaclass(DeclarativeMeta): 

67 # noinspection PyInitNewSignature 

68 def __init__(cls: Type['Ybocs'], 

69 name: str, 

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

71 classdict: Dict[str, Any]) -> None: 

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

73 for target in ["obsession", "compulsion", "avoidance"]: 

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

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

76 col = Column( 

77 fname, UnicodeText, 

78 comment=f"Target symptoms: {target} {n}" 

79 ) 

80 setattr(cls, fname, col) 

81 cls.TARGET_COLUMNS.append(col) 

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

83 fname = "q" + qnumstr 

84 setattr( 

85 cls, 

86 fname, 

87 CamcopsColumn( 

88 fname, Integer, 

89 permitted_value_checker=PermittedValueChecker( 

90 minimum=0, maximum=maxscore), 

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

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

93 ) 

94 ) 

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

96 

97 

98class Ybocs(TaskHasClinicianMixin, TaskHasPatientMixin, Task, 

99 metaclass=YbocsMetaclass): 

100 """ 

101 Server implementation of the Y-BOCS task. 

102 """ 

103 __tablename__ = "ybocs" 

104 shortname = "Y-BOCS" 

105 provides_trackers = True 

106 

107 NTARGETS = 3 

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

109 ('1', 4, "obsessions: time"), 

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

111 ('2', 4, "obsessions: interference"), 

112 ('3', 4, "obsessions: distress"), 

113 ('4', 4, "obsessions: resistance"), 

114 ('5', 4, "obsessions: control"), 

115 ('6', 4, "compulsions: time"), 

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

117 ('7', 4, "compulsions: interference"), 

118 ('8', 4, "compulsions: distress"), 

119 ('9', 4, "compulsions: resistance"), 

120 ('10', 4, "compulsions: control"), 

121 ('11', 4, "insight"), 

122 ('12', 4, "avoidance"), 

123 ('13', 4, "indecisiveness"), 

124 ('14', 4, "overvalued responsibility"), 

125 ('15', 4, "slowness"), 

126 ('16', 4, "doubting"), 

127 ('17', 6, "global severity"), 

128 ('18', 6, "global improvement"), 

129 ('19', 3, "reliability"), 

130 ] 

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

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

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

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

135 MAX_TOTAL = 40 

136 MAX_OBS = 20 

137 MAX_COM = 20 

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 [CtvInfo(content=( 

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

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

200 t=t, mt=self.MAX_TOTAL, 

201 o=o, mo=self.MAX_OBS, 

202 c=c, mc=self.MAX_COM, 

203 ) 

204 ))] 

205 

206 def total_score(self) -> int: 

207 return self.sum_fields(self.SCORED_QUESTIONS) 

208 

209 def obsession_score(self) -> int: 

210 return self.sum_fields(self.OBSESSION_QUESTIONS) 

211 

212 def compulsion_score(self) -> int: 

213 return self.sum_fields(self.COMPULSION_QUESTIONS) 

214 

215 def is_complete(self) -> bool: 

216 return ( 

217 self.field_contents_valid() and 

218 self.all_fields_not_none(self.SCORED_QUESTIONS) 

219 ) 

220 

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

222 target_symptoms = "" 

223 for col in self.TARGET_COLUMNS: 

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

225 q_a = "" 

226 for qi in self.QINFO: 

227 fieldname = "q" + qi[0] 

228 value = getattr(self, fieldname) 

229 q_a += tr( 

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

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

232 if value is not None else None) 

233 ) 

234 return f""" 

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

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

237 {self.get_is_complete_tr(req)} 

238 <tr> 

239 <td>Total score</td> 

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

241 {self.MAX_TOTAL}</td> 

242 </td> 

243 <tr> 

244 <td>Obsession score</td> 

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

246 {self.MAX_OBS}</td> 

247 </td> 

248 <tr> 

249 <td>Compulsion score</td> 

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

251 {self.MAX_COM}</td> 

252 </td> 

253 </table> 

254 </div> 

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

256 <tr> 

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

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

259 </tr> 

260 {target_symptoms} 

261 </table> 

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

263 <tr> 

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

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

266 </tr> 

267 {q_a} 

268 </table> 

269 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

270 """ 

271 

272 

273# ============================================================================= 

274# Y-BOCS-SC 

275# ============================================================================= 

276 

277class YbocsScMetaclass(DeclarativeMeta): 

278 # noinspection PyInitNewSignature 

279 def __init__(cls: Type['YbocsSc'], 

280 name: str, 

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

282 classdict: Dict[str, Any]) -> None: 

283 for item in cls.ITEMS: 

284 setattr( 

285 cls, 

286 item + cls.SUFFIX_CURRENT, 

287 CamcopsColumn( 

288 item + cls.SUFFIX_CURRENT, Boolean, 

289 permitted_value_checker=BIT_CHECKER, 

290 comment=item + " (current symptom)" 

291 ) 

292 ) 

293 setattr( 

294 cls, 

295 item + cls.SUFFIX_PAST, 

296 CamcopsColumn( 

297 item + cls.SUFFIX_PAST, Boolean, 

298 permitted_value_checker=BIT_CHECKER, 

299 comment=item + " (past symptom)" 

300 ) 

301 ) 

302 setattr( 

303 cls, 

304 item + cls.SUFFIX_PRINCIPAL, 

305 CamcopsColumn( 

306 item + cls.SUFFIX_PRINCIPAL, Boolean, 

307 permitted_value_checker=BIT_CHECKER, 

308 comment=item + " (principal symptom)" 

309 ) 

310 ) 

311 if item.endswith(cls.SUFFIX_OTHER): 

312 setattr( 

313 cls, 

314 item + cls.SUFFIX_DETAIL, 

315 Column( 

316 item + cls.SUFFIX_DETAIL, UnicodeText, 

317 comment=item + " (details)" 

318 ) 

319 ) 

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

321 

322 

323class YbocsSc(TaskHasClinicianMixin, TaskHasPatientMixin, Task, 

324 metaclass=YbocsScMetaclass): 

325 """ 

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

327 """ 

328 __tablename__ = "ybocssc" 

329 shortname = "Y-BOCS-SC" 

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

331 

332 SC_PREFIX = "sc_" 

333 SUFFIX_CURRENT = "_current" 

334 SUFFIX_PAST = "_past" 

335 SUFFIX_PRINCIPAL = "_principal" 

336 SUFFIX_OTHER = "_other" 

337 SUFFIX_DETAIL = "_detail" 

338 GROUPS = [ 

339 "obs_aggressive", 

340 "obs_contamination", 

341 "obs_sexual", 

342 "obs_hoarding", 

343 "obs_religious", 

344 "obs_symmetry", 

345 "obs_misc", 

346 "obs_somatic", 

347 "com_cleaning", 

348 "com_checking", 

349 "com_repeat", 

350 "com_counting", 

351 "com_arranging", 

352 "com_hoarding", 

353 "com_misc" 

354 ] 

355 ITEMS = [ 

356 "obs_aggressive_harm_self", 

357 "obs_aggressive_harm_others", 

358 "obs_aggressive_imagery", 

359 "obs_aggressive_obscenities", 

360 "obs_aggressive_embarrassing", 

361 "obs_aggressive_impulses", 

362 "obs_aggressive_steal", 

363 "obs_aggressive_accident", 

364 "obs_aggressive_responsible", 

365 "obs_aggressive_other", 

366 

367 "obs_contamination_bodily_waste", 

368 "obs_contamination_dirt", 

369 "obs_contamination_environmental", 

370 "obs_contamination_household", 

371 "obs_contamination_animals", 

372 "obs_contamination_sticky", 

373 "obs_contamination_ill", 

374 "obs_contamination_others_ill", 

375 "obs_contamination_feeling", 

376 "obs_contamination_other", 

377 

378 "obs_sexual_forbidden", 

379 "obs_sexual_children_incest", 

380 "obs_sexual_homosexuality", 

381 "obs_sexual_to_others", 

382 "obs_sexual_other", 

383 

384 "obs_hoarding_other", 

385 

386 "obs_religious_sacrilege", 

387 "obs_religious_morality", 

388 "obs_religious_other", 

389 

390 "obs_symmetry_with_magical", 

391 "obs_symmetry_without_magical", 

392 

393 "obs_misc_know_remember", 

394 "obs_misc_fear_saying", 

395 "obs_misc_fear_not_saying", 

396 "obs_misc_fear_losing", 

397 "obs_misc_intrusive_nonviolent_images", 

398 "obs_misc_intrusive_sounds", 

399 "obs_misc_bothered_sounds", 

400 "obs_misc_numbers", 

401 "obs_misc_colours", 

402 "obs_misc_superstitious", 

403 "obs_misc_other", 

404 

405 "obs_somatic_illness", 

406 "obs_somatic_appearance", 

407 "obs_somatic_other", 

408 

409 "com_cleaning_handwashing", 

410 "com_cleaning_toileting", 

411 "com_cleaning_cleaning_items", 

412 "com_cleaning_other_contaminant_avoidance", 

413 "com_cleaning_other", 

414 

415 "com_checking_locks_appliances", 

416 "com_checking_not_harm_others", 

417 "com_checking_not_harm_self", 

418 "com_checking_nothing_bad_happens", 

419 "com_checking_no_mistake", 

420 "com_checking_somatic", 

421 "com_checking_other", 

422 

423 "com_repeat_reread_rewrite", 

424 "com_repeat_routine", 

425 "com_repeat_other", 

426 

427 "com_counting_other", 

428 

429 "com_arranging_other", 

430 

431 "com_hoarding_other", 

432 

433 "com_misc_mental_rituals", 

434 "com_misc_lists", 

435 "com_misc_tell_ask", 

436 "com_misc_touch", 

437 "com_misc_blink_stare", 

438 "com_misc_prevent_harm_self", 

439 "com_misc_prevent_harm_others", 

440 "com_misc_prevent_terrible", 

441 "com_misc_eating_ritual", 

442 "com_misc_superstitious", 

443 "com_misc_trichotillomania", 

444 "com_misc_self_harm", 

445 "com_misc_other" 

446 ] 

447 

448 @staticmethod 

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

450 _ = req.gettext 

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

452 

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

454 if not self.is_complete(): 

455 return CTV_INCOMPLETE 

456 current_list = [] 

457 past_list = [] 

458 principal_list = [] 

459 for item in self.ITEMS: 

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

461 current_list.append(item) 

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

463 past_list.append(item) 

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

465 principal_list.append(item) 

466 return [ 

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

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

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

470 ] 

471 

472 # noinspection PyMethodOverriding 

473 @staticmethod 

474 def is_complete() -> bool: 

475 return True 

476 

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

478 h = f""" 

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

480 <tr> 

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

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

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

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

485 </tr> 

486 """ 

487 for group in self.GROUPS: 

488 h += subheading_spanning_four_columns( 

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

490 for item in self.ITEMS: 

491 if not item.startswith(group): 

492 continue 

493 h += tr( 

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

495 answer(get_ternary(getattr(self, 

496 item + self.SUFFIX_CURRENT), 

497 value_true="Current", 

498 value_false="", 

499 value_none="")), 

500 answer(get_ternary(getattr(self, 

501 item + self.SUFFIX_PAST), 

502 value_true="Past", 

503 value_false="", 

504 value_none="")), 

505 answer(get_ternary(getattr(self, 

506 item + self.SUFFIX_PRINCIPAL), 

507 value_true="Principal", 

508 value_false="", 

509 value_none="")), 

510 ) 

511 if item.endswith(self.SUFFIX_OTHER): 

512 h += f""" 

513 <tr> 

514 <td><i>Specify:</i></td> 

515 <td colspan="3">{ 

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

517 </tr> 

518 """ 

519 h += f""" 

520 </table> 

521 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

522 """ 

523 return h