Coverage for tasks/ybocs.py: 60%
124 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/ybocs.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26"""
28from typing import Any, cast, List, Type
30from cardinal_pythonlib.stringfunc import strseq
31from sqlalchemy.sql.schema import Column
32from sqlalchemy.sql.sqltypes import Boolean, Integer, UnicodeText
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
60# =============================================================================
61# Y-BOCS
62# =============================================================================
65class Ybocs( # type: ignore[misc]
66 TaskHasClinicianMixin,
67 TaskHasPatientMixin,
68 Task,
69):
70 """
71 Server implementation of the Y-BOCS task.
72 """
74 __tablename__ = "ybocs"
75 shortname = "Y-BOCS"
76 provides_trackers = True
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] = []
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 )
139 @staticmethod
140 def longname(req: "CamcopsRequest") -> str:
141 _ = req.gettext
142 return _("Yale–Brown Obsessive Compulsive Scale")
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 ]
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 ]
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 ]
213 def total_score(self) -> int:
214 return cast(int, self.sum_fields(self.SCORED_QUESTIONS))
216 def obsession_score(self) -> int:
217 return cast(int, self.sum_fields(self.OBSESSION_QUESTIONS))
219 def compulsion_score(self) -> int:
220 return cast(int, self.sum_fields(self.COMPULSION_QUESTIONS))
222 def is_complete(self) -> bool:
223 return self.field_contents_valid() and self.all_fields_not_none(
224 self.SCORED_QUESTIONS
225 )
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 """
282# =============================================================================
283# Y-BOCS-SC
284# =============================================================================
287class YbocsSc( # type: ignore[misc]
288 TaskHasClinicianMixin,
289 TaskHasPatientMixin,
290 Task,
291):
292 """
293 Server implementation of the Y-BOCS-SC task.
294 """
296 __tablename__ = "ybocssc"
297 shortname = "Y-BOCS-SC"
298 extrastring_taskname = "ybocs" # shares with Y-BOCS
299 info_filename_stem = extrastring_taskname
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 ]
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 )
447 @staticmethod
448 def longname(req: "CamcopsRequest") -> str:
449 _ = req.gettext
450 return _("Y-BOCS Symptom Checklist")
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 ]
473 # noinspection PyMethodOverriding
474 @staticmethod
475 def is_complete() -> bool:
476 return True
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