Coverage for tasks/factg.py: 56%

125 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2# camcops_server/tasks/factg.py 

3 

4""" 

5=============================================================================== 

6 

7 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

8 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

9 

10 This file is part of CamCOPS. 

11 

12 CamCOPS is free software: you can redistribute it and/or modify 

13 it under the terms of the GNU General Public License as published by 

14 the Free Software Foundation, either version 3 of the License, or 

15 (at your option) any later version. 

16 

17 CamCOPS is distributed in the hope that it will be useful, 

18 but WITHOUT ANY WARRANTY; without even the implied warranty of 

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

22 You should have received a copy of the GNU General Public License 

23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

24 

25=============================================================================== 

26 

27- By Joe Kearney, Rudolf Cardinal. 

28 

29""" 

30 

31from typing import Any, Dict, List, Tuple, Type 

32 

33from cardinal_pythonlib.stringfunc import strseq 

34from sqlalchemy.ext.declarative import DeclarativeMeta 

35from sqlalchemy.sql.sqltypes import Boolean, Float 

36 

37from camcops_server.cc_modules.cc_constants import CssClass 

38from camcops_server.cc_modules.cc_db import add_multiple_columns 

39from camcops_server.cc_modules.cc_html import ( 

40 answer, 

41 tr_qa, 

42 subheading_spanning_two_columns, 

43 tr, 

44) 

45from camcops_server.cc_modules.cc_request import CamcopsRequest 

46from camcops_server.cc_modules.cc_sqla_coltypes import ( 

47 BIT_CHECKER, 

48 CamcopsColumn, 

49) 

50from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

51from camcops_server.cc_modules.cc_task import ( 

52 get_from_dict, 

53 Task, 

54 TaskHasPatientMixin, 

55) 

56from camcops_server.cc_modules.cc_text import SS 

57from camcops_server.cc_modules.cc_trackerhelpers import ( 

58 TrackerAxisTick, 

59 TrackerInfo, 

60) 

61 

62 

63# ============================================================================= 

64# Fact-G 

65# ============================================================================= 

66 

67DISPLAY_DP = 2 

68MAX_QSCORE = 4 

69NON_REVERSE_SCORED_EMOTIONAL_QNUM = 2 

70 

71 

72class FactgMetaclass(DeclarativeMeta): 

73 # noinspection PyInitNewSignature 

74 def __init__( 

75 cls: Type["Factg"], 

76 name: str, 

77 bases: Tuple[Type, ...], 

78 classdict: Dict[str, Any], 

79 ) -> None: 

80 answer_stem = ( 

81 " (0 not at all, 1 a little bit, 2 somewhat, 3 quite a bit, " 

82 "4 very much)" 

83 ) 

84 add_multiple_columns( 

85 cls, 

86 "p_q", 

87 1, 

88 cls.N_QUESTIONS_PHYSICAL, 

89 minimum=0, 

90 maximum=4, 

91 comment_fmt="Physical well-being Q{n} ({s})" + answer_stem, 

92 comment_strings=[ 

93 "lack of energy", 

94 "nausea", 

95 "trouble meeting family needs", 

96 "pain", 

97 "treatment side effects", 

98 "feel ill", 

99 "bedbound", 

100 ], 

101 ) 

102 add_multiple_columns( 

103 cls, 

104 "s_q", 

105 1, 

106 cls.N_QUESTIONS_SOCIAL, 

107 minimum=0, 

108 maximum=4, 

109 comment_fmt="Social well-being Q{n} ({s})" + answer_stem, 

110 comment_strings=[ 

111 "close to friends", 

112 "emotional support from family", 

113 "support from friends", 

114 "family accepted illness", 

115 "good family comms re illness", 

116 "feel close to partner/main supporter", 

117 "satisfied with sex life", 

118 ], 

119 ) 

120 add_multiple_columns( 

121 cls, 

122 "e_q", 

123 1, 

124 cls.N_QUESTIONS_EMOTIONAL, 

125 minimum=0, 

126 maximum=4, 

127 comment_fmt="Emotional well-being Q{n} ({s})" + answer_stem, 

128 comment_strings=[ 

129 "sad", 

130 "satisfied with coping re illness", 

131 "losing hope in fight against illness", 

132 "nervous" "worried about dying", 

133 "worried condition will worsen", 

134 ], 

135 ) 

136 add_multiple_columns( 

137 cls, 

138 "f_q", 

139 1, 

140 cls.N_QUESTIONS_FUNCTIONAL, 

141 minimum=0, 

142 maximum=4, 

143 comment_fmt="Functional well-being Q{n} ({s})" + answer_stem, 

144 comment_strings=[ 

145 "able to work", 

146 "work fulfilling", 

147 "able to enjoy life", 

148 "accepted illness", 

149 "sleeping well", 

150 "enjoying usual fun things", 

151 "content with quality of life", 

152 ], 

153 ) 

154 super().__init__(name, bases, classdict) 

155 

156 

157class FactgGroupInfo(object): 

158 """ 

159 Internal information class for the FACT-G. 

160 """ 

161 

162 def __init__( 

163 self, 

164 heading_xstring_name: str, 

165 question_prefix: str, 

166 fieldnames: List[str], 

167 summary_fieldname: str, 

168 summary_description: str, 

169 max_score: int, 

170 reverse_score_all: bool = False, 

171 reverse_score_all_but_q2: bool = False, 

172 ) -> None: 

173 self.heading_xstring_name = heading_xstring_name 

174 self.question_prefix = question_prefix 

175 self.fieldnames = fieldnames 

176 self.summary_fieldname = summary_fieldname 

177 self.summary_description = summary_description 

178 self.max_score = max_score 

179 self.reverse_score_all = reverse_score_all 

180 self.reverse_score_all_but_q2 = reverse_score_all_but_q2 

181 self.n_questions = len(fieldnames) 

182 

183 def subscore(self, task: "Factg") -> float: 

184 answered = 0 

185 scoresum = 0 

186 for qnum, fieldname in enumerate(self.fieldnames, start=1): 

187 answer_val = getattr(task, fieldname) 

188 try: 

189 answer_int = int(answer_val) 

190 except (TypeError, ValueError): 

191 continue 

192 answered += 1 

193 if self.reverse_score_all or ( 

194 self.reverse_score_all_but_q2 

195 and qnum != NON_REVERSE_SCORED_EMOTIONAL_QNUM 

196 ): 

197 # reverse-scored 

198 scoresum += MAX_QSCORE - answer_int 

199 else: 

200 # normally scored 

201 scoresum += answer_int 

202 if answered == 0: 

203 return 0 

204 return scoresum * self.n_questions / answered 

205 

206 

207class Factg(TaskHasPatientMixin, Task, metaclass=FactgMetaclass): 

208 """ 

209 Server implementation of the Fact-G task. 

210 """ 

211 

212 __tablename__ = "factg" 

213 shortname = "FACT-G" 

214 provides_trackers = True 

215 

216 N_QUESTIONS_PHYSICAL = 7 

217 N_QUESTIONS_SOCIAL = 7 

218 N_QUESTIONS_EMOTIONAL = 6 

219 N_QUESTIONS_FUNCTIONAL = 7 

220 

221 MAX_SCORE_PHYSICAL = 28 

222 MAX_SCORE_SOCIAL = 28 

223 MAX_SCORE_EMOTIONAL = 24 

224 MAX_SCORE_FUNCTIONAL = 28 

225 

226 N_ALL = ( 

227 N_QUESTIONS_PHYSICAL 

228 + N_QUESTIONS_SOCIAL 

229 + N_QUESTIONS_EMOTIONAL 

230 + N_QUESTIONS_FUNCTIONAL 

231 ) 

232 

233 MAX_SCORE_TOTAL = N_ALL * MAX_QSCORE 

234 

235 PHYSICAL_PREFIX = "p_q" 

236 SOCIAL_PREFIX = "s_q" 

237 EMOTIONAL_PREFIX = "e_q" 

238 FUNCTIONAL_PREFIX = "f_q" 

239 

240 QUESTIONS_PHYSICAL = strseq(PHYSICAL_PREFIX, 1, N_QUESTIONS_PHYSICAL) 

241 QUESTIONS_SOCIAL = strseq(SOCIAL_PREFIX, 1, N_QUESTIONS_SOCIAL) 

242 QUESTIONS_EMOTIONAL = strseq(EMOTIONAL_PREFIX, 1, N_QUESTIONS_EMOTIONAL) 

243 QUESTIONS_FUNCTIONAL = strseq(FUNCTIONAL_PREFIX, 1, N_QUESTIONS_FUNCTIONAL) 

244 

245 GROUPS = [ 

246 FactgGroupInfo( 

247 "h1", 

248 PHYSICAL_PREFIX, 

249 QUESTIONS_PHYSICAL, 

250 "physical_wellbeing", 

251 "Physical wellbeing subscore", 

252 MAX_SCORE_PHYSICAL, 

253 reverse_score_all=True, 

254 ), 

255 FactgGroupInfo( 

256 "h2", 

257 SOCIAL_PREFIX, 

258 QUESTIONS_SOCIAL, 

259 "social_family_wellbeing", 

260 "Social/family wellbeing subscore", 

261 MAX_SCORE_SOCIAL, 

262 ), 

263 FactgGroupInfo( 

264 "h3", 

265 EMOTIONAL_PREFIX, 

266 QUESTIONS_EMOTIONAL, 

267 "emotional_wellbeing", 

268 "Emotional wellbeing subscore", 

269 MAX_SCORE_EMOTIONAL, 

270 reverse_score_all_but_q2=True, 

271 ), 

272 FactgGroupInfo( 

273 "h4", 

274 FUNCTIONAL_PREFIX, 

275 QUESTIONS_FUNCTIONAL, 

276 "functional_wellbeing", 

277 "Functional wellbeing subscore", 

278 MAX_SCORE_FUNCTIONAL, 

279 ), 

280 ] 

281 

282 OPTIONAL_Q = "s_q7" 

283 

284 ignore_s_q7 = CamcopsColumn( 

285 "ignore_s_q7", Boolean, permitted_value_checker=BIT_CHECKER 

286 ) 

287 

288 @staticmethod 

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

290 _ = req.gettext 

291 return _("Functional Assessment of Cancer Therapy — General") 

292 

293 def is_complete(self) -> bool: 

294 questions_social = self.QUESTIONS_SOCIAL.copy() 

295 if self.ignore_s_q7: 

296 questions_social.remove(self.OPTIONAL_Q) 

297 

298 all_qs = [ 

299 self.QUESTIONS_PHYSICAL, 

300 questions_social, 

301 self.QUESTIONS_EMOTIONAL, 

302 self.QUESTIONS_FUNCTIONAL, 

303 ] 

304 

305 for qlist in all_qs: 

306 if self.any_fields_none(qlist): 

307 return False 

308 

309 if not self.field_contents_valid(): 

310 return False 

311 

312 return True 

313 

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

315 return [ 

316 TrackerInfo( 

317 value=self.total_score(), 

318 plot_label="FACT-G total score (rating well-being)", 

319 axis_label=f"Total score (out of {self.MAX_SCORE_TOTAL})", 

320 axis_min=-0.5, 

321 axis_max=self.MAX_SCORE_TOTAL + 0.5, 

322 axis_ticks=[ 

323 TrackerAxisTick(108, "108"), 

324 TrackerAxisTick(100, "100"), 

325 TrackerAxisTick(80, "80"), 

326 TrackerAxisTick(60, "60"), 

327 TrackerAxisTick(40, "40"), 

328 TrackerAxisTick(20, "20"), 

329 TrackerAxisTick(0, "0"), 

330 ], 

331 horizontal_lines=[80, 60, 40, 20], 

332 ) 

333 ] 

334 

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

336 elements = self.standard_task_summary_fields() 

337 for info in self.GROUPS: 

338 subscore = info.subscore(self) 

339 elements.append( 

340 SummaryElement( 

341 name=info.summary_fieldname, 

342 coltype=Float(), 

343 value=subscore, 

344 comment=f"{info.summary_description} " 

345 f"(out of {info.max_score})", 

346 ) 

347 ) 

348 elements.append( 

349 SummaryElement( 

350 name="total_score", 

351 coltype=Float(), 

352 value=self.total_score(), 

353 comment=f"Total score (out of {self.MAX_SCORE_TOTAL})", 

354 ) 

355 ) 

356 return elements 

357 

358 def subscores(self) -> List[float]: 

359 sscores = [] 

360 for info in self.GROUPS: 

361 sscores.append(info.subscore(self)) 

362 return sscores 

363 

364 def total_score(self) -> float: 

365 return sum(self.subscores()) 

366 

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

368 answers = { 

369 None: None, 

370 0: "0 — " + self.wxstring(req, "a0"), 

371 1: "1 — " + self.wxstring(req, "a1"), 

372 2: "2 — " + self.wxstring(req, "a2"), 

373 3: "3 — " + self.wxstring(req, "a3"), 

374 4: "4 — " + self.wxstring(req, "a4"), 

375 } 

376 subscore_html = "" 

377 answer_html = "" 

378 

379 for info in self.GROUPS: 

380 heading = self.wxstring(req, info.heading_xstring_name) 

381 subscore = info.subscore(self) 

382 subscore_html += tr( 

383 heading, 

384 (answer(round(subscore, DISPLAY_DP)) + f" / {info.max_score}"), 

385 ) 

386 answer_html += subheading_spanning_two_columns(heading) 

387 for q in info.fieldnames: 

388 if q == self.OPTIONAL_Q: 

389 # insert additional row 

390 answer_html += tr_qa( 

391 self.xstring(req, "prefer_no_answer"), self.ignore_s_q7 

392 ) 

393 answer_val = getattr(self, q) 

394 answer_html += tr_qa( 

395 self.wxstring(req, q), get_from_dict(answers, answer_val) 

396 ) 

397 

398 tscore = round(self.total_score(), DISPLAY_DP) 

399 

400 tr_total_score = tr( 

401 req.sstring(SS.TOTAL_SCORE), 

402 answer(tscore) + f" / {self.MAX_SCORE_TOTAL}", 

403 ) 

404 return f""" 

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

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

407 {self.get_is_complete_tr(req)} 

408 {tr_total_score} 

409 {subscore_html} 

410 </table> 

411 </div> 

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

413 <tr> 

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

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

416 </tr> 

417 {answer_html} 

418 </table> 

419 """