Coverage for tasks/slums.py: 55%
106 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/slums.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26"""
28from typing import cast, List, Optional
30from sqlalchemy.orm import Mapped, mapped_column
31from sqlalchemy.sql.sqltypes import Integer, UnicodeText
33from camcops_server.cc_modules.cc_blob import (
34 Blob,
35 blob_relationship,
36 get_blob_img_html,
37)
38from camcops_server.cc_modules.cc_constants import CssClass
39from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
40from 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)
48from camcops_server.cc_modules.cc_request import CamcopsRequest
49from 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)
56from camcops_server.cc_modules.cc_summaryelement import SummaryElement
57from camcops_server.cc_modules.cc_task import (
58 Task,
59 TaskHasClinicianMixin,
60 TaskHasPatientMixin,
61)
62from camcops_server.cc_modules.cc_text import SS
63from camcops_server.cc_modules.cc_trackerhelpers import (
64 TrackerInfo,
65 TrackerLabel,
66)
69# =============================================================================
70# SLUMS
71# =============================================================================
73ZERO_OR_TWO_CHECKER = PermittedValueChecker(permitted_values=[0, 2])
76class Slums(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501
77 """
78 Server implementation of the SLUMS task.
79 """
81 __tablename__ = "slums"
82 shortname = "SLUMS"
83 provides_trackers = True
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 )
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
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 )
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]
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
227 @staticmethod
228 def longname(req: "CamcopsRequest") -> str:
229 _ = req.gettext
230 return _("St Louis University Mental Status")
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 ]
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 ]
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 ]
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 )
290 def total_score(self) -> int:
291 return cast(int, self.sum_fields(self.SCORED_FIELDS))
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")
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