Coverage for tasks/cbir.py: 49%

87 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

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

28from typing import Any, List, Optional, Type 

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.orm import Mapped, mapped_column 

32from sqlalchemy.sql.sqltypes import Float, UnicodeText 

33 

34from camcops_server.cc_modules.cc_constants import CssClass 

35from camcops_server.cc_modules.cc_db import add_multiple_columns 

36from camcops_server.cc_modules.cc_html import ( 

37 answer, 

38 get_yes_no, 

39 subheading_spanning_three_columns, 

40 tr, 

41) 

42from camcops_server.cc_modules.cc_request import CamcopsRequest 

43from camcops_server.cc_modules.cc_sqla_coltypes import ( 

44 BIT_CHECKER, 

45 mapped_camcops_column, 

46) 

47from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

48from 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 

60QUESTION_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 

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