Coverage for tasks/audit.py: 44%

1"""

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

28 from typing import Any, cast, List, Optional, Type

29

30 from cardinal_pythonlib.stringfunc import strseq

31 from sqlalchemy.sql.sqltypes import Integer

32

33 from camcops_server.cc_modules.cc_constants import CssClass

34 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

35 from camcops_server.cc_modules.cc_db import add_multiple_columns

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

37 from camcops_server.cc_modules.cc_request import CamcopsRequest

38 from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup

39 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

40 from camcops_server.cc_modules.cc_task import (

41 get_from_dict,

42 Task,

43 TaskHasPatientMixin,

44)

45 from camcops_server.cc_modules.cc_text import SS

46 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

47

48

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

50# AUDIT

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

52

53

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

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