Coverage for tasks/caps.py: 43%

84 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/tasks/caps.py 

3 

4=============================================================================== 

5 

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

8 

9 This file is part of CamCOPS. 

10 

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. 

15 

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. 

20 

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/>. 

23 

24=============================================================================== 

25 

26""" 

27 

28from typing import Any, cast, List, Type 

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.sql.sqltypes import Integer 

32 

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 

46 

47 

48# ============================================================================= 

49# CAPS 

50# ============================================================================= 

51 

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] 

86 

87 

88class Caps( # type: ignore[misc] 

89 TaskHasPatientMixin, 

90 Task, 

91): 

92 """ 

93 Server implementation of the CAPS task. 

94 """ 

95 

96 __tablename__ = "caps" 

97 shortname = "CAPS" 

98 provides_trackers = True 

99 

100 prohibits_commercial = True 

101 

102 NQUESTIONS = 32 

103 

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 ) 

146 

147 ENDORSE_FIELDS = strseq("endorse", 1, NQUESTIONS) 

148 

149 @staticmethod 

150 def longname(req: "CamcopsRequest") -> str: 

151 _ = req.gettext 

152 return _("Cardiff Anomalous Perceptions Scale") 

153 

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 ] 

164 

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 ] 

192 

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 

204 

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 

212 

213 def total_score(self) -> int: 

214 return self.count_booleans(self.ENDORSE_FIELDS) 

215 

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 

225 

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 

235 

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 

245 

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() 

251 

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 ) 

275 

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 """