Coverage for tasks/pcl5.py: 50%

94 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/pcl5.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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

25 

26=============================================================================== 

27 

28""" 

29 

30from typing import Any, Dict, List, Tuple, Type 

31 

32from cardinal_pythonlib.classes import classproperty 

33from cardinal_pythonlib.stringfunc import strseq 

34from semantic_version import Version 

35from sqlalchemy.ext.declarative import DeclarativeMeta 

36from sqlalchemy.sql.sqltypes import Boolean, Integer 

37 

38from camcops_server.cc_modules.cc_constants import CssClass 

39from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE 

40from camcops_server.cc_modules.cc_db import add_multiple_columns 

41from camcops_server.cc_modules.cc_html import ( 

42 answer, 

43 get_yes_no, 

44 subheading_spanning_two_columns, 

45 tr, 

46 tr_qa, 

47) 

48from camcops_server.cc_modules.cc_request import CamcopsRequest 

49 

50from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

51from camcops_server.cc_modules.cc_task import ( 

52 get_from_dict, 

53 Task, 

54 TaskHasPatientMixin, 

55) 

56from camcops_server.cc_modules.cc_text import SS 

57from camcops_server.cc_modules.cc_trackerhelpers import ( 

58 equally_spaced_int, 

59 regular_tracker_axis_ticks_int, 

60 TrackerInfo, 

61 TrackerLabel, 

62) 

63 

64 

65# ============================================================================= 

66# PCL-5 

67# ============================================================================= 

68 

69 

70class Pcl5Metaclass(DeclarativeMeta): 

71 """ 

72 There is a multilayer metaclass problem; see hads.py for discussion. 

73 """ 

74 

75 # noinspection PyInitNewSignature 

76 def __init__( 

77 cls: Type["Pcl5"], 

78 name: str, 

79 bases: Tuple[Type, ...], 

80 classdict: Dict[str, Any], 

81 ) -> None: 

82 add_multiple_columns( 

83 cls, 

84 "q", 

85 1, 

86 cls.N_QUESTIONS, 

87 minimum=0, 

88 maximum=4, 

89 comment_fmt="Q{n} ({s}) (0 not at all - 4 extremely)", 

90 comment_strings=[ 

91 "disturbing memories/thoughts/images", 

92 "disturbing dreams", 

93 "reliving", 

94 "upset at reminders", 

95 "physical reactions to reminders", 

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

97 "avoid activities/situations because they remind", 

98 "trouble remembering important parts of stressful event", 

99 "strong negative beliefs about self/others/world", 

100 "blaming", 

101 "strong negative emotions", 

102 "loss of interest in previously enjoyed activities", 

103 "feeling distant / cut off from people", 

104 "feeling emotionally numb", 

105 "irritable, angry and/or aggressive", 

106 "risk-taking and/or self-harming behaviour", 

107 "super alert/on guard", 

108 "jumpy/easily startled", 

109 "difficulty concentrating", 

110 "hard to sleep", 

111 ], 

112 ) 

113 super().__init__(name, bases, classdict) 

114 

115 

116class Pcl5(TaskHasPatientMixin, Task, metaclass=Pcl5Metaclass): 

117 """ 

118 Server implementation of the PCL-5 task. 

119 """ 

120 

121 __tablename__ = "pcl5" 

122 shortname = "PCL-5" 

123 provides_trackers = True 

124 extrastring_taskname = "pcl5" 

125 N_QUESTIONS = 20 

126 SCORED_FIELDS = strseq("q", 1, N_QUESTIONS) 

127 TASK_FIELDS = SCORED_FIELDS # may be overridden 

128 MIN_SCORE = 0 

129 MAX_SCORE = 4 * N_QUESTIONS 

130 

131 @staticmethod 

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

133 _ = req.gettext 

134 return _("PTSD Checklist, DSM-5 version") 

135 

136 # noinspection PyMethodParameters 

137 @classproperty 

138 def minimum_client_version(cls) -> Version: 

139 return Version("2.2.8") 

140 

141 def is_complete(self) -> bool: 

142 return ( 

143 self.all_fields_not_none(self.TASK_FIELDS) 

144 and self.field_contents_valid() 

145 ) 

146 

147 def total_score(self) -> int: 

148 return self.sum_fields(self.SCORED_FIELDS) 

149 

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

151 line_step = 20 

152 preliminary_cutoff = 33 

153 return [ 

154 TrackerInfo( 

155 value=self.total_score(), 

156 plot_label="PCL-5 total score", 

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

158 axis_min=self.MIN_SCORE - 0.5, 

159 axis_max=self.MAX_SCORE + 0.5, 

160 axis_ticks=regular_tracker_axis_ticks_int( 

161 self.MIN_SCORE, self.MAX_SCORE, step=line_step 

162 ), 

163 horizontal_lines=equally_spaced_int( 

164 self.MIN_SCORE + line_step, 

165 self.MAX_SCORE - line_step, 

166 step=line_step, 

167 ) 

168 + [preliminary_cutoff], 

169 horizontal_labels=[ 

170 TrackerLabel( 

171 preliminary_cutoff, 

172 self.wxstring(req, "preliminary_cutoff"), 

173 ) 

174 ], 

175 ) 

176 ] 

177 

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

179 if not self.is_complete(): 

180 return CTV_INCOMPLETE 

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

182 

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

184 return self.standard_task_summary_fields() + [ 

185 SummaryElement( 

186 name="total", 

187 coltype=Integer(), 

188 value=self.total_score(), 

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

190 ), 

191 SummaryElement( 

192 name="num_symptomatic", 

193 coltype=Integer(), 

194 value=self.num_symptomatic(), 

195 comment="Total number of symptoms considered symptomatic " 

196 "(meaning scoring 2 or more)", 

197 ), 

198 SummaryElement( 

199 name="num_symptomatic_B", 

200 coltype=Integer(), 

201 value=self.num_symptomatic_b(), 

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

203 "(meaning scoring 2 or more)", 

204 ), 

205 SummaryElement( 

206 name="num_symptomatic_C", 

207 coltype=Integer(), 

208 value=self.num_symptomatic_c(), 

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

210 "(meaning scoring 2 or more)", 

211 ), 

212 SummaryElement( 

213 name="num_symptomatic_D", 

214 coltype=Integer(), 

215 value=self.num_symptomatic_d(), 

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

217 "(meaning scoring 2 or more)", 

218 ), 

219 SummaryElement( 

220 name="num_symptomatic_E", 

221 coltype=Integer(), 

222 value=self.num_symptomatic_e(), 

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

224 "(meaning scoring 2 or more)", 

225 ), 

226 SummaryElement( 

227 name="ptsd", 

228 coltype=Boolean(), 

229 value=self.ptsd(), 

230 comment="Provisionally meets DSM-5 criteria for PTSD", 

231 ), 

232 ] 

233 

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

235 n = 0 

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

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

238 if value is not None and value >= 2: 

239 n += 1 

240 return n 

241 

242 def num_symptomatic(self) -> int: 

243 return self.get_num_symptomatic(1, self.N_QUESTIONS) 

244 

245 def num_symptomatic_b(self) -> int: 

246 return self.get_num_symptomatic(1, 5) 

247 

248 def num_symptomatic_c(self) -> int: 

249 return self.get_num_symptomatic(6, 7) 

250 

251 def num_symptomatic_d(self) -> int: 

252 return self.get_num_symptomatic(8, 14) 

253 

254 def num_symptomatic_e(self) -> int: 

255 return self.get_num_symptomatic(15, 20) 

256 

257 def ptsd(self) -> bool: 

258 num_symptomatic_b = self.num_symptomatic_b() 

259 num_symptomatic_c = self.num_symptomatic_c() 

260 num_symptomatic_d = self.num_symptomatic_d() 

261 num_symptomatic_e = self.num_symptomatic_e() 

262 return ( 

263 num_symptomatic_b >= 1 

264 and num_symptomatic_c >= 1 

265 and num_symptomatic_d >= 2 

266 and num_symptomatic_e >= 2 

267 ) 

268 

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

270 score = self.total_score() 

271 num_symptomatic = self.num_symptomatic() 

272 num_symptomatic_b = self.num_symptomatic_b() 

273 num_symptomatic_c = self.num_symptomatic_c() 

274 num_symptomatic_d = self.num_symptomatic_d() 

275 num_symptomatic_e = self.num_symptomatic_e() 

276 ptsd = self.ptsd() 

277 answer_dict = {None: None} 

278 for option in range(5): 

279 answer_dict[option] = ( 

280 str(option) + " – " + self.wxstring(req, "a" + str(option)) 

281 ) 

282 q_a = "" 

283 

284 section_start = { 

285 1: "B (intrusion symptoms)", 

286 6: "C (avoidance)", 

287 8: "D (negative cognition/mood)", 

288 15: "E (arousal/reactivity)", 

289 } 

290 

291 for q in range(1, self.N_QUESTIONS + 1): 

292 if q in section_start: 

293 section = section_start[q] 

294 q_a += subheading_spanning_two_columns( 

295 f"DSM-5 section {section}" 

296 ) 

297 

298 q_a += tr_qa( 

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

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

301 ) 

302 

303 h = """ 

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

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

306 {tr_is_complete} 

307 {total_score} 

308 {num_symptomatic} 

309 {dsm_criteria_met} 

310 </table> 

311 </div> 

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

313 <tr> 

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

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

316 </tr> 

317 {q_a} 

318 </table> 

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

320 [1] Questions with scores ≥2 are considered symptomatic; see 

321 https://www.ptsd.va.gov/professional/assessment/adult-sr/ptsd-checklist.asp 

322 [2] ≥1 ‘B’ symptoms and ≥1 ‘C’ symptoms and ≥2 ‘D’ symptoms 

323 and ≥2 ‘E’ symptoms. 

324 </div> 

325 """.format( # noqa 

326 CssClass=CssClass, 

327 tr_is_complete=self.get_is_complete_tr(req), 

328 total_score=tr_qa(f"{req.sstring(SS.TOTAL_SCORE)} (0–80)", score), 

329 num_symptomatic=tr( 

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

331 answer(num_symptomatic_b) 

332 + ", " 

333 + answer(num_symptomatic_c) 

334 + ", " 

335 + answer(num_symptomatic_d) 

336 + ", " 

337 + answer(num_symptomatic_e) 

338 + " (" 

339 + answer(num_symptomatic) 

340 + ")", 

341 ), 

342 dsm_criteria_met=tr_qa( 

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

344 get_yes_no(req, ptsd), 

345 ), 

346 q_a=q_a, 

347 ) 

348 return h