Coverage for tasks/slums.py: 55%

1"""

2 camcops_server/tasks/slums.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 cast, List, Optional

29

30 from sqlalchemy.orm import Mapped, mapped_column

31 from sqlalchemy.sql.sqltypes import Integer, UnicodeText

32

33 from camcops_server.cc_modules.cc_blob import (

34 Blob,

35 blob_relationship,

36 get_blob_img_html,

37)

38 from camcops_server.cc_modules.cc_constants import CssClass

39 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

40 from camcops_server.cc_modules.cc_html import (

41 answer,

42 get_yes_no_none,

43 subheading_spanning_two_columns,

44 td,

45 tr,

46 tr_qa,

47)

48 from camcops_server.cc_modules.cc_request import CamcopsRequest

49 from camcops_server.cc_modules.cc_sqla_coltypes import (

50 BIT_CHECKER,

51 mapped_camcops_column,

52 PermittedValueChecker,

53 SummaryCategoryColType,

54 ZERO_TO_THREE_CHECKER,

55)

56 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

57 from camcops_server.cc_modules.cc_task import (

58 Task,

59 TaskHasClinicianMixin,

60 TaskHasPatientMixin,

61)

62 from camcops_server.cc_modules.cc_text import SS

63 from camcops_server.cc_modules.cc_trackerhelpers import (

64 TrackerInfo,

65 TrackerLabel,

66)

67

68

69# =============================================================================

70# SLUMS

71# =============================================================================

72

73 ZERO_OR_TWO_CHECKER = PermittedValueChecker(permitted_values=[0, 2])

74

75

76 class Slums(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501

77 """

78 Server implementation of the SLUMS task.

79 """

80

81 __tablename__ = "slums"

82 shortname = "SLUMS"

83 provides_trackers = True

84

85 alert: Mapped[Optional[int]] = mapped_camcops_column(

86 permitted_value_checker=BIT_CHECKER,

87 comment="Is the patient alert? (0 no, 1 yes)",

88 )

89 highschooleducation: Mapped[Optional[int]] = mapped_camcops_column(

90 permitted_value_checker=BIT_CHECKER,

91 comment="Does that patient have at least a high-school level of "

92 "education? (0 no, 1 yes)",

93 )

94

95 q1: Mapped[Optional[int]] = mapped_camcops_column(

96 permitted_value_checker=BIT_CHECKER,

97 comment="Q1 (day) (0-1)",

98 )

99 q2: Mapped[Optional[int]] = mapped_camcops_column(

100 permitted_value_checker=BIT_CHECKER,

101 comment="Q2 (year) (0-1)",

102 )

103 q3: Mapped[Optional[int]] = mapped_camcops_column(

104 permitted_value_checker=BIT_CHECKER,

105 comment="Q3 (state) (0-1)",

106 )

107 q5a: Mapped[Optional[int]] = mapped_camcops_column(

108 permitted_value_checker=BIT_CHECKER,

109 comment="Q5a (money spent) (0-1)",

110 )

111 q5b: Mapped[Optional[int]] = mapped_camcops_column(

112 permitted_value_checker=ZERO_OR_TWO_CHECKER,

113 comment="Q5b (money left) (0 or 2)",

114 ) # worth 2 points

115 q6: Mapped[Optional[int]] = mapped_camcops_column(

116 permitted_value_checker=ZERO_TO_THREE_CHECKER,

117 comment="Q6 (animal naming) (0-3)",

118 ) # from 0 to 3 points

119 q7a: Mapped[Optional[int]] = mapped_camcops_column(

120 permitted_value_checker=BIT_CHECKER,

121 comment="Q7a (recall apple) (0-1)",

122 )

123 q7b: Mapped[Optional[int]] = mapped_camcops_column(

124 permitted_value_checker=BIT_CHECKER,

125 comment="Q7b (recall pen) (0-1)",

126 )

127 q7c: Mapped[Optional[int]] = mapped_camcops_column(

128 permitted_value_checker=BIT_CHECKER,

129 comment="Q7c (recall tie) (0-1)",

130 )

131 q7d: Mapped[Optional[int]] = mapped_camcops_column(

132 permitted_value_checker=BIT_CHECKER,

133 comment="Q7d (recall house) (0-1)",

134 )

135 q7e: Mapped[Optional[int]] = mapped_camcops_column(

136 permitted_value_checker=BIT_CHECKER,

137 comment="Q7e (recall car) (0-1)",

138 )

139 q8b: Mapped[Optional[int]] = mapped_camcops_column(

140 permitted_value_checker=BIT_CHECKER,

141 comment="Q8b (reverse 648) (0-1)",

142 )

143 q8c: Mapped[Optional[int]] = mapped_camcops_column(

144 permitted_value_checker=BIT_CHECKER,

145 comment="Q8c (reverse 8537) (0-1)",

146 )

147 q9a: Mapped[Optional[int]] = mapped_camcops_column(

148 permitted_value_checker=ZERO_OR_TWO_CHECKER,

149 comment="Q9a (clock - hour markers) (0 or 2)",

150 ) # worth 2 points

151 q9b: Mapped[Optional[int]] = mapped_camcops_column(

152 permitted_value_checker=ZERO_OR_TWO_CHECKER,

153 comment="Q9b (clock - time) (0 or 2)",

154 ) # worth 2 points

155 q10a: Mapped[Optional[int]] = mapped_camcops_column(

156 permitted_value_checker=BIT_CHECKER,

157 comment="Q10a (X in triangle) (0-1)",

158 )

159 q10b: Mapped[Optional[int]] = mapped_camcops_column(

160 permitted_value_checker=BIT_CHECKER,

161 comment="Q10b (biggest figure) (0-1)",

162 )

163 q11a: Mapped[Optional[int]] = mapped_camcops_column(

164 permitted_value_checker=ZERO_OR_TWO_CHECKER,

165 comment="Q11a (story - name) (0 or 2)",

166 ) # worth 2 points

167 q11b: Mapped[Optional[int]] = mapped_camcops_column(

168 permitted_value_checker=ZERO_OR_TWO_CHECKER,

169 comment="Q11b (story - occupation) (0 or 2)",

170 ) # worth 2 points

171 q11c: Mapped[Optional[int]] = mapped_camcops_column(

172 permitted_value_checker=ZERO_OR_TWO_CHECKER,

173 comment="Q11c (story - back to work) (0 or 2)",

174 ) # worth 2 points

175 q11d: Mapped[Optional[int]] = mapped_camcops_column(

176 permitted_value_checker=ZERO_OR_TWO_CHECKER,

177 comment="Q11d (story - state) (0 or 2)",

178 ) # worth 2 points

179

180 clockpicture_blobid: Mapped[Optional[int]] = mapped_camcops_column(

181 is_blob_id_field=True,

182 blob_relationship_attr_name="clockpicture",

183 comment="BLOB ID of clock picture",

184 )

185 shapespicture_blobid: Mapped[Optional[int]] = mapped_camcops_column(

186 is_blob_id_field=True,

187 blob_relationship_attr_name="shapespicture",

188 comment="BLOB ID of shapes picture",

189 )

190 comments: Mapped[Optional[str]] = mapped_column(

191 UnicodeText, comment="Clinician's comments"

192 )

193

194 clockpicture = blob_relationship( # type: ignore[assignment]

195 "Slums", "clockpicture_blobid"

196 ) # type: Optional[Blob]

197 shapespicture = blob_relationship( # type: ignore[assignment]

198 "Slums", "shapespicture_blobid"

199 ) # type: Optional[Blob]

200

201 PREAMBLE_FIELDS = ["alert", "highschooleducation"]

202 SCORED_FIELDS = [

203 "q1",

204 "q2",

205 "q3",

206 "q5a",

207 "q5b",

208 "q6",

209 "q7a",

210 "q7b",

211 "q7c",

212 "q7d",

213 "q7e",

214 "q8b",

215 "q8c",

216 "q9a",

217 "q9b",

218 "q10a",

219 "q10b",

220 "q11a",

221 "q11b",

222 "q11c",

223 "q11d",

224 ]

225 MAX_SCORE = 30

226

227 @staticmethod

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

229 _ = req.gettext

230 return _("St Louis University Mental Status")

231

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

233 if self.highschooleducation == 1:

234 hlines = [26.5, 20.5]

235 y_upper = 28.25

236 y_middle = 23.5

237 else:

238 hlines = [24.5, 19.5]

239 y_upper = 27.25

240 y_middle = 22

241 return [

242 TrackerInfo(

243 value=self.total_score(),

244 plot_label="SLUMS total score",

245 axis_label=f"Total score (out of {self.MAX_SCORE})",

246 axis_min=-0.5,

247 axis_max=self.MAX_SCORE + 0.5,

248 horizontal_lines=hlines,

249 horizontal_labels=[

250 TrackerLabel(y_upper, req.sstring(SS.NORMAL)),

251 TrackerLabel(y_middle, self.wxstring(req, "category_mci")),

252 TrackerLabel(17, self.wxstring(req, "category_dementia")),

253 ],

254 )

255 ]

256

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

258 if not self.is_complete():

259 return CTV_INCOMPLETE

260 return [

261 CtvInfo(

262 content=f"SLUMS total score "

263 f"{self.total_score()}/{self.MAX_SCORE} "

264 f"({self.category(req)})"

265 )

266 ]

267

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

269 return self.standard_task_summary_fields() + [

270 SummaryElement(

271 name="total",

272 coltype=Integer(),

273 value=self.total_score(),

274 comment=f"Total score (/{self.MAX_SCORE})",

275 ),

276 SummaryElement(

277 name="category",

278 coltype=SummaryCategoryColType,

279 value=self.category(req),

280 comment="Category",

281 ),

282 ]

283

284 def is_complete(self) -> bool:

285 return (

286 self.all_fields_not_none(self.PREAMBLE_FIELDS + self.SCORED_FIELDS)

287 and self.field_contents_valid()

288 )

289

290 def total_score(self) -> int:

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

292

293 def category(self, req: CamcopsRequest) -> str:

294 score = self.total_score()

295 if self.highschooleducation == 1:

296 if score >= 27:

297 return req.sstring(SS.NORMAL)

298 elif score >= 21:

299 return self.wxstring(req, "category_mci")

300 else:

301 return self.wxstring(req, "category_dementia")

302 else:

303 if score >= 25:

304 return req.sstring(SS.NORMAL)

305 elif score >= 20:

306 return self.wxstring(req, "category_mci")

307 else:

308 return self.wxstring(req, "category_dementia")

309

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

311 score = self.total_score()

312 category = self.category(req)

313 h = """

314 {clinician_comments}

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

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

317 {tr_is_complete}

318 {total_score}

319 {category}

320 </table>

321 </div>

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

323 <tr>

324 <th width="80%">Question</th>

325 <th width="20%">Score</th>

326 </tr>

327 """.format(

328 clinician_comments=self.get_standard_clinician_comments_block(

329 req, self.comments

330 ),

331 CssClass=CssClass,

332 tr_is_complete=self.get_is_complete_tr(req),

333 total_score=tr(

334 req.sstring(SS.TOTAL_SCORE),

335 answer(score) + f" / {self.MAX_SCORE}",

336 ),

337 category=tr_qa(

338 req.sstring(SS.CATEGORY) + " <sup>[1]</sup>", category

339 ),

340 )

341 h += tr_qa(

342 self.wxstring(req, "alert_s"), get_yes_no_none(req, self.alert)

343 )

344 h += tr_qa(

345 self.wxstring(req, "highschool_s"),

346 get_yes_no_none(req, self.highschooleducation),

347 )

348 h += tr_qa(self.wxstring(req, "q1_s"), self.q1)

349 h += tr_qa(self.wxstring(req, "q2_s"), self.q2)

350 h += tr_qa(self.wxstring(req, "q3_s"), self.q3)

351 h += tr(

352 "Q5 <sup>[2]</sup> (money spent, money left " "[<i>scores 2</i>]",

353 ", ".join(answer(x) for x in (self.q5a, self.q5b)),

354 )

355 h += tr_qa(

356 "Q6 (animal fluency) [<i>≥15 scores 3, 10–14 scores 2, "

357 "5–9 scores 1, 0–4 scores 0</i>]",

358 self.q6,

359 )

360 h += tr(

361 "Q7 (recall: apple, pen, tie, house, car)",

362 ", ".join(

363 answer(x)

364 for x in (self.q7a, self.q7b, self.q7c, self.q7d, self.q7e)

365 ),

366 )

367 h += tr(

368 "Q8 (backwards: 648, 8537)",

369 ", ".join(answer(x) for x in (self.q8b, self.q8c)),

370 )

371 h += tr(

372 "Q9 (clock: hour markers, time [<i>score 2 each</i>]",

373 ", ".join(answer(x) for x in (self.q9a, self.q9b)),

374 )

375 h += tr(

376 "Q10 (X in triangle; which is biggest?)",

377 ", ".join(answer(x) for x in (self.q10a, self.q10b)),

378 )

379 h += tr(

380 "Q11 (story: Female’s name? Job? When back to work? "

381 "State she lived in? [<i>score 2 each</i>])",

382 ", ".join(

383 answer(x) for x in (self.q11a, self.q11b, self.q11c, self.q11d)

384 ),

385 )

386 h += f"""

387 </table>

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

389 """

390 h += subheading_spanning_two_columns("Images of tests: clock, shapes")

391 # noinspection PyTypeChecker

392 h += tr(

393 td(

394 get_blob_img_html(self.clockpicture),

395 td_width="50%",

396 td_class=CssClass.PHOTO,

397 ),

398 td(

399 get_blob_img_html(self.shapespicture),

400 td_width="50%",

401 td_class=CssClass.PHOTO,

402 ),

403 literal=True,

404 )

405 h += f"""

406 </table>

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

408 [1] With high school education:

409 ≥27 normal, ≥21 MCI, ≤20 dementia.

410 Without high school education:

411 ≥25 normal, ≥20 MCI, ≤19 dementia.

412 (Tariq et al. 2006, PubMed ID 17068312.)

413 [2] Q4 (learning the five words) isn’t scored.

414 </div>

415 """

416 return h