Coverage for tasks/honos.py : 52%

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/honos.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, Optional, 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 Integer, UnicodeText
36from camcops_server.cc_modules.cc_constants import CssClass
37from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
38from camcops_server.cc_modules.cc_db import add_multiple_columns
39from camcops_server.cc_modules.cc_html import (
40 answer,
41 subheading_spanning_two_columns,
42 tr,
43 tr_qa,
44)
45from camcops_server.cc_modules.cc_request import CamcopsRequest
46from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
47from camcops_server.cc_modules.cc_sqla_coltypes import (
48 CamcopsColumn,
49 CharColType,
50 PermittedValueChecker,
51)
52from camcops_server.cc_modules.cc_summaryelement import SummaryElement
53from camcops_server.cc_modules.cc_task import (
54 get_from_dict,
55 Task,
56 TaskHasClinicianMixin,
57 TaskHasPatientMixin,
58)
59from camcops_server.cc_modules.cc_text import SS
60from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
63PV_MAIN = [0, 1, 2, 3, 4, 9]
64PV_PROBLEMTYPE = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']
66FOOTNOTE_SCORING = """
67 [1] 0 = no problem;
68 1 = minor problem requiring no action;
69 2 = mild problem but definitely present;
70 3 = moderately severe problem;
71 4 = severe to very severe problem;
72 9 = not known.
73"""
76# =============================================================================
77# HoNOS abstract base class
78# =============================================================================
80# noinspection PyAbstractClass
81class HonosBase(TaskHasPatientMixin, TaskHasClinicianMixin, Task):
82 __abstract__ = True
83 provides_trackers = True
85 period_rated = Column(
86 "period_rated", UnicodeText,
87 comment="Period being rated"
88 )
90 COPYRIGHT_DIV = f"""
91 <div class="{CssClass.COPYRIGHT}">
92 Health of the Nation Outcome Scales:
93 Copyright © Royal College of Psychiatrists.
94 Used here with permission.
95 </div>
96 """
98 QFIELDS = None # type: List[str] # must be overridden
99 MAX_SCORE = None # type: int # must be overridden
101 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
102 return [TrackerInfo(
103 value=self.total_score(),
104 plot_label=f"{self.shortname} total score",
105 axis_label=f"Total score (out of {self.MAX_SCORE})",
106 axis_min=-0.5,
107 axis_max=self.MAX_SCORE + 0.5
108 )]
110 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
111 if not self.is_complete():
112 return CTV_INCOMPLETE
113 return [CtvInfo(content=(
114 f"{self.shortname} total score "
115 f"{self.total_score()}/{self.MAX_SCORE}"
116 ))]
118 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
119 return self.standard_task_summary_fields() + [
120 SummaryElement(name="total",
121 coltype=Integer(),
122 value=self.total_score(),
123 comment=f"Total score (/{self.MAX_SCORE})"),
124 ]
126 def _total_score_for_fields(self, fieldnames: List[str]) -> int:
127 total = 0
128 for qname in fieldnames:
129 value = getattr(self, qname)
130 if value is not None and 0 <= value <= 4:
131 # i.e. ignore null values and 9 (= not known)
132 total += value
133 return total
135 def total_score(self) -> int:
136 return self._total_score_for_fields(self.QFIELDS)
138 def get_q(self, req: CamcopsRequest, q: int) -> str:
139 return self.wxstring(req, "q" + str(q) + "_s")
141 def get_answer(self, req: CamcopsRequest, q: int, a: int) -> Optional[str]:
142 if a == 9:
143 return self.wxstring(req, "option9")
144 if a is None or a < 0 or a > 4:
145 return None
146 return self.wxstring(req, "q" + str(q) + "_option" + str(a))
149# =============================================================================
150# HoNOS
151# =============================================================================
153class HonosMetaclass(DeclarativeMeta):
154 # noinspection PyInitNewSignature
155 def __init__(cls: Type['Honos'],
156 name: str,
157 bases: Tuple[Type, ...],
158 classdict: Dict[str, Any]) -> None:
159 add_multiple_columns(
160 cls, "q", 1, cls.NQUESTIONS,
161 pv=PV_MAIN,
162 comment_fmt="Q{n}, {s} (0-4, higher worse)",
163 comment_strings=[
164 "overactive/aggressive/disruptive/agitated",
165 "deliberate self-harm",
166 "problem-drinking/drug-taking",
167 "cognitive problems",
168 "physical illness/disability",
169 "hallucinations/delusions",
170 "depressed mood",
171 "other mental/behavioural problem",
172 "relationship problems",
173 "activities of daily living",
174 "problems with living conditions",
175 "occupation/activities",
176 ]
177 )
178 super().__init__(name, bases, classdict)
181class Honos(HonosBase,
182 metaclass=HonosMetaclass):
183 """
184 Server implementation of the HoNOS task.
185 """
186 __tablename__ = "honos"
187 shortname = "HoNOS"
189 q8problemtype = CamcopsColumn(
190 "q8problemtype", CharColType,
191 permitted_value_checker=PermittedValueChecker(
192 permitted_values=PV_PROBLEMTYPE),
193 comment="Q8: type of problem (A phobic; B anxiety; "
194 "C obsessive-compulsive; D mental strain/tension; "
195 "E dissociative; F somatoform; G eating; H sleep; "
196 "I sexual; J other, specify)"
197 )
198 q8otherproblem = Column(
199 "q8otherproblem", UnicodeText,
200 comment="Q8: other problem: specify"
201 )
203 NQUESTIONS = 12
204 QFIELDS = strseq("q", 1, NQUESTIONS)
205 MAX_SCORE = 48
207 @staticmethod
208 def longname(req: "CamcopsRequest") -> str:
209 _ = req.gettext
210 return _("Health of the Nation Outcome Scales, working age adults")
212 # noinspection PyUnresolvedReferences
213 def is_complete(self) -> bool:
214 if self.any_fields_none(self.QFIELDS):
215 return False
216 if not self.field_contents_valid():
217 return False
218 if self.q8 != 0 and self.q8 != 9 and self.q8problemtype is None:
219 return False
220 if self.q8 != 0 and self.q8 != 9 and self.q8problemtype == "J" \
221 and self.q8otherproblem is None:
222 return False
223 return self.period_rated is not None
225 def get_task_html(self, req: CamcopsRequest) -> str:
226 q8_problem_type_dict = {
227 None: None,
228 "A": self.wxstring(req, "q8problemtype_option_a"),
229 "B": self.wxstring(req, "q8problemtype_option_b"),
230 "C": self.wxstring(req, "q8problemtype_option_c"),
231 "D": self.wxstring(req, "q8problemtype_option_d"),
232 "E": self.wxstring(req, "q8problemtype_option_e"),
233 "F": self.wxstring(req, "q8problemtype_option_f"),
234 "G": self.wxstring(req, "q8problemtype_option_g"),
235 "H": self.wxstring(req, "q8problemtype_option_h"),
236 "I": self.wxstring(req, "q8problemtype_option_i"),
237 "J": self.wxstring(req, "q8problemtype_option_j"),
238 }
239 one_to_eight = ""
240 for i in range(1, 8 + 1):
241 one_to_eight += tr_qa(
242 self.get_q(req, i),
243 self.get_answer(req, i, getattr(self, "q" + str(i)))
244 )
245 nine_onwards = ""
246 for i in range(9, self.NQUESTIONS + 1):
247 nine_onwards += tr_qa(
248 self.get_q(req, i),
249 self.get_answer(req, i, getattr(self, "q" + str(i)))
250 )
252 h = """
253 <div class="{CssClass.SUMMARY}">
254 <table class="{CssClass.SUMMARY}">
255 {tr_is_complete}
256 {total_score}
257 </table>
258 </div>
259 <table class="{CssClass.TASKDETAIL}">
260 <tr>
261 <th width="50%">Question</th>
262 <th width="50%">Answer <sup>[1]</sup></th>
263 </tr>
264 {period_rated}
265 {one_to_eight}
266 {q8problemtype}
267 {q8otherproblem}
268 {nine_onwards}
269 </table>
270 <div class="{CssClass.FOOTNOTES}">
271 {FOOTNOTE_SCORING}
272 </div>
273 {copyright_div}
274 """.format(
275 CssClass=CssClass,
276 tr_is_complete=self.get_is_complete_tr(req),
277 total_score=tr(
278 req.sstring(SS.TOTAL_SCORE),
279 answer(self.total_score()) + f" / {self.MAX_SCORE}"
280 ),
281 period_rated=tr_qa(self.wxstring(req, "period_rated"),
282 self.period_rated),
283 one_to_eight=one_to_eight,
284 q8problemtype=tr_qa(
285 self.wxstring(req, "q8problemtype_s"),
286 get_from_dict(q8_problem_type_dict, self.q8problemtype)
287 ),
288 q8otherproblem=tr_qa(
289 self.wxstring(req, "q8otherproblem_s"),
290 self.q8otherproblem
291 ),
292 nine_onwards=nine_onwards,
293 FOOTNOTE_SCORING=FOOTNOTE_SCORING,
294 copyright_div=self.COPYRIGHT_DIV,
295 )
296 return h
298 # noinspection PyUnresolvedReferences
299 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
300 codes = [SnomedExpression(req.snomed(SnomedLookup.HONOSWA_PROCEDURE_ASSESSMENT))] # noqa
301 if self.is_complete():
302 codes.append(SnomedExpression(
303 req.snomed(SnomedLookup.HONOSWA_SCALE),
304 {
305 req.snomed(SnomedLookup.HONOSWA_SCORE): self.total_score(),
306 req.snomed(SnomedLookup.HONOSWA_1_OVERACTIVE_SCORE): self.q1, # noqa
307 req.snomed(SnomedLookup.HONOSWA_2_SELFINJURY_SCORE): self.q2, # noqa
308 req.snomed(SnomedLookup.HONOSWA_3_SUBSTANCE_SCORE): self.q3, # noqa
309 req.snomed(SnomedLookup.HONOSWA_4_COGNITIVE_SCORE): self.q4, # noqa
310 req.snomed(SnomedLookup.HONOSWA_5_PHYSICAL_SCORE): self.q5,
311 req.snomed(SnomedLookup.HONOSWA_6_PSYCHOSIS_SCORE): self.q6, # noqa
312 req.snomed(SnomedLookup.HONOSWA_7_DEPRESSION_SCORE): self.q7, # noqa
313 req.snomed(SnomedLookup.HONOSWA_8_OTHERMENTAL_SCORE): self.q8, # noqa
314 req.snomed(SnomedLookup.HONOSWA_9_RELATIONSHIPS_SCORE): self.q9, # noqa
315 req.snomed(SnomedLookup.HONOSWA_10_ADL_SCORE): self.q10,
316 req.snomed(SnomedLookup.HONOSWA_11_LIVINGCONDITIONS_SCORE): self.q11, # noqa
317 req.snomed(SnomedLookup.HONOSWA_12_OCCUPATION_SCORE): self.q12, # noqa
318 }
319 ))
320 return codes
323# =============================================================================
324# HoNOS 65+
325# =============================================================================
327class Honos65Metaclass(DeclarativeMeta):
328 # noinspection PyInitNewSignature
329 def __init__(cls: Type['Honos65'],
330 name: str,
331 bases: Tuple[Type, ...],
332 classdict: Dict[str, Any]) -> None:
333 add_multiple_columns(
334 cls, "q", 1, cls.NQUESTIONS,
335 pv=PV_MAIN,
336 comment_fmt="Q{n}, {s} (0-4, higher worse)",
337 comment_strings=[ # not exactly identical to HoNOS
338 "behavioural disturbance",
339 "deliberate self-harm",
340 "problem drinking/drug-taking",
341 "cognitive problems",
342 "physical illness/disability",
343 "hallucinations/delusions",
344 "depressive symptoms",
345 "other mental/behavioural problem",
346 "relationship problems",
347 "activities of daily living",
348 "living conditions",
349 "occupation/activities",
350 ]
351 )
352 super().__init__(name, bases, classdict)
355class Honos65(HonosBase,
356 metaclass=Honos65Metaclass):
357 """
358 Server implementation of the HoNOS 65+ task.
359 """
360 __tablename__ = "honos65"
361 shortname = "HoNOS 65+"
363 q8problemtype = CamcopsColumn(
364 "q8problemtype", CharColType,
365 permitted_value_checker=PermittedValueChecker(
366 permitted_values=PV_PROBLEMTYPE),
367 comment="Q8: type of problem (A phobic; B anxiety; "
368 "C obsessive-compulsive; D stress; " # NB slight difference: D
369 "E dissociative; F somatoform; G eating; H sleep; "
370 "I sexual; J other, specify)"
371 )
372 q8otherproblem = Column(
373 "q8otherproblem", UnicodeText,
374 comment="Q8: other problem: specify"
375 )
377 NQUESTIONS = 12
378 QFIELDS = strseq("q", 1, NQUESTIONS)
379 MAX_SCORE = 48
381 @staticmethod
382 def longname(req: "CamcopsRequest") -> str:
383 _ = req.gettext
384 return _("Health of the Nation Outcome Scales, older adults")
386 # noinspection PyUnresolvedReferences
387 def is_complete(self) -> bool:
388 if self.any_fields_none(self.QFIELDS):
389 return False
390 if not self.field_contents_valid():
391 return False
392 if self.q8 != 0 and self.q8 != 9 and self.q8problemtype is None:
393 return False
394 if self.q8 != 0 and self.q8 != 9 and self.q8problemtype == "J" \
395 and self.q8otherproblem is None:
396 return False
397 return self.period_rated is not None
399 def get_task_html(self, req: CamcopsRequest) -> str:
400 q8_problem_type_dict = {
401 None: None,
402 "A": self.wxstring(req, "q8problemtype_option_a"),
403 "B": self.wxstring(req, "q8problemtype_option_b"),
404 "C": self.wxstring(req, "q8problemtype_option_c"),
405 "D": self.wxstring(req, "q8problemtype_option_d"),
406 "E": self.wxstring(req, "q8problemtype_option_e"),
407 "F": self.wxstring(req, "q8problemtype_option_f"),
408 "G": self.wxstring(req, "q8problemtype_option_g"),
409 "H": self.wxstring(req, "q8problemtype_option_h"),
410 "I": self.wxstring(req, "q8problemtype_option_i"),
411 "J": self.wxstring(req, "q8problemtype_option_j"),
412 }
413 one_to_eight = ""
414 for i in range(1, 8 + 1):
415 one_to_eight += tr_qa(
416 self.get_q(req, i),
417 self.get_answer(req, i, getattr(self, "q" + str(i)))
418 )
419 nine_onwards = ""
420 for i in range(9, Honos.NQUESTIONS + 1):
421 nine_onwards += tr_qa(
422 self.get_q(req, i),
423 self.get_answer(req, i, getattr(self, "q" + str(i)))
424 )
426 h = """
427 <div class="{CssClass.SUMMARY}">
428 <table class="{CssClass.SUMMARY}">
429 {tr_is_complete}
430 {total_score}
431 </table>
432 </div>
433 <table class="{CssClass.TASKDETAIL}">
434 <tr>
435 <th width="50%">Question</th>
436 <th width="50%">Answer <sup>[1]</sup></th>
437 </tr>
438 {period_rated}
439 {one_to_eight}
440 {q8problemtype}
441 {q8otherproblem}
442 {nine_onwards}
443 </table>
444 <div class="{CssClass.FOOTNOTES}">
445 {FOOTNOTE_SCORING}
446 </div>
447 {copyright_div}
448 """.format(
449 CssClass=CssClass,
450 tr_is_complete=self.get_is_complete_tr(req),
451 total_score=tr(
452 req.sstring(SS.TOTAL_SCORE),
453 answer(self.total_score()) + f" / {self.MAX_SCORE}"
454 ),
455 period_rated=tr_qa(self.wxstring(req, "period_rated"),
456 self.period_rated),
457 one_to_eight=one_to_eight,
458 q8problemtype=tr_qa(
459 self.wxstring(req, "q8problemtype_s"),
460 get_from_dict(q8_problem_type_dict, self.q8problemtype)
461 ),
462 q8otherproblem=tr_qa(
463 self.wxstring(req, "q8otherproblem_s"),
464 self.q8otherproblem
465 ),
466 nine_onwards=nine_onwards,
467 FOOTNOTE_SCORING=FOOTNOTE_SCORING,
468 copyright_div=self.COPYRIGHT_DIV,
469 )
470 return h
472 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
473 codes = [SnomedExpression(req.snomed(SnomedLookup.HONOS65_PROCEDURE_ASSESSMENT))] # noqa
474 if self.is_complete():
475 codes.append(SnomedExpression(
476 req.snomed(SnomedLookup.HONOS65_SCALE),
477 {
478 req.snomed(SnomedLookup.HONOS65_SCORE): self.total_score(),
479 }
480 ))
481 return codes
484# =============================================================================
485# HoNOSCA
486# =============================================================================
488class HonoscaMetaclass(DeclarativeMeta):
489 # noinspection PyInitNewSignature
490 def __init__(cls: Type['Honosca'],
491 name: str,
492 bases: Tuple[Type, ...],
493 classdict: Dict[str, Any]) -> None:
494 add_multiple_columns(
495 cls, "q", 1, cls.NQUESTIONS,
496 pv=PV_MAIN,
497 comment_fmt="Q{n}, {s} (0-4, higher worse)",
498 comment_strings=[
499 "disruptive/antisocial/aggressive",
500 "overactive/inattentive",
501 "self-harm",
502 "alcohol/drug misuse",
503 "scholastic/language problems",
504 "physical illness/disability",
505 "delusions/hallucinations",
506 "non-organic somatic symptoms",
507 "emotional symptoms",
508 "peer relationships",
509 "self-care and independence",
510 "family life/relationships",
511 "school attendance",
512 "problems with knowledge/understanding of child's problems",
513 "lack of information about services",
514 ]
515 )
516 super().__init__(name, bases, classdict)
519class Honosca(HonosBase,
520 metaclass=HonoscaMetaclass):
521 """
522 Server implementation of the HoNOSCA task.
523 """
524 __tablename__ = "honosca"
525 shortname = "HoNOSCA"
527 NQUESTIONS = 15
528 QFIELDS = strseq("q", 1, NQUESTIONS)
529 LAST_SECTION_A_Q = 13
530 FIRST_SECTION_B_Q = 14
531 SECTION_A_QFIELDS = strseq("q", 1, LAST_SECTION_A_Q)
532 SECTION_B_QFIELDS = strseq("q", FIRST_SECTION_B_Q, NQUESTIONS)
533 MAX_SCORE = 60
534 MAX_SECTION_A = 4 * len(SECTION_A_QFIELDS)
535 MAX_SECTION_B = 4 * len(SECTION_B_QFIELDS)
536 TASK_FIELDS = QFIELDS + ["period_rated"]
538 @staticmethod
539 def longname(req: "CamcopsRequest") -> str:
540 _ = req.gettext
541 return _(
542 "Health of the Nation Outcome Scales, Children and Adolescents")
544 def is_complete(self) -> bool:
545 return (
546 self.all_fields_not_none(self.TASK_FIELDS) and
547 self.field_contents_valid()
548 )
550 def section_a_score(self) -> int:
551 return self._total_score_for_fields(self.SECTION_A_QFIELDS)
553 def section_b_score(self) -> int:
554 return self._total_score_for_fields(self.SECTION_B_QFIELDS)
556 def get_task_html(self, req: CamcopsRequest) -> str:
557 section_a = ""
558 for i in range(1, 13 + 1):
559 section_a += tr_qa(
560 self.get_q(req, i),
561 self.get_answer(req, i, getattr(self, "q" + str(i)))
562 )
563 section_b = ""
564 for i in range(14, self.NQUESTIONS + 1):
565 section_b += tr_qa(
566 self.get_q(req, i),
567 self.get_answer(req, i, getattr(self, "q" + str(i)))
568 )
570 h = """
571 <div class="{CssClass.SUMMARY}">
572 <table class="{CssClass.SUMMARY}">
573 {tr_is_complete}
574 {total_score}
575 {section_a_total}
576 {section_b_total}
577 </table>
578 </div>
579 <table class="{CssClass.TASKDETAIL}">
580 <tr>
581 <th width="50%">Question</th>
582 <th width="50%">Answer <sup>[1]</sup></th>
583 </tr>
584 {period_rated}
585 {section_a_subhead}
586 {section_a}
587 {section_b_subhead}
588 {section_b}
589 </table>
590 <div class="{CssClass.FOOTNOTES}">
591 {FOOTNOTE_SCORING}
592 </div>
593 {copyright_div}
594 """.format(
595 CssClass=CssClass,
596 tr_is_complete=self.get_is_complete_tr(req),
597 total_score=tr(
598 req.sstring(SS.TOTAL_SCORE),
599 answer(self.total_score()) + f" / {self.MAX_SCORE}"
600 ),
601 section_a_total=tr(
602 self.wxstring(req, "section_a_total"),
603 answer(self.section_a_score()) +
604 f" / {self.MAX_SECTION_A}"
605 ),
606 section_b_total=tr(
607 self.wxstring(req, "section_b_total"),
608 answer(self.section_b_score()) +
609 f" / {self.MAX_SECTION_B}"
610 ),
611 period_rated=tr_qa(self.wxstring(req, "period_rated"),
612 self.period_rated),
613 section_a_subhead=subheading_spanning_two_columns(
614 self.wxstring(req, "section_a_title")),
615 section_a=section_a,
616 section_b_subhead=subheading_spanning_two_columns(
617 self.wxstring(req, "section_b_title")),
618 section_b=section_b,
619 FOOTNOTE_SCORING=FOOTNOTE_SCORING,
620 copyright_div=self.COPYRIGHT_DIV,
621 )
622 return h
624 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
625 codes = [SnomedExpression(req.snomed(SnomedLookup.HONOSCA_PROCEDURE_ASSESSMENT))] # noqa
626 if self.is_complete():
627 a = self.section_a_score()
628 b = self.section_b_score()
629 total = a + b
630 codes.append(SnomedExpression(
631 req.snomed(SnomedLookup.HONOSCA_SCALE),
632 {
633 req.snomed(SnomedLookup.HONOSCA_SCORE): total,
634 req.snomed(SnomedLookup.HONOSCA_SECTION_A_SCORE): a,
635 req.snomed(SnomedLookup.HONOSCA_SECTION_B_SCORE): b,
636 req.snomed(SnomedLookup.HONOSCA_SECTION_A_PLUS_B_SCORE): total, # noqa
637 }
638 ))
639 return codes