Coverage for tasks/pcl.py: 56%

108 statements  

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

1""" 

2camcops_server/tasks/pcl.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 abc import ABC 

29from typing import Any, cast, List, Optional, Type 

30 

31from cardinal_pythonlib.stringfunc import strseq 

32from sqlalchemy.orm import Mapped, mapped_column 

33from sqlalchemy.sql.sqltypes import Boolean, Integer, UnicodeText 

34 

35from camcops_server.cc_modules.cc_constants import CssClass 

36from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

37from camcops_server.cc_modules.cc_db import add_multiple_columns 

38from camcops_server.cc_modules.cc_html import ( 

39 answer, 

40 get_yes_no, 

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_summaryelement import SummaryElement 

47from camcops_server.cc_modules.cc_task import ( 

48 get_from_dict, 

49 Task, 

50 TaskHasPatientMixin, 

51) 

52from camcops_server.cc_modules.cc_text import SS 

53from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

54 

55 

56# ============================================================================= 

57# PCL 

58# ============================================================================= 

59 

60 

61class PclCommon(TaskHasPatientMixin, Task, ABC): # type: ignore[misc] 

62 __abstract__ = True 

63 provides_trackers = True 

64 extrastring_taskname = "pcl" 

65 info_filename_stem = extrastring_taskname 

66 

67 NQUESTIONS = 17 

68 SCORED_FIELDS = strseq("q", 1, NQUESTIONS) 

69 TASK_FIELDS = SCORED_FIELDS # may be overridden 

70 TASK_TYPE = "?" # will be overridden 

71 # ... not really used; we display the generic question forms on the server 

72 MIN_SCORE = NQUESTIONS 

73 MAX_SCORE = 5 * NQUESTIONS 

74 

75 # noinspection PyInitNewSignature 

76 

77 @classmethod 

78 def extend_columns(cls: Type["PclCommon"], **kwargs: Any) -> None: 

79 add_multiple_columns( 

80 cls, 

81 "q", 

82 1, 

83 cls.NQUESTIONS, 

84 minimum=1, 

85 maximum=5, 

86 comment_fmt="Q{n} ({s}) (1 not at all - 5 extremely)", 

87 comment_strings=[ 

88 "disturbing memories/thoughts/images", 

89 "disturbing dreams", 

90 "reliving", 

91 "upset at reminders", 

92 "physical reactions to reminders", 

93 "avoid thinking/talking/feelings relating to experience", 

94 "avoid activities/situations because they remind", 

95 "trouble remembering important parts of stressful event", 

96 "loss of interest in previously enjoyed activities", 

97 "feeling distant/cut off from people", 

98 "feeling emotionally numb", 

99 "feeling future will be cut short", 

100 "hard to sleep", 

101 "irritable", 

102 "difficulty concentrating", 

103 "super alert/on guard", 

104 "jumpy/easily startled", 

105 ], 

106 ) 

107 

108 def is_complete(self) -> bool: 

109 return ( 

110 self.all_fields_not_none(self.TASK_FIELDS) 

111 and self.field_contents_valid() 

112 ) 

113 

114 def total_score(self) -> int: 

115 return cast(int, self.sum_fields(self.SCORED_FIELDS)) 

116 

117 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

118 return [ 

119 TrackerInfo( 

120 value=self.total_score(), 

121 plot_label="PCL total score", 

122 axis_label=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})", 

123 axis_min=self.MIN_SCORE - 0.5, 

124 axis_max=self.MAX_SCORE + 0.5, 

125 ) 

126 ] 

127 

128 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

129 if not self.is_complete(): 

130 return CTV_INCOMPLETE 

131 return [CtvInfo(content=f"PCL total score {self.total_score()}")] 

132 

133 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

134 return self.standard_task_summary_fields() + [ 

135 SummaryElement( 

136 name="total", 

137 coltype=Integer(), 

138 value=self.total_score(), 

139 comment=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})", 

140 ), 

141 SummaryElement( 

142 name="num_symptomatic", 

143 coltype=Integer(), 

144 value=self.num_symptomatic(), 

145 comment="Total number of symptoms considered symptomatic " 

146 "(meaning scoring 3 or more)", 

147 ), 

148 SummaryElement( 

149 name="num_symptomatic_B", 

150 coltype=Integer(), 

151 value=self.num_symptomatic_b(), 

152 comment="Number of group B symptoms considered symptomatic " 

153 "(meaning scoring 3 or more)", 

154 ), 

155 SummaryElement( 

156 name="num_symptomatic_C", 

157 coltype=Integer(), 

158 value=self.num_symptomatic_c(), 

159 comment="Number of group C symptoms considered symptomatic " 

160 "(meaning scoring 3 or more)", 

161 ), 

162 SummaryElement( 

163 name="num_symptomatic_D", 

164 coltype=Integer(), 

165 value=self.num_symptomatic_d(), 

166 comment="Number of group D symptoms considered symptomatic " 

167 "(meaning scoring 3 or more)", 

168 ), 

169 SummaryElement( 

170 name="ptsd", 

171 coltype=Boolean(), 

172 value=self.ptsd(), 

173 comment="Meets DSM-IV criteria for PTSD", 

174 ), 

175 ] 

176 

177 def get_num_symptomatic(self, first: int, last: int) -> int: 

178 n = 0 

179 for i in range(first, last + 1): 

180 value = getattr(self, "q" + str(i)) 

181 if value is not None and value >= 3: 

182 n += 1 

183 return n 

184 

185 def num_symptomatic(self) -> int: 

186 return self.get_num_symptomatic(1, self.NQUESTIONS) 

187 

188 def num_symptomatic_b(self) -> int: 

189 return self.get_num_symptomatic(1, 5) 

190 

191 def num_symptomatic_c(self) -> int: 

192 return self.get_num_symptomatic(6, 12) 

193 

194 def num_symptomatic_d(self) -> int: 

195 return self.get_num_symptomatic(13, 17) 

196 

197 def ptsd(self) -> bool: 

198 num_symptomatic_b = self.num_symptomatic_b() 

199 num_symptomatic_c = self.num_symptomatic_c() 

200 num_symptomatic_d = self.num_symptomatic_d() 

201 return ( 

202 num_symptomatic_b >= 1 

203 and num_symptomatic_c >= 3 

204 and num_symptomatic_d >= 2 

205 ) 

206 

207 def get_task_html(self, req: CamcopsRequest) -> str: 

208 score = self.total_score() 

209 num_symptomatic = self.num_symptomatic() 

210 num_symptomatic_b = self.num_symptomatic_b() 

211 num_symptomatic_c = self.num_symptomatic_c() 

212 num_symptomatic_d = self.num_symptomatic_d() 

213 ptsd = self.ptsd() 

214 answer_dict: dict[Optional[int], Optional[str]] = {None: None} 

215 for option in range(1, 6): 

216 answer_dict[option] = ( 

217 str(option) 

218 + " – " 

219 + self.wxstring(req, "option" + str(option)) 

220 ) 

221 q_a = "" 

222 if hasattr(self, "event") and hasattr(self, "eventdate"): 

223 # PCL-S 

224 q_a += tr_qa(self.wxstring(req, "s_event_s"), self.event) 

225 q_a += tr_qa(self.wxstring(req, "s_eventdate_s"), self.eventdate) 

226 for q in range(1, self.NQUESTIONS + 1): 

227 if q == 1 or q == 6 or q == 13: 

228 section = "B" if q == 1 else ("C" if q == 6 else "D") 

229 q_a += subheading_spanning_two_columns( 

230 f"DSM-IV-TR section {section}" 

231 ) 

232 q_a += tr_qa( 

233 self.wxstring(req, "q" + str(q) + "_s"), 

234 get_from_dict(answer_dict, getattr(self, "q" + str(q))), 

235 ) 

236 h = """ 

237 <div class="{CssClass.SUMMARY}"> 

238 <table class="{CssClass.SUMMARY}"> 

239 {tr_is_complete} 

240 {total_score} 

241 {num_symptomatic} 

242 {dsm_criteria_met} 

243 </table> 

244 </div> 

245 <table class="{CssClass.TASKDETAIL}"> 

246 <tr> 

247 <th width="70%">Question</th> 

248 <th width="30%">Answer</th> 

249 </tr> 

250 {q_a} 

251 </table> 

252 <div class="{CssClass.FOOTNOTES}"> 

253 [1] Questions with scores ≥3 are considered symptomatic. 

254 [2] ≥1 ‘B’ symptoms and ≥3 ‘C’ symptoms and 

255 ≥2 ‘D’ symptoms. 

256 </div> 

257 """.format( 

258 CssClass=CssClass, 

259 tr_is_complete=self.get_is_complete_tr(req), 

260 total_score=tr_qa(f"{req.sstring(SS.TOTAL_SCORE)} (17–85)", score), 

261 num_symptomatic=tr( 

262 "Number symptomatic <sup>[1]</sup>: B, C, D (total)", 

263 answer(num_symptomatic_b) 

264 + ", " 

265 + answer(num_symptomatic_c) 

266 + ", " 

267 + answer(num_symptomatic_d) 

268 + " (" 

269 + answer(num_symptomatic) 

270 + ")", 

271 ), 

272 dsm_criteria_met=tr_qa( 

273 self.wxstring(req, "dsm_criteria_met") + " <sup>[2]</sup>", 

274 get_yes_no(req, ptsd), 

275 ), 

276 q_a=q_a, 

277 ) 

278 return h 

279 

280 

281# ============================================================================= 

282# PCL-C 

283# ============================================================================= 

284 

285 

286class PclC(PclCommon): 

287 """ 

288 Server implementation of the PCL-C task. 

289 """ 

290 

291 __tablename__ = "pclc" 

292 shortname = "PCL-C" 

293 

294 TASK_TYPE = "C" 

295 

296 @staticmethod 

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

298 _ = req.gettext 

299 return _("PTSD Checklist, Civilian version") 

300 

301 

302# ============================================================================= 

303# PCL-M 

304# ============================================================================= 

305 

306 

307class PclM(PclCommon): 

308 """ 

309 Server implementation of the PCL-M task. 

310 """ 

311 

312 __tablename__ = "pclm" 

313 shortname = "PCL-M" 

314 

315 TASK_TYPE = "M" 

316 

317 @staticmethod 

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

319 _ = req.gettext 

320 return _("PTSD Checklist, Military version") 

321 

322 

323# ============================================================================= 

324# PCL-S 

325# ============================================================================= 

326 

327 

328class PclS(PclCommon): 

329 """ 

330 Server implementation of the PCL-S task. 

331 """ 

332 

333 __tablename__ = "pcls" 

334 shortname = "PCL-S" 

335 

336 event: Mapped[Optional[str]] = mapped_column( 

337 UnicodeText, comment="Traumatic event" 

338 ) 

339 eventdate: Mapped[Optional[str]] = mapped_column( 

340 "eventdate", UnicodeText, comment="Date of traumatic event (free text)" 

341 ) 

342 

343 TASK_FIELDS = PclCommon.SCORED_FIELDS + ["event", "eventdate"] 

344 TASK_TYPE = "S" 

345 

346 @staticmethod 

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

348 _ = req.gettext 

349 return _("PTSD Checklist, Stressor-specific version")