Coverage for tasks/factg.py: 55%
123 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1# camcops_server/tasks/factg.py
3"""
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26- By Joe Kearney, Rudolf Cardinal.
28"""
30from typing import Any, List, Optional, Type
32from cardinal_pythonlib.stringfunc import strseq
33from sqlalchemy.orm import Mapped
34from sqlalchemy.sql.sqltypes import Float
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)
62# =============================================================================
63# Fact-G
64# =============================================================================
66DISPLAY_DP = 2
67MAX_QSCORE = 4
68NON_REVERSE_SCORED_EMOTIONAL_QNUM = 2
71class FactgGroupInfo(object):
72 """
73 Internal information class for the FACT-G.
74 """
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)
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
121class Factg(TaskHasPatientMixin, Task): # type: ignore[misc]
122 """
123 Server implementation of the Fact-G task.
124 """
126 __tablename__ = "factg"
127 shortname = "FACT-G"
128 provides_trackers = True
130 N_QUESTIONS_PHYSICAL = 7
131 N_QUESTIONS_SOCIAL = 7
132 N_QUESTIONS_EMOTIONAL = 6
133 N_QUESTIONS_FUNCTIONAL = 7
135 MAX_SCORE_PHYSICAL = 28
136 MAX_SCORE_SOCIAL = 28
137 MAX_SCORE_EMOTIONAL = 24
138 MAX_SCORE_FUNCTIONAL = 28
140 N_ALL = (
141 N_QUESTIONS_PHYSICAL
142 + N_QUESTIONS_SOCIAL
143 + N_QUESTIONS_EMOTIONAL
144 + N_QUESTIONS_FUNCTIONAL
145 )
147 MAX_SCORE_TOTAL = N_ALL * MAX_QSCORE
149 PHYSICAL_PREFIX = "p_q"
150 SOCIAL_PREFIX = "s_q"
151 EMOTIONAL_PREFIX = "e_q"
152 FUNCTIONAL_PREFIX = "f_q"
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)
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 ]
196 OPTIONAL_Q = "s_q7"
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 )
275 ignore_s_q7: Mapped[Optional[bool]] = mapped_camcops_column(
276 permitted_value_checker=BIT_CHECKER
277 )
279 @staticmethod
280 def longname(req: "CamcopsRequest") -> str:
281 _ = req.gettext
282 return _("Functional Assessment of Cancer Therapy — General")
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)
289 all_qs = [
290 self.QUESTIONS_PHYSICAL,
291 questions_social,
292 self.QUESTIONS_EMOTIONAL,
293 self.QUESTIONS_FUNCTIONAL,
294 ]
296 for qlist in all_qs:
297 if self.any_fields_none(qlist):
298 return False
300 if not self.field_contents_valid():
301 return False
303 return True
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 ]
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
349 def subscores(self) -> List[float]:
350 sscores = []
351 for info in self.GROUPS:
352 sscores.append(info.subscore(self))
353 return sscores
355 def total_score(self) -> float:
356 return sum(self.subscores())
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 = ""
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 )
389 tscore = round(self.total_score(), DISPLAY_DP)
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 """