Coverage for tasks/factg.py : 56%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
2# camcops_server/tasks/factg.py
4"""
5===============================================================================
7 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
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, Dict, List, Tuple, Type
32from cardinal_pythonlib.stringfunc import strseq
33from sqlalchemy.ext.declarative import DeclarativeMeta
34from sqlalchemy.sql.sqltypes import Boolean, 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 CamcopsColumn,
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 FactgMetaclass(DeclarativeMeta):
72 # noinspection PyInitNewSignature
73 def __init__(cls: Type['Factg'],
74 name: str,
75 bases: Tuple[Type, ...],
76 classdict: Dict[str, Any]) -> None:
77 answer_stem = (
78 " (0 not at all, 1 a little bit, 2 somewhat, 3 quite a bit, "
79 "4 very much)"
80 )
81 add_multiple_columns(
82 cls, "p_q", 1, cls.N_QUESTIONS_PHYSICAL,
83 minimum=0, maximum=4,
84 comment_fmt="Physical well-being Q{n} ({s})" + answer_stem,
85 comment_strings=[
86 "lack of energy",
87 "nausea",
88 "trouble meeting family needs",
89 "pain",
90 "treatment side effects",
91 "feel ill",
92 "bedbound",
93 ],
94 )
95 add_multiple_columns(
96 cls, "s_q", 1, cls.N_QUESTIONS_SOCIAL,
97 minimum=0, maximum=4,
98 comment_fmt="Social well-being Q{n} ({s})" + answer_stem,
99 comment_strings=[
100 "close to friends",
101 "emotional support from family",
102 "support from friends",
103 "family accepted illness",
104 "good family comms re illness",
105 "feel close to partner/main supporter",
106 "satisfied with sex life",
107 ],
108 )
109 add_multiple_columns(
110 cls, "e_q", 1, cls.N_QUESTIONS_EMOTIONAL,
111 minimum=0, maximum=4,
112 comment_fmt="Emotional well-being Q{n} ({s})" + answer_stem,
113 comment_strings=[
114 "sad",
115 "satisfied with coping re illness",
116 "losing hope in fight against illness",
117 "nervous"
118 "worried about dying",
119 "worried condition will worsen",
120 ],
121 )
122 add_multiple_columns(
123 cls, "f_q", 1, cls.N_QUESTIONS_FUNCTIONAL,
124 minimum=0, maximum=4,
125 comment_fmt="Functional well-being Q{n} ({s})" + answer_stem,
126 comment_strings=[
127 "able to work",
128 "work fulfilling",
129 "able to enjoy life",
130 "accepted illness",
131 "sleeping well",
132 "enjoying usual fun things",
133 "content with quality of life",
134 ],
135 )
136 super().__init__(name, bases, classdict)
139class FactgGroupInfo(object):
140 """
141 Internal information class for the FACT-G.
142 """
143 def __init__(self,
144 heading_xstring_name: str,
145 question_prefix: str,
146 fieldnames: List[str],
147 summary_fieldname: str,
148 summary_description: str,
149 max_score: int,
150 reverse_score_all: bool = False,
151 reverse_score_all_but_q2: bool = False) -> None:
152 self.heading_xstring_name = heading_xstring_name
153 self.question_prefix = question_prefix
154 self.fieldnames = fieldnames
155 self.summary_fieldname = summary_fieldname
156 self.summary_description = summary_description
157 self.max_score = max_score
158 self.reverse_score_all = reverse_score_all
159 self.reverse_score_all_but_q2 = reverse_score_all_but_q2
160 self.n_questions = len(fieldnames)
162 def subscore(self, task: "Factg") -> float:
163 answered = 0
164 scoresum = 0
165 for qnum, fieldname in enumerate(self.fieldnames, start=1):
166 answer_val = getattr(task, fieldname)
167 try:
168 answer_int = int(answer_val)
169 except (TypeError, ValueError):
170 continue
171 answered += 1
172 if (self.reverse_score_all or
173 (self.reverse_score_all_but_q2 and
174 qnum != NON_REVERSE_SCORED_EMOTIONAL_QNUM)):
175 # reverse-scored
176 scoresum += MAX_QSCORE - answer_int
177 else:
178 # normally scored
179 scoresum += answer_int
180 if answered == 0:
181 return 0
182 return scoresum * self.n_questions / answered
185class Factg(TaskHasPatientMixin, Task,
186 metaclass=FactgMetaclass):
187 """
188 Server implementation of the Fact-G task.
189 """
190 __tablename__ = "factg"
191 shortname = "FACT-G"
192 provides_trackers = True
194 N_QUESTIONS_PHYSICAL = 7
195 N_QUESTIONS_SOCIAL = 7
196 N_QUESTIONS_EMOTIONAL = 6
197 N_QUESTIONS_FUNCTIONAL = 7
199 MAX_SCORE_PHYSICAL = 28
200 MAX_SCORE_SOCIAL = 28
201 MAX_SCORE_EMOTIONAL = 24
202 MAX_SCORE_FUNCTIONAL = 28
204 N_ALL = (
205 N_QUESTIONS_PHYSICAL + N_QUESTIONS_SOCIAL +
206 N_QUESTIONS_EMOTIONAL + N_QUESTIONS_FUNCTIONAL
207 )
209 MAX_SCORE_TOTAL = N_ALL * MAX_QSCORE
211 PHYSICAL_PREFIX = "p_q"
212 SOCIAL_PREFIX = "s_q"
213 EMOTIONAL_PREFIX = "e_q"
214 FUNCTIONAL_PREFIX = "f_q"
216 QUESTIONS_PHYSICAL = strseq(PHYSICAL_PREFIX, 1, N_QUESTIONS_PHYSICAL)
217 QUESTIONS_SOCIAL = strseq(SOCIAL_PREFIX, 1, N_QUESTIONS_SOCIAL)
218 QUESTIONS_EMOTIONAL = strseq(EMOTIONAL_PREFIX, 1, N_QUESTIONS_EMOTIONAL)
219 QUESTIONS_FUNCTIONAL = strseq(FUNCTIONAL_PREFIX, 1, N_QUESTIONS_FUNCTIONAL)
221 GROUPS = [
222 FactgGroupInfo("h1", PHYSICAL_PREFIX, QUESTIONS_PHYSICAL,
223 "physical_wellbeing", "Physical wellbeing subscore",
224 MAX_SCORE_PHYSICAL,
225 reverse_score_all=True),
226 FactgGroupInfo("h2", SOCIAL_PREFIX, QUESTIONS_SOCIAL,
227 "social_family_wellbeing",
228 "Social/family wellbeing subscore",
229 MAX_SCORE_SOCIAL),
230 FactgGroupInfo("h3", EMOTIONAL_PREFIX, QUESTIONS_EMOTIONAL,
231 "emotional_wellbeing", "Emotional wellbeing subscore",
232 MAX_SCORE_EMOTIONAL,
233 reverse_score_all_but_q2=True),
234 FactgGroupInfo("h4", FUNCTIONAL_PREFIX, QUESTIONS_FUNCTIONAL,
235 "functional_wellbeing", "Functional wellbeing subscore",
236 MAX_SCORE_FUNCTIONAL),
237 ]
239 OPTIONAL_Q = "s_q7"
241 ignore_s_q7 = CamcopsColumn("ignore_s_q7", Boolean,
242 permitted_value_checker=BIT_CHECKER)
244 @staticmethod
245 def longname(req: "CamcopsRequest") -> str:
246 _ = req.gettext
247 return _("Functional Assessment of Cancer Therapy — General")
249 def is_complete(self) -> bool:
250 questions_social = self.QUESTIONS_SOCIAL.copy()
251 if self.ignore_s_q7:
252 questions_social.remove(self.OPTIONAL_Q)
254 all_qs = [self.QUESTIONS_PHYSICAL, questions_social,
255 self.QUESTIONS_EMOTIONAL, self.QUESTIONS_FUNCTIONAL]
257 for qlist in all_qs:
258 if self.any_fields_none(qlist):
259 return False
261 if not self.field_contents_valid():
262 return False
264 return True
266 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
267 return [TrackerInfo(
268 value=self.total_score(),
269 plot_label="FACT-G total score (rating well-being)",
270 axis_label=f"Total score (out of {self.MAX_SCORE_TOTAL})",
271 axis_min=-0.5,
272 axis_max=self.MAX_SCORE_TOTAL + 0.5,
273 axis_ticks=[
274 TrackerAxisTick(108, "108"),
275 TrackerAxisTick(100, "100"),
276 TrackerAxisTick(80, "80"),
277 TrackerAxisTick(60, "60"),
278 TrackerAxisTick(40, "40"),
279 TrackerAxisTick(20, "20"),
280 TrackerAxisTick(0, "0"),
281 ],
282 horizontal_lines=[
283 80,
284 60,
285 40,
286 20
287 ],
288 )]
290 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
291 elements = self.standard_task_summary_fields()
292 for info in self.GROUPS:
293 subscore = info.subscore(self)
294 elements.append(SummaryElement(
295 name=info.summary_fieldname, coltype=Float(),
296 value=subscore,
297 comment=f"{info.summary_description} (out of {info.max_score})"
298 ))
299 elements.append(SummaryElement(
300 name="total_score", coltype=Float(),
301 value=self.total_score(),
302 comment=f"Total score (out of {self.MAX_SCORE_TOTAL})"
303 ))
304 return elements
306 def subscores(self) -> List[float]:
307 sscores = []
308 for info in self.GROUPS:
309 sscores.append(info.subscore(self))
310 return sscores
312 def total_score(self) -> float:
313 return sum(self.subscores())
315 def get_task_html(self, req: CamcopsRequest) -> str:
316 answers = {
317 None: None,
318 0: "0 — " + self.wxstring(req, "a0"),
319 1: "1 — " + self.wxstring(req, "a1"),
320 2: "2 — " + self.wxstring(req, "a2"),
321 3: "3 — " + self.wxstring(req, "a3"),
322 4: "4 — " + self.wxstring(req, "a4"),
323 }
324 subscore_html = ""
325 answer_html = ""
327 for info in self.GROUPS:
328 heading = self.wxstring(req, info.heading_xstring_name)
329 subscore = info.subscore(self)
330 subscore_html += tr(
331 heading,
332 (
333 answer(round(subscore, DISPLAY_DP)) +
334 f" / {info.max_score}"
335 )
336 )
337 answer_html += subheading_spanning_two_columns(heading)
338 for q in info.fieldnames:
339 if q == self.OPTIONAL_Q:
340 # insert additional row
341 answer_html += tr_qa(
342 self.xstring(req, "prefer_no_answer"),
343 self.ignore_s_q7)
344 answer_val = getattr(self, q)
345 answer_html += tr_qa(self.wxstring(req, q),
346 get_from_dict(answers, answer_val))
348 tscore = round(self.total_score(), DISPLAY_DP)
350 tr_total_score = tr(
351 req.sstring(SS.TOTAL_SCORE),
352 answer(tscore) + f" / {self.MAX_SCORE_TOTAL}"
353 )
354 return f"""
355 <div class="{CssClass.SUMMARY}">
356 <table class="{CssClass.SUMMARY}">
357 {self.get_is_complete_tr(req)}
358 {tr_total_score}
359 {subscore_html}
360 </table>
361 </div>
362 <table class="{CssClass.TASKDETAIL}">
363 <tr>
364 <th width="50%">Question</th>
365 <th width="50%">Answer</th>
366 </tr>
367 {answer_html}
368 </table>
369 """