Coverage for tasks/factg.py: 55%

123 statements  

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

1# camcops_server/tasks/factg.py 

2 

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- By Joe Kearney, Rudolf Cardinal. 

27 

28""" 

29 

30from typing import Any, List, Optional, Type 

31 

32from cardinal_pythonlib.stringfunc import strseq 

33from sqlalchemy.orm import Mapped 

34from sqlalchemy.sql.sqltypes import Float 

35 

36from camcops_server.cc_modules.cc_constants import CssClass 

37from camcops_server.cc_modules.cc_db import add_multiple_columns 

38from camcops_server.cc_modules.cc_html import ( 

39 answer, 

40 tr_qa, 

41 subheading_spanning_two_columns, 

42 tr, 

43) 

44from camcops_server.cc_modules.cc_request import CamcopsRequest 

45from camcops_server.cc_modules.cc_sqla_coltypes import ( 

46 BIT_CHECKER, 

47 mapped_camcops_column, 

48) 

49from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

50from camcops_server.cc_modules.cc_task import ( 

51 get_from_dict, 

52 Task, 

53 TaskHasPatientMixin, 

54) 

55from camcops_server.cc_modules.cc_text import SS 

56from camcops_server.cc_modules.cc_trackerhelpers import ( 

57 TrackerAxisTick, 

58 TrackerInfo, 

59) 

60 

61 

62# ============================================================================= 

63# Fact-G 

64# ============================================================================= 

65 

66DISPLAY_DP = 2 

67MAX_QSCORE = 4 

68NON_REVERSE_SCORED_EMOTIONAL_QNUM = 2 

69 

70 

71class FactgGroupInfo(object): 

72 """ 

73 Internal information class for the FACT-G. 

74 """ 

75 

76 def __init__( 

77 self, 

78 heading_xstring_name: str, 

79 question_prefix: str, 

80 fieldnames: List[str], 

81 summary_fieldname: str, 

82 summary_description: str, 

83 max_score: int, 

84 reverse_score_all: bool = False, 

85 reverse_score_all_but_q2: bool = False, 

86 ) -> None: 

87 self.heading_xstring_name = heading_xstring_name 

88 self.question_prefix = question_prefix 

89 self.fieldnames = fieldnames 

90 self.summary_fieldname = summary_fieldname 

91 self.summary_description = summary_description 

92 self.max_score = max_score 

93 self.reverse_score_all = reverse_score_all 

94 self.reverse_score_all_but_q2 = reverse_score_all_but_q2 

95 self.n_questions = len(fieldnames) 

96 

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

98 answered = 0 

99 scoresum = 0 

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

101 answer_val = getattr(task, fieldname) 

102 try: 

103 answer_int = int(answer_val) 

104 except (TypeError, ValueError): 

105 continue 

106 answered += 1 

107 if self.reverse_score_all or ( 

108 self.reverse_score_all_but_q2 

109 and qnum != NON_REVERSE_SCORED_EMOTIONAL_QNUM 

110 ): 

111 # reverse-scored 

112 scoresum += MAX_QSCORE - answer_int 

113 else: 

114 # normally scored 

115 scoresum += answer_int 

116 if answered == 0: 

117 return 0 

118 return scoresum * self.n_questions / answered 

119 

120 

121class Factg(TaskHasPatientMixin, Task): # type: ignore[misc] 

122 """ 

123 Server implementation of the Fact-G task. 

124 """ 

125 

126 __tablename__ = "factg" 

127 shortname = "FACT-G" 

128 provides_trackers = True 

129 

130 N_QUESTIONS_PHYSICAL = 7 

131 N_QUESTIONS_SOCIAL = 7 

132 N_QUESTIONS_EMOTIONAL = 6 

133 N_QUESTIONS_FUNCTIONAL = 7 

134 

135 MAX_SCORE_PHYSICAL = 28 

136 MAX_SCORE_SOCIAL = 28 

137 MAX_SCORE_EMOTIONAL = 24 

138 MAX_SCORE_FUNCTIONAL = 28 

139 

140 N_ALL = ( 

141 N_QUESTIONS_PHYSICAL 

142 + N_QUESTIONS_SOCIAL 

143 + N_QUESTIONS_EMOTIONAL 

144 + N_QUESTIONS_FUNCTIONAL 

145 ) 

146 

147 MAX_SCORE_TOTAL = N_ALL * MAX_QSCORE 

148 

149 PHYSICAL_PREFIX = "p_q" 

150 SOCIAL_PREFIX = "s_q" 

151 EMOTIONAL_PREFIX = "e_q" 

152 FUNCTIONAL_PREFIX = "f_q" 

153 

154 QUESTIONS_PHYSICAL = strseq(PHYSICAL_PREFIX, 1, N_QUESTIONS_PHYSICAL) 

155 QUESTIONS_SOCIAL = strseq(SOCIAL_PREFIX, 1, N_QUESTIONS_SOCIAL) 

156 QUESTIONS_EMOTIONAL = strseq(EMOTIONAL_PREFIX, 1, N_QUESTIONS_EMOTIONAL) 

157 QUESTIONS_FUNCTIONAL = strseq(FUNCTIONAL_PREFIX, 1, N_QUESTIONS_FUNCTIONAL) 

158 

159 GROUPS = [ 

160 FactgGroupInfo( 

161 "h1", 

162 PHYSICAL_PREFIX, 

163 QUESTIONS_PHYSICAL, 

164 "physical_wellbeing", 

165 "Physical wellbeing subscore", 

166 MAX_SCORE_PHYSICAL, 

167 reverse_score_all=True, 

168 ), 

169 FactgGroupInfo( 

170 "h2", 

171 SOCIAL_PREFIX, 

172 QUESTIONS_SOCIAL, 

173 "social_family_wellbeing", 

174 "Social/family wellbeing subscore", 

175 MAX_SCORE_SOCIAL, 

176 ), 

177 FactgGroupInfo( 

178 "h3", 

179 EMOTIONAL_PREFIX, 

180 QUESTIONS_EMOTIONAL, 

181 "emotional_wellbeing", 

182 "Emotional wellbeing subscore", 

183 MAX_SCORE_EMOTIONAL, 

184 reverse_score_all_but_q2=True, 

185 ), 

186 FactgGroupInfo( 

187 "h4", 

188 FUNCTIONAL_PREFIX, 

189 QUESTIONS_FUNCTIONAL, 

190 "functional_wellbeing", 

191 "Functional wellbeing subscore", 

192 MAX_SCORE_FUNCTIONAL, 

193 ), 

194 ] 

195 

196 OPTIONAL_Q = "s_q7" 

197 

198 @classmethod 

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

200 answer_stem = ( 

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

202 "4 very much)" 

203 ) 

204 add_multiple_columns( 

205 cls, 

206 "p_q", 

207 1, 

208 cls.N_QUESTIONS_PHYSICAL, 

209 minimum=0, 

210 maximum=4, 

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

212 comment_strings=[ 

213 "lack of energy", 

214 "nausea", 

215 "trouble meeting family needs", 

216 "pain", 

217 "treatment side effects", 

218 "feel ill", 

219 "bedbound", 

220 ], 

221 ) 

222 add_multiple_columns( 

223 cls, 

224 "s_q", 

225 1, 

226 cls.N_QUESTIONS_SOCIAL, 

227 minimum=0, 

228 maximum=4, 

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

230 comment_strings=[ 

231 "close to friends", 

232 "emotional support from family", 

233 "support from friends", 

234 "family accepted illness", 

235 "good family comms re illness", 

236 "feel close to partner/main supporter", 

237 "satisfied with sex life", 

238 ], 

239 ) 

240 add_multiple_columns( 

241 cls, 

242 "e_q", 

243 1, 

244 cls.N_QUESTIONS_EMOTIONAL, 

245 minimum=0, 

246 maximum=4, 

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

248 comment_strings=[ 

249 "sad", 

250 "satisfied with coping re illness", 

251 "losing hope in fight against illness", 

252 "nervous" "worried about dying", 

253 "worried condition will worsen", 

254 ], 

255 ) 

256 add_multiple_columns( 

257 cls, 

258 "f_q", 

259 1, 

260 cls.N_QUESTIONS_FUNCTIONAL, 

261 minimum=0, 

262 maximum=4, 

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

264 comment_strings=[ 

265 "able to work", 

266 "work fulfilling", 

267 "able to enjoy life", 

268 "accepted illness", 

269 "sleeping well", 

270 "enjoying usual fun things", 

271 "content with quality of life", 

272 ], 

273 ) 

274 

275 ignore_s_q7: Mapped[Optional[bool]] = mapped_camcops_column( 

276 permitted_value_checker=BIT_CHECKER 

277 ) 

278 

279 @staticmethod 

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

281 _ = req.gettext 

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

283 

284 def is_complete(self) -> bool: 

285 questions_social = self.QUESTIONS_SOCIAL.copy() 

286 if self.ignore_s_q7: 

287 questions_social.remove(self.OPTIONAL_Q) 

288 

289 all_qs = [ 

290 self.QUESTIONS_PHYSICAL, 

291 questions_social, 

292 self.QUESTIONS_EMOTIONAL, 

293 self.QUESTIONS_FUNCTIONAL, 

294 ] 

295 

296 for qlist in all_qs: 

297 if self.any_fields_none(qlist): 

298 return False 

299 

300 if not self.field_contents_valid(): 

301 return False 

302 

303 return True 

304 

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

306 return [ 

307 TrackerInfo( 

308 value=self.total_score(), 

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

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

311 axis_min=-0.5, 

312 axis_max=self.MAX_SCORE_TOTAL + 0.5, 

313 axis_ticks=[ 

314 TrackerAxisTick(108, "108"), 

315 TrackerAxisTick(100, "100"), 

316 TrackerAxisTick(80, "80"), 

317 TrackerAxisTick(60, "60"), 

318 TrackerAxisTick(40, "40"), 

319 TrackerAxisTick(20, "20"), 

320 TrackerAxisTick(0, "0"), 

321 ], 

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

323 ) 

324 ] 

325 

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

327 elements = self.standard_task_summary_fields() 

328 for info in self.GROUPS: 

329 subscore = info.subscore(self) 

330 elements.append( 

331 SummaryElement( 

332 name=info.summary_fieldname, 

333 coltype=Float(), 

334 value=subscore, 

335 comment=f"{info.summary_description} " 

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

337 ) 

338 ) 

339 elements.append( 

340 SummaryElement( 

341 name="total_score", 

342 coltype=Float(), 

343 value=self.total_score(), 

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

345 ) 

346 ) 

347 return elements 

348 

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

350 sscores = [] 

351 for info in self.GROUPS: 

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

353 return sscores 

354 

355 def total_score(self) -> float: 

356 return sum(self.subscores()) 

357 

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

359 answers = { 

360 None: None, 

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

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

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

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

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

366 } 

367 subscore_html = "" 

368 answer_html = "" 

369 

370 for info in self.GROUPS: 

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

372 subscore = info.subscore(self) 

373 subscore_html += tr( 

374 heading, 

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

376 ) 

377 answer_html += subheading_spanning_two_columns(heading) 

378 for q in info.fieldnames: 

379 if q == self.OPTIONAL_Q: 

380 # insert additional row 

381 answer_html += tr_qa( 

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

383 ) 

384 answer_val = getattr(self, q) 

385 answer_html += tr_qa( 

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

387 ) 

388 

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

390 

391 tr_total_score = tr( 

392 req.sstring(SS.TOTAL_SCORE), 

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

394 ) 

395 return f""" 

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

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

398 {self.get_is_complete_tr(req)} 

399 {tr_total_score} 

400 {subscore_html} 

401 </table> 

402 </div> 

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

404 <tr> 

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

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

407 </tr> 

408 {answer_html} 

409 </table> 

410 """