Coverage for tasks/pcl5.py: 49%

1"""

2 camcops_server/tasks/pcl5.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

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

29

30 from cardinal_pythonlib.classes import classproperty

31 from cardinal_pythonlib.stringfunc import strseq

32 from semantic_version import Version

33 from sqlalchemy.sql.sqltypes import Boolean, Integer

34

35 from camcops_server.cc_modules.cc_constants import CssClass

36 from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE

37 from camcops_server.cc_modules.cc_db import add_multiple_columns

38 from camcops_server.cc_modules.cc_html import (

39 answer,

40 get_yes_no,

41 subheading_spanning_two_columns,

42 tr,

43 tr_qa,

44)

45 from camcops_server.cc_modules.cc_request import CamcopsRequest

46

47 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

48 from camcops_server.cc_modules.cc_task import (

49 get_from_dict,

50 Task,

51 TaskHasPatientMixin,

52)

53 from camcops_server.cc_modules.cc_text import SS

54 from camcops_server.cc_modules.cc_trackerhelpers import (

55 equally_spaced_int,

56 regular_tracker_axis_ticks_int,

57 TrackerInfo,

58 TrackerLabel,

59)

60

61

62# =============================================================================

63# PCL-5

64# =============================================================================

65

66

67 class Pcl5( # type: ignore[misc]

68 TaskHasPatientMixin,

69 Task,

70):

71 """

72 Server implementation of the PCL-5 task.

73 """

74

75 __tablename__ = "pcl5"

76 shortname = "PCL-5"

77 provides_trackers = True

78 extrastring_taskname = "pcl5"

79 N_QUESTIONS = 20

80

81 @classmethod

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

83 add_multiple_columns(

84 cls,

85 "q",

86 1,

87 cls.N_QUESTIONS,

88 minimum=0,

89 maximum=4,

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

91 comment_strings=[

92 "disturbing memories/thoughts/images",

93 "disturbing dreams",

94 "reliving",

95 "upset at reminders",

96 "physical reactions to reminders",

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

98 "avoid activities/situations because they remind",

99 "trouble remembering important parts of stressful event",

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

101 "blaming",

102 "strong negative emotions",

103 "loss of interest in previously enjoyed activities",

104 "feeling distant / cut off from people",

105 "feeling emotionally numb",

106 "irritable, angry and/or aggressive",

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

108 "super alert/on guard",

109 "jumpy/easily startled",

110 "difficulty concentrating",

111 "hard to sleep",

112 ],

113 )

114

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

116 TASK_FIELDS = SCORED_FIELDS # may be overridden

117 MIN_SCORE = 0

118 MAX_SCORE = 4 * N_QUESTIONS

119

120 @staticmethod

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

122 _ = req.gettext

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

124

125 # noinspection PyMethodParameters

126 @classproperty

127 def minimum_client_version(cls) -> Version:

128 return Version("2.2.8")

129

130 def is_complete(self) -> bool:

131 return (

132 self.all_fields_not_none(self.TASK_FIELDS)

133 and self.field_contents_valid()

134 )

135

136 def total_score(self) -> int:

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

138

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

140 line_step = 20

141 preliminary_cutoff = 33

142 return [

143 TrackerInfo(

144 value=self.total_score(),

145 plot_label="PCL-5 total score",

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

147 axis_min=self.MIN_SCORE - 0.5,

148 axis_max=self.MAX_SCORE + 0.5,

149 axis_ticks=regular_tracker_axis_ticks_int(

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

151 ),

152 horizontal_lines=equally_spaced_int(

153 self.MIN_SCORE + line_step,

154 self.MAX_SCORE - line_step,

155 step=line_step,

156 )

157 + [preliminary_cutoff],

158 horizontal_labels=[

159 TrackerLabel(

160 preliminary_cutoff,

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

162 )

163 ],

164 )

165 ]

166

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

168 if not self.is_complete():

169 return CTV_INCOMPLETE

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

171

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

173 return self.standard_task_summary_fields() + [

174 SummaryElement(

175 name="total",

176 coltype=Integer(),

177 value=self.total_score(),

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

179 ),

180 SummaryElement(

181 name="num_symptomatic",

182 coltype=Integer(),

183 value=self.num_symptomatic(),

184 comment="Total number of symptoms considered symptomatic "

185 "(meaning scoring 2 or more)",

186 ),

187 SummaryElement(

188 name="num_symptomatic_B",

189 coltype=Integer(),

190 value=self.num_symptomatic_b(),

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

192 "(meaning scoring 2 or more)",

193 ),

194 SummaryElement(

195 name="num_symptomatic_C",

196 coltype=Integer(),

197 value=self.num_symptomatic_c(),

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

199 "(meaning scoring 2 or more)",

200 ),

201 SummaryElement(

202 name="num_symptomatic_D",

203 coltype=Integer(),

204 value=self.num_symptomatic_d(),

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

206 "(meaning scoring 2 or more)",

207 ),

208 SummaryElement(

209 name="num_symptomatic_E",

210 coltype=Integer(),

211 value=self.num_symptomatic_e(),

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

213 "(meaning scoring 2 or more)",

214 ),

215 SummaryElement(

216 name="ptsd",

217 coltype=Boolean(),

218 value=self.ptsd(),

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

220 ),

221 ]

222

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

224 n = 0

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

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

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

228 n += 1

229 return n

230

231 def num_symptomatic(self) -> int:

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

233

234 def num_symptomatic_b(self) -> int:

235 return self.get_num_symptomatic(1, 5)

236

237 def num_symptomatic_c(self) -> int:

238 return self.get_num_symptomatic(6, 7)

239

240 def num_symptomatic_d(self) -> int:

241 return self.get_num_symptomatic(8, 14)

242

243 def num_symptomatic_e(self) -> int:

244 return self.get_num_symptomatic(15, 20)

245

246 def ptsd(self) -> bool:

247 num_symptomatic_b = self.num_symptomatic_b()

248 num_symptomatic_c = self.num_symptomatic_c()

249 num_symptomatic_d = self.num_symptomatic_d()

250 num_symptomatic_e = self.num_symptomatic_e()

251 return (

252 num_symptomatic_b >= 1

253 and num_symptomatic_c >= 1

254 and num_symptomatic_d >= 2

255 and num_symptomatic_e >= 2

256 )

257

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

259 score = self.total_score()

260 num_symptomatic = self.num_symptomatic()

261 num_symptomatic_b = self.num_symptomatic_b()

262 num_symptomatic_c = self.num_symptomatic_c()

263 num_symptomatic_d = self.num_symptomatic_d()

264 num_symptomatic_e = self.num_symptomatic_e()

265 ptsd = self.ptsd()

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

267 for option in range(5):

268 answer_dict[option] = (

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

270 )

271 q_a = ""

272

273 section_start = {

274 1: "B (intrusion symptoms)",

275 6: "C (avoidance)",

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

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

278 }

279

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

281 if q in section_start:

282 section = section_start[q]

283 q_a += subheading_spanning_two_columns(

284 f"DSM-5 section {section}"

285 )

286

287 q_a += tr_qa(

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

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

290 )

291

292 h = """

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

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

295 {tr_is_complete}

296 {total_score}

297 {num_symptomatic}

298 {dsm_criteria_met}

299 </table>

300 </div>

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

302 <tr>

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

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

305 </tr>

306 {q_a}

307 </table>

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

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

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

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

312 and ≥2 ‘E’ symptoms.

313 </div>

314 """.format(

315 CssClass=CssClass,

316 tr_is_complete=self.get_is_complete_tr(req),

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

318 num_symptomatic=tr(

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

320 answer(num_symptomatic_b)

321 + ", "

322 + answer(num_symptomatic_c)

323 + ", "

324 + answer(num_symptomatic_d)

325 + ", "

326 + answer(num_symptomatic_e)

327 + " ("

328 + answer(num_symptomatic)

329 + ")",

330 ),

331 dsm_criteria_met=tr_qa(

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

333 get_yes_no(req, ptsd),

334 ),

335 q_a=q_a,

336 )

337 return h