Coverage for tasks/audit.py: 44%

119 statements  

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

1""" 

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

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.sql.sqltypes import Integer 

32 

33from camcops_server.cc_modules.cc_constants import CssClass 

34from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

35from camcops_server.cc_modules.cc_db import add_multiple_columns 

36from camcops_server.cc_modules.cc_html import answer, get_yes_no, tr, tr_qa 

37from camcops_server.cc_modules.cc_request import CamcopsRequest 

38from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

39from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

40from camcops_server.cc_modules.cc_task import ( 

41 get_from_dict, 

42 Task, 

43 TaskHasPatientMixin, 

44) 

45from camcops_server.cc_modules.cc_text import SS 

46from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

47 

48 

49# ============================================================================= 

50# AUDIT 

51# ============================================================================= 

52 

53 

54class Audit( # type: ignore[misc] 

55 TaskHasPatientMixin, 

56 Task, 

57): 

58 """ 

59 Server implementation of the AUDIT task. 

60 """ 

61 

62 __tablename__ = "audit" 

63 shortname = "AUDIT" 

64 provides_trackers = True 

65 

66 prohibits_commercial = True 

67 

68 NQUESTIONS = 10 

69 

70 @classmethod 

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

72 add_multiple_columns( 

73 cls, 

74 "q", 

75 1, 

76 cls.NQUESTIONS, 

77 minimum=0, 

78 maximum=4, 

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

80 comment_strings=[ 

81 "how often drink", 

82 "drinks per day", 

83 "how often six drinks", 

84 "unable to stop", 

85 "unable to do what was expected", 

86 "eye opener", 

87 "guilt", 

88 "unable to remember", 

89 "injuries", 

90 "others concerned", 

91 ], 

92 ) 

93 

94 TASK_FIELDS = strseq("q", 1, NQUESTIONS) 

95 

96 @staticmethod 

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

98 _ = req.gettext 

99 return _("WHO Alcohol Use Disorders Identification Test") 

100 

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

102 return [ 

103 TrackerInfo( 

104 value=self.total_score(), 

105 plot_label="AUDIT total score", 

106 axis_label="Total score (out of 40)", 

107 axis_min=-0.5, 

108 axis_max=40.5, 

109 horizontal_lines=[7.5], 

110 ) 

111 ] 

112 

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

114 if not self.is_complete(): 

115 return CTV_INCOMPLETE 

116 return [CtvInfo(content=f"AUDIT total score {self.total_score()}/40")] 

117 

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

119 return self.standard_task_summary_fields() + [ 

120 SummaryElement( 

121 name="total", 

122 coltype=Integer(), 

123 value=self.total_score(), 

124 comment="Total score (/40)", 

125 ) 

126 ] 

127 

128 # noinspection PyUnresolvedReferences 

129 def is_complete(self) -> bool: 

130 if not self.field_contents_valid(): 

131 return False 

132 if ( 

133 self.q1 is None or self.q9 is None or self.q10 is None # type: ignore[attr-defined] # noqa: E501 

134 ): 

135 return False 

136 if self.q1 == 0: # type: ignore[attr-defined] 

137 # Special limited-information completeness 

138 return True 

139 if ( 

140 self.q2 is not None # type: ignore[attr-defined] 

141 and self.q3 is not None # type: ignore[attr-defined] 

142 and (self.q2 + self.q3 == 0) # type: ignore[attr-defined] 

143 ): 

144 # Special limited-information completeness 

145 return True 

146 # Otherwise, any null values cause problems 

147 return self.all_fields_not_none(self.TASK_FIELDS) 

148 

149 def total_score(self) -> int: 

150 return cast(int, self.sum_fields(self.TASK_FIELDS)) 

151 

152 # noinspection PyUnresolvedReferences 

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

154 score = self.total_score() 

155 exceeds_cutoff = score >= 8 

156 q1_dict: dict[Optional[int], Optional[str]] = {None: None} 

157 q2_dict: dict[Optional[int], Optional[str]] = {None: None} 

158 q3_to_8_dict: dict[Optional[int], Optional[str]] = {None: None} 

159 q9_to_10_dict: dict[Optional[int], Optional[str]] = {None: None} 

160 for option in range(0, 5): 

161 q1_dict[option] = ( 

162 str(option) 

163 + " – " 

164 + self.wxstring(req, "q1_option" + str(option)) 

165 ) 

166 q2_dict[option] = ( 

167 str(option) 

168 + " – " 

169 + self.wxstring(req, "q2_option" + str(option)) 

170 ) 

171 q3_to_8_dict[option] = ( 

172 str(option) 

173 + " – " 

174 + self.wxstring(req, "q3to8_option" + str(option)) 

175 ) 

176 if option != 1 and option != 3: 

177 q9_to_10_dict[option] = ( 

178 str(option) 

179 + " – " 

180 + self.wxstring(req, "q9to10_option" + str(option)) 

181 ) 

182 

183 q_a = tr_qa( 

184 self.wxstring(req, "q1_s"), 

185 get_from_dict(q1_dict, self.q1), # type: ignore[attr-defined] 

186 ) 

187 q_a += tr_qa( 

188 self.wxstring(req, "q2_s"), 

189 get_from_dict(q2_dict, self.q2), # type: ignore[attr-defined] 

190 ) 

191 for q in range(3, 8 + 1): 

192 q_a += tr_qa( 

193 self.wxstring(req, "q" + str(q) + "_s"), 

194 get_from_dict(q3_to_8_dict, getattr(self, "q" + str(q))), 

195 ) 

196 q_a += tr_qa( 

197 self.wxstring(req, "q9_s"), 

198 get_from_dict( 

199 q9_to_10_dict, self.q9 # type: ignore[attr-defined] 

200 ), 

201 ) 

202 q_a += tr_qa( 

203 self.wxstring(req, "q10_s"), 

204 get_from_dict( 

205 q9_to_10_dict, self.q10 # type: ignore[attr-defined] 

206 ), 

207 ) 

208 

209 return f""" 

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

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

212 {self.get_is_complete_tr(req)} 

213 {tr(req.wsstring(SS.TOTAL_SCORE), 

214 answer(score) + " / 40")} 

215 {tr_qa(self.wxstring(req, "exceeds_standard_cutoff"), 

216 get_yes_no(req, exceeds_cutoff))} 

217 </table> 

218 </div> 

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

220 <tr> 

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

222 <th width="50%">Answer</th> 

223 </tr> 

224 {q_a} 

225 </table> 

226 <div class="{CssClass.COPYRIGHT}"> 

227 AUDIT: Copyright © World Health Organization. 

228 Reproduced here under the permissions granted for 

229 NON-COMMERCIAL use only. You must obtain permission from the 

230 copyright holder for any other use. 

231 </div> 

232 """ 

233 

234 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

235 codes = [ 

236 SnomedExpression( 

237 req.snomed(SnomedLookup.AUDIT_PROCEDURE_ASSESSMENT) 

238 ) 

239 ] 

240 if self.is_complete(): 

241 codes.append( 

242 SnomedExpression( 

243 req.snomed(SnomedLookup.AUDIT_SCALE), 

244 {req.snomed(SnomedLookup.AUDIT_SCORE): self.total_score()}, 

245 ) 

246 ) 

247 return codes 

248 

249 

250# ============================================================================= 

251# AUDIT-C 

252# ============================================================================= 

253 

254 

255class AuditC(TaskHasPatientMixin, Task): # type: ignore[misc] 

256 __tablename__ = "audit_c" 

257 shortname = "AUDIT-C" 

258 extrastring_taskname = "audit" # shares strings with AUDIT 

259 info_filename_stem = extrastring_taskname 

260 

261 prohibits_commercial = True 

262 

263 NQUESTIONS = 3 

264 

265 @classmethod 

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

267 add_multiple_columns( 

268 cls, 

269 "q", 

270 1, 

271 cls.NQUESTIONS, 

272 minimum=0, 

273 maximum=4, 

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

275 comment_strings=[ 

276 "how often drink", 

277 "drinks per day", 

278 "how often six drinks", 

279 ], 

280 ) 

281 

282 TASK_FIELDS = strseq("q", 1, NQUESTIONS) 

283 

284 @staticmethod 

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

286 _ = req.gettext 

287 return _("AUDIT Alcohol Consumption Questions") 

288 

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

290 return [ 

291 TrackerInfo( 

292 value=self.total_score(), 

293 plot_label="AUDIT-C total score", 

294 axis_label="Total score (out of 12)", 

295 axis_min=-0.5, 

296 axis_max=12.5, 

297 ) 

298 ] 

299 

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

301 if not self.is_complete(): 

302 return CTV_INCOMPLETE 

303 return [ 

304 CtvInfo(content=f"AUDIT-C total score {self.total_score()}/12") 

305 ] 

306 

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

308 return self.standard_task_summary_fields() + [ 

309 SummaryElement( 

310 name="total", 

311 coltype=Integer(), 

312 value=self.total_score(), 

313 comment="Total score (/12)", 

314 ) 

315 ] 

316 

317 def is_complete(self) -> bool: 

318 return self.all_fields_not_none(self.TASK_FIELDS) 

319 

320 def total_score(self) -> int: 

321 return cast(int, self.sum_fields(self.TASK_FIELDS)) 

322 

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

324 score = self.total_score() 

325 q1_dict: dict[Optional[int], Optional[str]] = {None: None} 

326 q2_dict: dict[Optional[int], Optional[str]] = {None: None} 

327 q3_dict: dict[Optional[int], Optional[str]] = {None: None} 

328 for option in range(0, 5): 

329 q1_dict[option] = ( 

330 str(option) 

331 + " – " 

332 + self.wxstring(req, "q1_option" + str(option)) 

333 ) 

334 if option == 0: # special! 

335 q2_dict[option] = ( 

336 str(option) + " – " + self.wxstring(req, "c_q2_option0") 

337 ) 

338 else: 

339 q2_dict[option] = ( 

340 str(option) 

341 + " – " 

342 + self.wxstring(req, "q2_option" + str(option)) 

343 ) 

344 q3_dict[option] = ( 

345 str(option) 

346 + " – " 

347 + self.wxstring(req, "q3to8_option" + str(option)) 

348 ) 

349 

350 row_1 = tr_qa( 

351 self.wxstring(req, "c_q1_question"), 

352 get_from_dict(q1_dict, self.q1), # type: ignore[attr-defined] 

353 ) 

354 row_2 = tr_qa( 

355 self.wxstring(req, "c_q2_question"), 

356 get_from_dict(q2_dict, self.q2), # type: ignore[attr-defined] 

357 ) 

358 row_3 = tr_qa( 

359 self.wxstring(req, "c_q3_question"), 

360 get_from_dict(q3_dict, self.q3), # type: ignore[attr-defined] 

361 ) 

362 

363 # noinspection PyUnresolvedReferences 

364 return f""" 

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

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

367 {self.get_is_complete_tr(req)} 

368 {tr(req.sstring(SS.TOTAL_SCORE), 

369 answer(score) + " / 12")} 

370 </table> 

371 </div> 

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

373 <tr> 

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

375 <th width="50%">Answer</th> 

376 </tr> 

377 {row_1} 

378 {row_2} 

379 {row_3} 

380 </table> 

381 <div class="{CssClass.COPYRIGHT}"> 

382 AUDIT: Copyright © World Health Organization. 

383 Reproduced here under the permissions granted for 

384 NON-COMMERCIAL use only. You must obtain permission from the 

385 copyright holder for any other use. 

386 

387 AUDIT-C: presumed to have the same restrictions. 

388 </div> 

389 """ 

390 

391 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

392 codes = [ 

393 SnomedExpression( 

394 req.snomed(SnomedLookup.AUDITC_PROCEDURE_ASSESSMENT) 

395 ) 

396 ] 

397 if self.is_complete(): 

398 codes.append( 

399 SnomedExpression( 

400 req.snomed(SnomedLookup.AUDITC_SCALE), 

401 { 

402 req.snomed( 

403 SnomedLookup.AUDITC_SCORE 

404 ): self.total_score() 

405 }, 

406 ) 

407 ) 

408 return codes