Coverage for tasks/ybocs.py: 61%
128 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/ybocs.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28"""
30from typing import Any, Dict, List, Tuple, Type
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
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
63# =============================================================================
64# Y-BOCS
65# =============================================================================
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)
105class Ybocs(
106 TaskHasClinicianMixin, TaskHasPatientMixin, Task, metaclass=YbocsMetaclass
107):
108 """
109 Server implementation of the Y-BOCS task.
110 """
112 __tablename__ = "ybocs"
113 shortname = "Y-BOCS"
114 provides_trackers = True
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
148 @staticmethod
149 def longname(req: "CamcopsRequest") -> str:
150 _ = req.gettext
151 return _("Yale–Brown Obsessive Compulsive Scale")
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 ]
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 ]
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 ]
222 def total_score(self) -> int:
223 return self.sum_fields(self.SCORED_QUESTIONS)
225 def obsession_score(self) -> int:
226 return self.sum_fields(self.OBSESSION_QUESTIONS)
228 def compulsion_score(self) -> int:
229 return self.sum_fields(self.COMPULSION_QUESTIONS)
231 def is_complete(self) -> bool:
232 return self.field_contents_valid() and self.all_fields_not_none(
233 self.SCORED_QUESTIONS
234 )
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 """
291# =============================================================================
292# Y-BOCS-SC
293# =============================================================================
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)
348class YbocsSc(
349 TaskHasClinicianMixin,
350 TaskHasPatientMixin,
351 Task,
352 metaclass=YbocsScMetaclass,
353):
354 """
355 Server implementation of the Y-BOCS-SC task.
356 """
358 __tablename__ = "ybocssc"
359 shortname = "Y-BOCS-SC"
360 extrastring_taskname = "ybocs" # shares with Y-BOCS
361 info_filename_stem = extrastring_taskname
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 ]
465 @staticmethod
466 def longname(req: "CamcopsRequest") -> str:
467 _ = req.gettext
468 return _("Y-BOCS Symptom Checklist")
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 ]
491 # noinspection PyMethodOverriding
492 @staticmethod
493 def is_complete() -> bool:
494 return True
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