Coverage for tasks/cbir.py: 49%

1"""

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

29

30 from cardinal_pythonlib.stringfunc import strseq

31 from sqlalchemy.orm import Mapped, mapped_column

32 from sqlalchemy.sql.sqltypes import Float, UnicodeText

33

34 from camcops_server.cc_modules.cc_constants import CssClass

35 from camcops_server.cc_modules.cc_db import add_multiple_columns

36 from camcops_server.cc_modules.cc_html import (

37 answer,

38 get_yes_no,

39 subheading_spanning_three_columns,

40 tr,

41)

42 from camcops_server.cc_modules.cc_request import CamcopsRequest

43 from camcops_server.cc_modules.cc_sqla_coltypes import (

44 BIT_CHECKER,

45 mapped_camcops_column,

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 TaskHasRespondentMixin,

53)

54

55

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

57# CBI-R

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

59

60 QUESTION_SNIPPETS = [

61 "memory: poor day to day memory", # 1

62 "memory: asks same questions",

63 "memory: loses things",

64 "memory: forgets familiar names",

65 "memory: forgets names of objects", # 5

66 "memory: poor concentration",

67 "memory: forgets day",

68 "memory: confused in unusual surroundings",

69 "everyday: electrical appliances",

70 "everyday: writing", # 10

71 "everyday: using telephone",

72 "everyday: making hot drink",

73 "everyday: money",

74 "self-care: grooming",

75 "self-care: dressing", # 15

76 "self-care: feeding",

77 "self-care: bathing",

78 "behaviour: inappropriate humour",

79 "behaviour: temper outbursts",

80 "behaviour: uncooperative", # 20

81 "behaviour: socially embarrassing",

82 "behaviour: tactless/suggestive",

83 "behaviour: impulsive",

84 "mood: cries",

85 "mood: sad/depressed", # 25

86 "mood: restless/agitated",

87 "mood: irritable",

88 "beliefs: visual hallucinations",

89 "beliefs: auditory hallucinations",

90 "beliefs: delusions", # 30

91 "eating: sweet tooth",

92 "eating: repetitive",

93 "eating: increased appetite",

94 "eating: table manners",

95 "sleep: disturbed at night", # 35

96 "sleep: daytime sleep increased",

97 "stereotypy/motor: rigid/fixed opinions",

98 "stereotypy/motor: routines",

99 "stereotypy/motor: preoccupied with time",

100 "stereotypy/motor: expression/catchphrase", # 40

101 "motivation: less enthusiasm in usual interests",

102 "motivation: no interest in new things",

103 "motivation: fails to contact friends/family",

104 "motivation: indifferent to family/friend concerns",

105 "motivation: reduced affection", # 45

106]

107

108

109 class CbiR( # type: ignore[misc]

110 TaskHasPatientMixin,

111 TaskHasRespondentMixin,

112 Task,

113):

114 """

115 Server implementation of the CBI-R task.

116 """

117

118 __tablename__ = "cbir"

119 shortname = "CBI-R"

120

121 @classmethod

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

123 add_multiple_columns(

124 cls,

125 "frequency",

126 1,

127 cls.NQUESTIONS,

128 comment_fmt="Frequency Q{n}, {s} (0-4, higher worse)",

129 minimum=cls.MIN_SCORE,

130 maximum=cls.MAX_SCORE,

131 comment_strings=QUESTION_SNIPPETS,

132 )

133 add_multiple_columns(

134 cls,

135 "distress",

136 1,

137 cls.NQUESTIONS,

138 comment_fmt="Distress Q{n}, {s} (0-4, higher worse)",

139 minimum=cls.MIN_SCORE,

140 maximum=cls.MAX_SCORE,

141 comment_strings=QUESTION_SNIPPETS,

142 )

143

144 confirm_blanks: Mapped[Optional[int]] = mapped_camcops_column(

145 permitted_value_checker=BIT_CHECKER,

146 comment="Respondent confirmed that blanks are deliberate (N/A) "

147 "(0/NULL no, 1 yes)",

148 )

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

150 UnicodeText, comment="Additional comments"

151 )

152

153 MIN_SCORE = 0

154 MAX_SCORE = 4

155 QNUMS_MEMORY = (1, 8) # tuple: first, last

156 QNUMS_EVERYDAY = (9, 13)

157 QNUMS_SELF = (14, 17)

158 QNUMS_BEHAVIOUR = (18, 23)

159 QNUMS_MOOD = (24, 27)

160 QNUMS_BELIEFS = (28, 30)

161 QNUMS_EATING = (31, 34)

162 QNUMS_SLEEP = (35, 36)

163 QNUMS_STEREOTYPY = (37, 40)

164 QNUMS_MOTIVATION = (41, 45)

165

166 NQUESTIONS = 45

167 TASK_FIELDS = strseq("frequency", 1, NQUESTIONS) + strseq(

168 "distress", 1, NQUESTIONS

169 )

170

171 @staticmethod

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

173 _ = req.gettext

174 return _("Cambridge Behavioural Inventory, Revised")

175

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

177 return self.standard_task_summary_fields() + [

178 SummaryElement(

179 name="memory_frequency_pct",

180 coltype=Float(),

181 value=self.frequency_subscore(*self.QNUMS_MEMORY),

182 comment="Memory/orientation: frequency score (% of max)",

183 ),

184 SummaryElement(

185 name="memory_distress_pct",

186 coltype=Float(),

187 value=self.distress_subscore(*self.QNUMS_MEMORY),

188 comment="Memory/orientation: distress score (% of max)",

189 ),

190 SummaryElement(

191 name="everyday_frequency_pct",

192 coltype=Float(),

193 value=self.frequency_subscore(*self.QNUMS_EVERYDAY),

194 comment="Everyday skills: frequency score (% of max)",

195 ),

196 SummaryElement(

197 name="everyday_distress_pct",

198 coltype=Float(),

199 value=self.distress_subscore(*self.QNUMS_EVERYDAY),

200 comment="Everyday skills: distress score (% of max)",

201 ),

202 SummaryElement(

203 name="selfcare_frequency_pct",

204 coltype=Float(),

205 value=self.frequency_subscore(*self.QNUMS_SELF),

206 comment="Self-care: frequency score (% of max)",

207 ),

208 SummaryElement(

209 name="selfcare_distress_pct",

210 coltype=Float(),

211 value=self.distress_subscore(*self.QNUMS_SELF),

212 comment="Self-care: distress score (% of max)",

213 ),

214 SummaryElement(

215 name="behaviour_frequency_pct",

216 coltype=Float(),

217 value=self.frequency_subscore(*self.QNUMS_BEHAVIOUR),

218 comment="Abnormal behaviour: frequency score (% of max)",

219 ),

220 SummaryElement(

221 name="behaviour_distress_pct",

222 coltype=Float(),

223 value=self.distress_subscore(*self.QNUMS_BEHAVIOUR),

224 comment="Abnormal behaviour: distress score (% of max)",

225 ),

226 SummaryElement(

227 name="mood_frequency_pct",

228 coltype=Float(),

229 value=self.frequency_subscore(*self.QNUMS_MOOD),

230 comment="Mood: frequency score (% of max)",

231 ),

232 SummaryElement(

233 name="mood_distress_pct",

234 coltype=Float(),

235 value=self.distress_subscore(*self.QNUMS_MOOD),

236 comment="Mood: distress score (% of max)",

237 ),

238 SummaryElement(

239 name="beliefs_frequency_pct",

240 coltype=Float(),

241 value=self.frequency_subscore(*self.QNUMS_BELIEFS),

242 comment="Beliefs: frequency score (% of max)",

243 ),

244 SummaryElement(

245 name="beliefs_distress_pct",

246 coltype=Float(),

247 value=self.distress_subscore(*self.QNUMS_BELIEFS),

248 comment="Beliefs: distress score (% of max)",

249 ),

250 SummaryElement(

251 name="eating_frequency_pct",

252 coltype=Float(),

253 value=self.frequency_subscore(*self.QNUMS_EATING),

254 comment="Eating habits: frequency score (% of max)",

255 ),

256 SummaryElement(

257 name="eating_distress_pct",

258 coltype=Float(),

259 value=self.distress_subscore(*self.QNUMS_EATING),

260 comment="Eating habits: distress score (% of max)",

261 ),

262 SummaryElement(

263 name="sleep_frequency_pct",

264 coltype=Float(),

265 value=self.frequency_subscore(*self.QNUMS_SLEEP),

266 comment="Sleep: frequency score (% of max)",

267 ),

268 SummaryElement(

269 name="sleep_distress_pct",

270 coltype=Float(),

271 value=self.distress_subscore(*self.QNUMS_SLEEP),

272 comment="Sleep: distress score (% of max)",

273 ),

274 SummaryElement(

275 name="stereotypic_frequency_pct",

276 coltype=Float(),

277 value=self.frequency_subscore(*self.QNUMS_STEREOTYPY),

278 comment="Stereotypic and motor behaviours: frequency "

279 "score (% of max)",

280 ),

281 SummaryElement(

282 name="stereotypic_distress_pct",

283 coltype=Float(),

284 value=self.distress_subscore(*self.QNUMS_STEREOTYPY),

285 comment="Stereotypic and motor behaviours: distress "

286 "score (% of max)",

287 ),

288 SummaryElement(

289 name="motivation_frequency_pct",

290 coltype=Float(),

291 value=self.frequency_subscore(*self.QNUMS_MOTIVATION),

292 comment="Motivation: frequency score (% of max)",

293 ),

294 SummaryElement(

295 name="motivation_distress_pct",

296 coltype=Float(),

297 value=self.distress_subscore(*self.QNUMS_MOTIVATION),

298 comment="Motivation: distress score (% of max)",

299 ),

300 ]

301

302 def subscore(

303 self, first: int, last: int, fieldprefix: str

304 ) -> Optional[float]:

305 score = 0

306 n = 0

307 for q in range(first, last + 1):

308 value = getattr(self, fieldprefix + str(q))

309 if value is not None:

310 score += value / self.MAX_SCORE

311 n += 1

312 return 100 * score / n if n > 0 else None

313

314 def frequency_subscore(self, first: int, last: int) -> Optional[float]:

315 return self.subscore(first, last, "frequency")

316

317 def distress_subscore(self, first: int, last: int) -> Optional[float]:

318 return self.subscore(first, last, "distress")

319

320 def is_complete(self) -> bool:

321 if (

322 not self.field_contents_valid()

323 or not self.is_respondent_complete()

324 ):

325 return False

326 if self.confirm_blanks:

327 return True

328 return self.all_fields_not_none(self.TASK_FIELDS)

329

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

331 freq_dict: dict[Optional[int], Optional[str]] = {None: None}

332 distress_dict: dict[Optional[int], Optional[str]] = {None: None}

333 for a in range(self.MIN_SCORE, self.MAX_SCORE + 1):

334 freq_dict[a] = self.wxstring(req, "f" + str(a))

335 distress_dict[a] = self.wxstring(req, "d" + str(a))

336

337 heading_memory = self.wxstring(req, "h_memory")

338 heading_everyday = self.wxstring(req, "h_everyday")

339 heading_selfcare = self.wxstring(req, "h_selfcare")

340 heading_behaviour = self.wxstring(req, "h_abnormalbehaviour")

341 heading_mood = self.wxstring(req, "h_mood")

342 heading_beliefs = self.wxstring(req, "h_beliefs")

343 heading_eating = self.wxstring(req, "h_eating")

344 heading_sleep = self.wxstring(req, "h_sleep")

345 heading_motor = self.wxstring(req, "h_stereotypy_motor")

346 heading_motivation = self.wxstring(req, "h_motivation")

347

348 def get_question_rows(first: int, last: int) -> str:

349 html = ""

350 for q in range(first, last + 1):

351 f = getattr(self, "frequency" + str(q))

352 d = getattr(self, "distress" + str(q))

353 fa = (

354 f"{f}: {get_from_dict(freq_dict, f)}"

355 if f is not None

356 else None

357 )

358 da = (

359 f"{d}: {get_from_dict(distress_dict, d)}"

360 if d is not None

361 else None

362 )

363 html += tr(

364 self.wxstring(req, "q" + str(q)), answer(fa), answer(da)

365 )

366 return html

367

368 h = f"""

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

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

371 {self.get_is_complete_tr(req)}

372 </table>

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

374 <tr>

375 <th>Subscale</th>

376 <th>Frequency (% of max)</th>

377 <th>Distress (% of max)</th>

378 </tr>

379 <tr>

380 <td>{heading_memory}</td>

381 <td>{answer(self.frequency_subscore(*self.QNUMS_MEMORY))}</td>

382 <td>{answer(self.distress_subscore(*self.QNUMS_MEMORY))}</td>

383 </tr>

384 <tr>

385 <td>{heading_everyday}</td>

386 <td>{answer(self.frequency_subscore(*self.QNUMS_EVERYDAY))}</td>

387 <td>{answer(self.distress_subscore(*self.QNUMS_EVERYDAY))}</td>

388 </tr>

389 <tr>

390 <td>{heading_selfcare}</td>

391 <td>{answer(self.frequency_subscore(*self.QNUMS_SELF))}</td>

392 <td>{answer(self.distress_subscore(*self.QNUMS_SELF))}</td>

393 </tr>

394 <tr>

395 <td>{heading_behaviour}</td>

396 <td>{answer(self.frequency_subscore(*self.QNUMS_BEHAVIOUR))}</td>

397 <td>{answer(self.distress_subscore(*self.QNUMS_BEHAVIOUR))}</td>

398 </tr>

399 <tr>

400 <td>{heading_mood}</td>

401 <td>{answer(self.frequency_subscore(*self.QNUMS_MOOD))}</td>

402 <td>{answer(self.distress_subscore(*self.QNUMS_MOOD))}</td>

403 </tr>

404 <tr>

405 <td>{heading_beliefs}</td>

406 <td>{answer(self.frequency_subscore(*self.QNUMS_BELIEFS))}</td>

407 <td>{answer(self.distress_subscore(*self.QNUMS_BELIEFS))}</td>

408 </tr>

409 <tr>

410 <td>{heading_eating}</td>

411 <td>{answer(self.frequency_subscore(*self.QNUMS_EATING))}</td>

412 <td>{answer(self.distress_subscore(*self.QNUMS_EATING))}</td>

413 </tr>

414 <tr>

415 <td>{heading_sleep}</td>

416 <td>{answer(self.frequency_subscore(*self.QNUMS_SLEEP))}</td>

417 <td>{answer(self.distress_subscore(*self.QNUMS_SLEEP))}</td>

418 </tr>

419 <tr>

420 <td>{heading_motor}</td>

421 <td>{answer(self.frequency_subscore(*self.QNUMS_STEREOTYPY))}</td>

422 <td>{answer(self.distress_subscore(*self.QNUMS_STEREOTYPY))}</td>

423 </tr>

424 <tr>

425 <td>{heading_motivation}</td>

426 <td>{answer(self.frequency_subscore(*self.QNUMS_MOTIVATION))}</td>

427 <td>{answer(self.distress_subscore(*self.QNUMS_MOTIVATION))}</td>

428 </tr>

429 </table>

430 </div>

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

432 {tr(

433 "Respondent confirmed that blanks are deliberate (N/A)",

434 answer(get_yes_no(req, self.confirm_blanks))

435 )}

436 {tr("Comments", answer(self.comments, default=""))}

437 </table>

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

439 <tr>

440 <th width="50%">Question</th>

441 <th width="25%">Frequency (0–4)</th>

442 <th width="25%">Distress (0–4)</th>

443 </tr>

444 {subheading_spanning_three_columns(heading_memory)}

445 {get_question_rows(*self.QNUMS_MEMORY)}

446 {subheading_spanning_three_columns(heading_everyday)}

447 {get_question_rows(*self.QNUMS_EVERYDAY)}

448 {subheading_spanning_three_columns(heading_selfcare)}

449 {get_question_rows(*self.QNUMS_SELF)}

450 {subheading_spanning_three_columns(heading_behaviour)}

451 {get_question_rows(*self.QNUMS_BEHAVIOUR)}

452 {subheading_spanning_three_columns(heading_mood)}

453 {get_question_rows(*self.QNUMS_MOOD)}

454 {subheading_spanning_three_columns(heading_beliefs)}

455 {get_question_rows(*self.QNUMS_BELIEFS)}

456 {subheading_spanning_three_columns(heading_eating)}

457 {get_question_rows(*self.QNUMS_EATING)}

458 {subheading_spanning_three_columns(heading_sleep)}

459 {get_question_rows(*self.QNUMS_SLEEP)}

460 {subheading_spanning_three_columns(heading_motor)}

461 {get_question_rows(*self.QNUMS_STEREOTYPY)}

462 {subheading_spanning_three_columns(heading_motivation)}

463 {get_question_rows(*self.QNUMS_MOTIVATION)}

464 </table>

465 """

466 return h