Coverage for tasks/factg.py: 56%
125 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« 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
4"""
5===============================================================================
7 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
8 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27- By Joe Kearney, Rudolf Cardinal.
29"""
31from typing import Any, Dict, List, Tuple, Type
33from cardinal_pythonlib.stringfunc import strseq
34from sqlalchemy.ext.declarative import DeclarativeMeta
35from sqlalchemy.sql.sqltypes import Boolean, Float
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)
63# =============================================================================
64# Fact-G
65# =============================================================================
67DISPLAY_DP = 2
68MAX_QSCORE = 4
69NON_REVERSE_SCORED_EMOTIONAL_QNUM = 2
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)
157class FactgGroupInfo(object):
158 """
159 Internal information class for the FACT-G.
160 """
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)
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
207class Factg(TaskHasPatientMixin, Task, metaclass=FactgMetaclass):
208 """
209 Server implementation of the Fact-G task.
210 """
212 __tablename__ = "factg"
213 shortname = "FACT-G"
214 provides_trackers = True
216 N_QUESTIONS_PHYSICAL = 7
217 N_QUESTIONS_SOCIAL = 7
218 N_QUESTIONS_EMOTIONAL = 6
219 N_QUESTIONS_FUNCTIONAL = 7
221 MAX_SCORE_PHYSICAL = 28
222 MAX_SCORE_SOCIAL = 28
223 MAX_SCORE_EMOTIONAL = 24
224 MAX_SCORE_FUNCTIONAL = 28
226 N_ALL = (
227 N_QUESTIONS_PHYSICAL
228 + N_QUESTIONS_SOCIAL
229 + N_QUESTIONS_EMOTIONAL
230 + N_QUESTIONS_FUNCTIONAL
231 )
233 MAX_SCORE_TOTAL = N_ALL * MAX_QSCORE
235 PHYSICAL_PREFIX = "p_q"
236 SOCIAL_PREFIX = "s_q"
237 EMOTIONAL_PREFIX = "e_q"
238 FUNCTIONAL_PREFIX = "f_q"
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)
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 ]
282 OPTIONAL_Q = "s_q7"
284 ignore_s_q7 = CamcopsColumn(
285 "ignore_s_q7", Boolean, permitted_value_checker=BIT_CHECKER
286 )
288 @staticmethod
289 def longname(req: "CamcopsRequest") -> str:
290 _ = req.gettext
291 return _("Functional Assessment of Cancer Therapy — General")
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)
298 all_qs = [
299 self.QUESTIONS_PHYSICAL,
300 questions_social,
301 self.QUESTIONS_EMOTIONAL,
302 self.QUESTIONS_FUNCTIONAL,
303 ]
305 for qlist in all_qs:
306 if self.any_fields_none(qlist):
307 return False
309 if not self.field_contents_valid():
310 return False
312 return True
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 ]
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
358 def subscores(self) -> List[float]:
359 sscores = []
360 for info in self.GROUPS:
361 sscores.append(info.subscore(self))
362 return sscores
364 def total_score(self) -> float:
365 return sum(self.subscores())
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 = ""
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 )
398 tscore = round(self.total_score(), DISPLAY_DP)
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 """