Coverage for tasks/factg.py: 55%

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

30 from typing import Any, List, Optional, Type

31

32 from cardinal_pythonlib.stringfunc import strseq

33 from sqlalchemy.orm import Mapped

34 from sqlalchemy.sql.sqltypes import Float

35

36 from camcops_server.cc_modules.cc_constants import CssClass

37 from camcops_server.cc_modules.cc_db import add_multiple_columns

38 from camcops_server.cc_modules.cc_html import (

39 answer,

40 tr_qa,

41 subheading_spanning_two_columns,

42 tr,

43)

44 from camcops_server.cc_modules.cc_request import CamcopsRequest

45 from camcops_server.cc_modules.cc_sqla_coltypes import (

46 BIT_CHECKER,

47 mapped_camcops_column,

48)

49 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

50 from camcops_server.cc_modules.cc_task import (

51 get_from_dict,

52 Task,

53 TaskHasPatientMixin,

54)

55 from camcops_server.cc_modules.cc_text import SS

56 from camcops_server.cc_modules.cc_trackerhelpers import (

57 TrackerAxisTick,

58 TrackerInfo,

59)

60

61

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

63# Fact-G

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

65

66 DISPLAY_DP = 2

67 MAX_QSCORE = 4

68 NON_REVERSE_SCORED_EMOTIONAL_QNUM = 2

69

70

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

121 class 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 """