Coverage for tasks/caps.py: 43%
84 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/caps.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.sqltypes import Integer
33from camcops_server.cc_modules.cc_constants import CssClass, PV
34from camcops_server.cc_modules.cc_db import add_multiple_columns
35from camcops_server.cc_modules.cc_html import (
36 answer,
37 get_yes_no_none,
38 tr,
39 tr_qa,
40)
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 Task, TaskHasPatientMixin
44from camcops_server.cc_modules.cc_text import SS
45from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
48# =============================================================================
49# CAPS
50# =============================================================================
52QUESTION_SNIPPETS = [
53 "sounds loud",
54 "presence of another",
55 "heard thoughts echoed",
56 "see shapes/lights/colours",
57 "burning or other bodily sensations",
58 "hear noises/sounds",
59 "thoughts spoken aloud",
60 "unexplained smells",
61 "body changing shape",
62 "limbs not own",
63 "voices commenting",
64 "feeling a touch",
65 "hearing words or sentences",
66 "unexplained tastes",
67 "sensations flooding",
68 "sounds distorted",
69 "hard to distinguish sensations",
70 "odours strong",
71 "shapes/people distorted",
72 "hypersensitive to touch/temperature",
73 "tastes stronger than normal",
74 "face looks different",
75 "lights/colours more intense",
76 "feeling of being uplifted",
77 "common smells seem different",
78 "everyday things look abnormal",
79 "altered perception of time",
80 "hear voices conversing",
81 "smells or odours that others are unaware of",
82 "food/drink tastes unusual",
83 "see things that others cannot",
84 "hear sounds/music that others cannot",
85]
88class Caps( # type: ignore[misc]
89 TaskHasPatientMixin,
90 Task,
91):
92 """
93 Server implementation of the CAPS task.
94 """
96 __tablename__ = "caps"
97 shortname = "CAPS"
98 provides_trackers = True
100 prohibits_commercial = True
102 NQUESTIONS = 32
104 @classmethod
105 def extend_columns(cls: Type["Caps"], **kwargs: Any) -> None:
106 add_multiple_columns(
107 cls,
108 "endorse",
109 1,
110 cls.NQUESTIONS,
111 pv=PV.BIT,
112 comment_fmt="Q{n} ({s}): endorsed? (0 no, 1 yes)",
113 comment_strings=QUESTION_SNIPPETS,
114 )
115 add_multiple_columns(
116 cls,
117 "distress",
118 1,
119 cls.NQUESTIONS,
120 minimum=1,
121 maximum=5,
122 comment_fmt="Q{n} ({s}): distress (1 low - 5 high), if endorsed",
123 comment_strings=QUESTION_SNIPPETS,
124 )
125 add_multiple_columns(
126 cls,
127 "intrusiveness",
128 1,
129 cls.NQUESTIONS,
130 minimum=1,
131 maximum=5,
132 comment_fmt="Q{n} ({s}): intrusiveness (1 low - 5 high), "
133 "if endorsed",
134 comment_strings=QUESTION_SNIPPETS,
135 )
136 add_multiple_columns(
137 cls,
138 "frequency",
139 1,
140 cls.NQUESTIONS,
141 minimum=1,
142 maximum=5,
143 comment_fmt="Q{n} ({s}): frequency (1 low - 5 high), if endorsed",
144 comment_strings=QUESTION_SNIPPETS,
145 )
147 ENDORSE_FIELDS = strseq("endorse", 1, NQUESTIONS)
149 @staticmethod
150 def longname(req: "CamcopsRequest") -> str:
151 _ = req.gettext
152 return _("Cardiff Anomalous Perceptions Scale")
154 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
155 return [
156 TrackerInfo(
157 value=self.total_score(),
158 plot_label="CAPS total score",
159 axis_label="Total score (out of 32)",
160 axis_min=-0.5,
161 axis_max=32.5,
162 )
163 ]
165 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
166 return self.standard_task_summary_fields() + [
167 SummaryElement(
168 name="total",
169 coltype=Integer(),
170 value=self.total_score(),
171 comment="Total score (/32)",
172 ),
173 SummaryElement(
174 name="distress",
175 coltype=Integer(),
176 value=self.distress_score(),
177 comment="Distress score (/160)",
178 ),
179 SummaryElement(
180 name="intrusiveness",
181 coltype=Integer(),
182 value=self.intrusiveness_score(),
183 comment="Intrusiveness score (/160)",
184 ),
185 SummaryElement(
186 name="frequency",
187 coltype=Integer(),
188 value=self.frequency_score(),
189 comment="Frequency score (/160)",
190 ),
191 ]
193 def is_question_complete(self, q: int) -> bool:
194 if getattr(self, "endorse" + str(q)) is None:
195 return False
196 if getattr(self, "endorse" + str(q)):
197 if getattr(self, "distress" + str(q)) is None:
198 return False
199 if getattr(self, "intrusiveness" + str(q)) is None:
200 return False
201 if getattr(self, "frequency" + str(q)) is None:
202 return False
203 return True
205 def is_complete(self) -> bool:
206 if not self.field_contents_valid():
207 return False
208 for i in range(1, Caps.NQUESTIONS + 1):
209 if not self.is_question_complete(i):
210 return False
211 return True
213 def total_score(self) -> int:
214 return self.count_booleans(self.ENDORSE_FIELDS)
216 def distress_score(self) -> int:
217 score = 0
218 for q in range(1, Caps.NQUESTIONS + 1):
219 if (
220 getattr(self, "endorse" + str(q))
221 and getattr(self, "distress" + str(q)) is not None
222 ):
223 score += cast(int, self.sum_fields(["distress" + str(q)]))
224 return score
226 def intrusiveness_score(self) -> int:
227 score = 0
228 for q in range(1, Caps.NQUESTIONS + 1):
229 if (
230 getattr(self, "endorse" + str(q))
231 and getattr(self, "intrusiveness" + str(q)) is not None
232 ):
233 score += cast(int, self.sum_fields(["intrusiveness" + str(q)]))
234 return score
236 def frequency_score(self) -> int:
237 score = 0
238 for q in range(1, Caps.NQUESTIONS + 1):
239 if (
240 getattr(self, "endorse" + str(q))
241 and getattr(self, "frequency" + str(q)) is not None
242 ):
243 score += cast(int, self.sum_fields(["frequency" + str(q)]))
244 return score
246 def get_task_html(self, req: CamcopsRequest) -> str:
247 total = self.total_score()
248 distress = self.distress_score()
249 intrusiveness = self.intrusiveness_score()
250 frequency = self.frequency_score()
252 q_a = ""
253 for q in range(1, Caps.NQUESTIONS + 1):
254 q_a += tr(
255 self.wxstring(req, "q" + str(q)),
256 answer(
257 get_yes_no_none(req, getattr(self, "endorse" + str(q)))
258 ),
259 answer(
260 getattr(self, "distress" + str(q))
261 if getattr(self, "endorse" + str(q))
262 else ""
263 ),
264 answer(
265 getattr(self, "intrusiveness" + str(q))
266 if getattr(self, "endorse" + str(q))
267 else ""
268 ),
269 answer(
270 getattr(self, "frequency" + str(q))
271 if getattr(self, "endorse" + str(q))
272 else ""
273 ),
274 )
276 tr_total_score = tr_qa(
277 f"{req.sstring(SS.TOTAL_SCORE)} <sup>[1]</sup> (0–32)", total
278 )
279 tr_distress = tr_qa(
280 "{} (0–160)".format(self.wxstring(req, "distress")), distress
281 )
282 tr_intrusiveness = tr_qa(
283 "{} (0–160)".format(self.wxstring(req, "intrusiveness")),
284 intrusiveness,
285 )
286 tr_frequency = tr_qa(
287 "{} (0–160)".format(self.wxstring(req, "frequency")), frequency
288 )
289 return f"""
290 <div class="{CssClass.SUMMARY}">
291 <table class="{CssClass.SUMMARY}">
292 {self.get_is_complete_tr(req)}
293 {tr_total_score}
294 {tr_distress}
295 {tr_intrusiveness}
296 {tr_frequency}
297 </table>
298 </div>
299 <div class="{CssClass.EXPLANATION}">
300 Anchor points:
301 DISTRESS
302 {self.wxstring(req, "distress_option1")},
303 {self.wxstring(req, "distress_option5")}.
304 INTRUSIVENESS
305 {self.wxstring(req, "intrusiveness_option1")},
306 {self.wxstring(req, "intrusiveness_option5")}.
307 FREQUENCY
308 {self.wxstring(req, "frequency_option1")},
309 {self.wxstring(req, "frequency_option5")}.
310 </div>
311 <table class="{CssClass.TASKDETAIL}">
312 <tr>
313 <th width="60%">Question</th>
314 <th width="10%">Endorsed?</th>
315 <th width="10%">Distress (1–5)</th>
316 <th width="10%">Intrusiveness (1–5)</th>
317 <th width="10%">Frequency (1–5)</th>
318 </tr>
319 </table>
320 <div class="{CssClass.FOOTNOTES}">
321 [1] Total score: sum of endorsements (yes = 1, no = 0).
322 Dimension scores: sum of ratings (0 if not endorsed).
323 (Bell et al. 2006, PubMed ID 16237200)
324 </div>
325 <div class="{CssClass.COPYRIGHT}">
326 CAPS: Copyright © 2005, Bell, Halligan & Ellis.
327 Original article:
328 Bell V, Halligan PW, Ellis HD (2006).
329 The Cardiff Anomalous Perceptions Scale (CAPS): a new
330 validated measure of anomalous perceptual experience.
331 Schizophrenia Bulletin 32: 366–377.
332 Published by Oxford University Press on behalf of the Maryland
333 Psychiatric Research Center. All rights reserved. The online
334 version of this article has been published under an open access
335 model. Users are entitled to use, reproduce, disseminate, or
336 display the open access version of this article for
337 non-commercial purposes provided that: the original authorship
338 is properly and fully attributed; the Journal and Oxford
339 University Press are attributed as the original place of
340 publication with the correct citation details given; if an
341 article is subsequently reproduced or disseminated not in its
342 entirety but only in part or as a derivative work this must be
343 clearly indicated. For commercial re-use, please contact
344 journals.permissions@oxfordjournals.org.<br>
345 <b>This is a derivative work (partial reproduction, viz. the
346 scale text).</b>
347 </div>
348 """