Coverage for tasks/ybocs.py : 61%

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
3"""
4camcops_server/tasks/ybocs.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27"""
29from typing import Any, Dict, List, Tuple, Type
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
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
62# =============================================================================
63# Y-BOCS
64# =============================================================================
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)
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
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
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 [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 ))]
206 def total_score(self) -> int:
207 return self.sum_fields(self.SCORED_QUESTIONS)
209 def obsession_score(self) -> int:
210 return self.sum_fields(self.OBSESSION_QUESTIONS)
212 def compulsion_score(self) -> int:
213 return self.sum_fields(self.COMPULSION_QUESTIONS)
215 def is_complete(self) -> bool:
216 return (
217 self.field_contents_valid() and
218 self.all_fields_not_none(self.SCORED_QUESTIONS)
219 )
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 """
273# =============================================================================
274# Y-BOCS-SC
275# =============================================================================
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)
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
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",
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",
378 "obs_sexual_forbidden",
379 "obs_sexual_children_incest",
380 "obs_sexual_homosexuality",
381 "obs_sexual_to_others",
382 "obs_sexual_other",
384 "obs_hoarding_other",
386 "obs_religious_sacrilege",
387 "obs_religious_morality",
388 "obs_religious_other",
390 "obs_symmetry_with_magical",
391 "obs_symmetry_without_magical",
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",
405 "obs_somatic_illness",
406 "obs_somatic_appearance",
407 "obs_somatic_other",
409 "com_cleaning_handwashing",
410 "com_cleaning_toileting",
411 "com_cleaning_cleaning_items",
412 "com_cleaning_other_contaminant_avoidance",
413 "com_cleaning_other",
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",
423 "com_repeat_reread_rewrite",
424 "com_repeat_routine",
425 "com_repeat_other",
427 "com_counting_other",
429 "com_arranging_other",
431 "com_hoarding_other",
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 ]
448 @staticmethod
449 def longname(req: "CamcopsRequest") -> str:
450 _ = req.gettext
451 return _("Y-BOCS Symptom Checklist")
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 ]
472 # noinspection PyMethodOverriding
473 @staticmethod
474 def is_complete() -> bool:
475 return True
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