Coverage for tasks/shaps.py: 57%
58 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/shaps.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**Snaith–Hamilton Pleasure Scale (SHAPS) task.**
30"""
32from typing import Any, Dict, List, Type, Tuple
34from cardinal_pythonlib.stringfunc import strseq
35from sqlalchemy import Integer
36from sqlalchemy.ext.declarative import DeclarativeMeta
38from camcops_server.cc_modules.cc_constants import CssClass
39from camcops_server.cc_modules.cc_db import add_multiple_columns
40from camcops_server.cc_modules.cc_html import tr_qa, tr, answer
41from camcops_server.cc_modules.cc_request import CamcopsRequest
42from camcops_server.cc_modules.cc_summaryelement import SummaryElement
43from camcops_server.cc_modules.cc_task import (
44 get_from_dict,
45 TaskHasPatientMixin,
46 Task,
47)
48from camcops_server.cc_modules.cc_text import SS
51class ShapsMetaclass(DeclarativeMeta):
52 # noinspection PyInitNewSignature
53 def __init__(
54 cls: Type["Shaps"],
55 name: str,
56 bases: Tuple[Type, ...],
57 classdict: Dict[str, Any],
58 ) -> None:
60 add_multiple_columns(
61 cls,
62 "q",
63 1,
64 cls.N_QUESTIONS,
65 minimum=0,
66 maximum=3,
67 comment_fmt="Q{n} - {s}",
68 comment_strings=[
69 "television",
70 "family",
71 "hobbies",
72 "meal",
73 "bath",
74 "flowers",
75 "smiling",
76 "smart",
77 "book",
78 "tea",
79 "sunny",
80 "landscape",
81 "helping",
82 "praise",
83 ],
84 )
86 super().__init__(name, bases, classdict)
89class Shaps(TaskHasPatientMixin, Task, metaclass=ShapsMetaclass):
90 __tablename__ = "shaps"
91 shortname = "SHAPS"
93 N_QUESTIONS = 14
94 MAX_SCORE = 14
95 ALL_QUESTIONS = strseq("q", 1, N_QUESTIONS)
97 STRONGLY_DISAGREE = 0
98 DISAGREE = 1
99 AGREE = 2
100 STRONGLY_OR_DEFINITELY_AGREE = 3
102 # Q11 in British Journal of Psychiatry (1995), 167, 99-103
103 # actually has two "Strongly disagree" options. Assuming this
104 # is not intentional!
105 REVERSE_QUESTIONS = {2, 4, 5, 7, 9, 12, 14}
107 @staticmethod
108 def longname(req: "CamcopsRequest") -> str:
109 _ = req.gettext
110 return _("Snaith–Hamilton Pleasure Scale")
112 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
113 return self.standard_task_summary_fields() + [
114 SummaryElement(
115 name="total",
116 coltype=Integer(),
117 value=self.total_score(),
118 comment=f"Total score (/{self.MAX_SCORE})",
119 )
120 ]
122 def is_complete(self) -> bool:
123 if self.any_fields_none(self.ALL_QUESTIONS):
124 return False
125 if not self.field_contents_valid():
126 return False
127 return True
129 def total_score(self) -> int:
130 # Consistent with client implementation
131 return self.count_where(
132 self.ALL_QUESTIONS, [self.STRONGLY_DISAGREE, self.DISAGREE]
133 )
135 def get_task_html(self, req: CamcopsRequest) -> str:
136 strongly_disagree = self.wxstring(req, "strongly_disagree")
137 disagree = self.wxstring(req, "disagree")
138 agree = self.wxstring(req, "agree")
140 # We store the actual answers given but these are scored 1 or 0
141 forward_answer_dict = {
142 None: None,
143 self.STRONGLY_DISAGREE: "1 — " + strongly_disagree,
144 self.DISAGREE: "1 — " + disagree,
145 self.AGREE: "0 — " + agree,
146 self.STRONGLY_OR_DEFINITELY_AGREE: "0 — "
147 + self.wxstring(req, "strongly_agree"),
148 }
150 # Subtle difference in wording when options presented in reverse
151 reverse_answer_dict = {
152 None: None,
153 self.STRONGLY_OR_DEFINITELY_AGREE: "0 — "
154 + self.wxstring(req, "definitely_agree"),
155 self.AGREE: "0 — " + agree,
156 self.DISAGREE: "1 — " + disagree,
157 self.STRONGLY_DISAGREE: "1 — " + strongly_disagree,
158 }
160 rows = ""
161 for q_num in range(1, self.N_QUESTIONS + 1):
162 q_field = "q" + str(q_num)
163 question_cell = "{}. {}".format(q_num, self.wxstring(req, q_field))
165 answer_dict = forward_answer_dict
167 if q_num in self.REVERSE_QUESTIONS:
168 answer_dict = reverse_answer_dict
170 answer_cell = get_from_dict(answer_dict, getattr(self, q_field))
171 rows += tr_qa(question_cell, answer_cell)
173 html = """
174 <div class="{CssClass.SUMMARY}">
175 <table class="{CssClass.SUMMARY}">
176 {tr_is_complete}
177 {total_score}
178 </table>
179 </div>
180 <table class="{CssClass.TASKDETAIL}">
181 <tr>
182 <th width="60%">Question</th>
183 <th width="40%">Answer</th>
184 </tr>
185 {rows}
186 </table>
187 <div class="{CssClass.FOOTNOTES}">
188 [1] Score 1 point for either ‘disagree’ option,
189 0 points for either ‘agree’ option.
190 </div>
191 """.format(
192 CssClass=CssClass,
193 tr_is_complete=self.get_is_complete_tr(req),
194 total_score=tr(
195 req.sstring(SS.TOTAL_SCORE) + " <sup>[1]</sup>",
196 "{} / {}".format(answer(self.total_score()), self.MAX_SCORE),
197 ),
198 rows=rows,
199 )
200 return html