Coverage for tasks/pcl.py: 56%

1"""

2 camcops_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

28 from abc import ABC

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

30

31 from cardinal_pythonlib.stringfunc import strseq

32 from sqlalchemy.orm import Mapped, mapped_column

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

34

35 from camcops_server.cc_modules.cc_constants import CssClass

36 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

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 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

47 from camcops_server.cc_modules.cc_task import (

48 get_from_dict,

49 Task,

50 TaskHasPatientMixin,

51)

52 from camcops_server.cc_modules.cc_text import SS

53 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

54

55

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

57# PCL

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

59

60

61 class 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

286 class 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

307 class 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

328 class 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")