Coverage for tasks/ybocs.py: 60%

1"""

2 camcops_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

28 from typing import Any, cast, List, Type

29

30 from cardinal_pythonlib.stringfunc import strseq

31 from sqlalchemy.sql.schema import Column

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

33

34 from camcops_server.cc_modules.cc_constants import (

35 CssClass,

36 DATA_COLLECTION_UNLESS_UPGRADED_DIV,

37)

38 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

39 from camcops_server.cc_modules.cc_html import (

40 answer,

41 get_ternary,

42 subheading_spanning_four_columns,

43 tr,

44)

45 from camcops_server.cc_modules.cc_request import CamcopsRequest

46 from camcops_server.cc_modules.cc_sqla_coltypes import (

47 BIT_CHECKER,

48 camcops_column,

49 PermittedValueChecker,

50)

51 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

52 from camcops_server.cc_modules.cc_task import (

53 Task,

54 TaskHasClinicianMixin,

55 TaskHasPatientMixin,

56)

57 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

58

59

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

61# Y-BOCS

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

63

64

65 class 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

287 class 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