# camcops_server/tasks/factg.py
"""
===============================================================================
Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
This file is part of CamCOPS.
CamCOPS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CamCOPS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
===============================================================================
- By Joe Kearney, Rudolf Cardinal.
"""
from typing import Any, Dict, List, Tuple, Type
from cardinal_pythonlib.stringfunc import strseq
from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.sql.sqltypes import Boolean, Float
from camcops_server.cc_modules.cc_constants import CssClass
from camcops_server.cc_modules.cc_db import add_multiple_columns
from camcops_server.cc_modules.cc_html import (
answer,
tr_qa,
subheading_spanning_two_columns,
tr,
)
from camcops_server.cc_modules.cc_request import CamcopsRequest
from camcops_server.cc_modules.cc_sqla_coltypes import (
BIT_CHECKER,
CamcopsColumn,
)
from camcops_server.cc_modules.cc_summaryelement import SummaryElement
from camcops_server.cc_modules.cc_task import (
get_from_dict,
Task,
TaskHasPatientMixin,
)
from camcops_server.cc_modules.cc_text import SS
from camcops_server.cc_modules.cc_trackerhelpers import (
TrackerAxisTick,
TrackerInfo,
)
# =============================================================================
# Fact-G
# =============================================================================
DISPLAY_DP = 2
MAX_QSCORE = 4
NON_REVERSE_SCORED_EMOTIONAL_QNUM = 2
class FactgMetaclass(DeclarativeMeta):
# noinspection PyInitNewSignature
def __init__(
cls: Type["Factg"],
name: str,
bases: Tuple[Type, ...],
classdict: Dict[str, Any],
) -> None:
answer_stem = (
" (0 not at all, 1 a little bit, 2 somewhat, 3 quite a bit, "
"4 very much)"
)
add_multiple_columns(
cls,
"p_q",
1,
cls.N_QUESTIONS_PHYSICAL,
minimum=0,
maximum=4,
comment_fmt="Physical well-being Q{n} ({s})" + answer_stem,
comment_strings=[
"lack of energy",
"nausea",
"trouble meeting family needs",
"pain",
"treatment side effects",
"feel ill",
"bedbound",
],
)
add_multiple_columns(
cls,
"s_q",
1,
cls.N_QUESTIONS_SOCIAL,
minimum=0,
maximum=4,
comment_fmt="Social well-being Q{n} ({s})" + answer_stem,
comment_strings=[
"close to friends",
"emotional support from family",
"support from friends",
"family accepted illness",
"good family comms re illness",
"feel close to partner/main supporter",
"satisfied with sex life",
],
)
add_multiple_columns(
cls,
"e_q",
1,
cls.N_QUESTIONS_EMOTIONAL,
minimum=0,
maximum=4,
comment_fmt="Emotional well-being Q{n} ({s})" + answer_stem,
comment_strings=[
"sad",
"satisfied with coping re illness",
"losing hope in fight against illness",
"nervous" "worried about dying",
"worried condition will worsen",
],
)
add_multiple_columns(
cls,
"f_q",
1,
cls.N_QUESTIONS_FUNCTIONAL,
minimum=0,
maximum=4,
comment_fmt="Functional well-being Q{n} ({s})" + answer_stem,
comment_strings=[
"able to work",
"work fulfilling",
"able to enjoy life",
"accepted illness",
"sleeping well",
"enjoying usual fun things",
"content with quality of life",
],
)
super().__init__(name, bases, classdict)
[docs]class FactgGroupInfo(object):
"""
Internal information class for the FACT-G.
"""
[docs] def __init__(
self,
heading_xstring_name: str,
question_prefix: str,
fieldnames: List[str],
summary_fieldname: str,
summary_description: str,
max_score: int,
reverse_score_all: bool = False,
reverse_score_all_but_q2: bool = False,
) -> None:
self.heading_xstring_name = heading_xstring_name
self.question_prefix = question_prefix
self.fieldnames = fieldnames
self.summary_fieldname = summary_fieldname
self.summary_description = summary_description
self.max_score = max_score
self.reverse_score_all = reverse_score_all
self.reverse_score_all_but_q2 = reverse_score_all_but_q2
self.n_questions = len(fieldnames)
def subscore(self, task: "Factg") -> float:
answered = 0
scoresum = 0
for qnum, fieldname in enumerate(self.fieldnames, start=1):
answer_val = getattr(task, fieldname)
try:
answer_int = int(answer_val)
except (TypeError, ValueError):
continue
answered += 1
if self.reverse_score_all or (
self.reverse_score_all_but_q2
and qnum != NON_REVERSE_SCORED_EMOTIONAL_QNUM
):
# reverse-scored
scoresum += MAX_QSCORE - answer_int
else:
# normally scored
scoresum += answer_int
if answered == 0:
return 0
return scoresum * self.n_questions / answered
[docs]class Factg(TaskHasPatientMixin, Task, metaclass=FactgMetaclass):
"""
Server implementation of the Fact-G task.
"""
__tablename__ = "factg"
shortname = "FACT-G"
provides_trackers = True
N_QUESTIONS_PHYSICAL = 7
N_QUESTIONS_SOCIAL = 7
N_QUESTIONS_EMOTIONAL = 6
N_QUESTIONS_FUNCTIONAL = 7
MAX_SCORE_PHYSICAL = 28
MAX_SCORE_SOCIAL = 28
MAX_SCORE_EMOTIONAL = 24
MAX_SCORE_FUNCTIONAL = 28
N_ALL = (
N_QUESTIONS_PHYSICAL
+ N_QUESTIONS_SOCIAL
+ N_QUESTIONS_EMOTIONAL
+ N_QUESTIONS_FUNCTIONAL
)
MAX_SCORE_TOTAL = N_ALL * MAX_QSCORE
PHYSICAL_PREFIX = "p_q"
SOCIAL_PREFIX = "s_q"
EMOTIONAL_PREFIX = "e_q"
FUNCTIONAL_PREFIX = "f_q"
QUESTIONS_PHYSICAL = strseq(PHYSICAL_PREFIX, 1, N_QUESTIONS_PHYSICAL)
QUESTIONS_SOCIAL = strseq(SOCIAL_PREFIX, 1, N_QUESTIONS_SOCIAL)
QUESTIONS_EMOTIONAL = strseq(EMOTIONAL_PREFIX, 1, N_QUESTIONS_EMOTIONAL)
QUESTIONS_FUNCTIONAL = strseq(FUNCTIONAL_PREFIX, 1, N_QUESTIONS_FUNCTIONAL)
GROUPS = [
FactgGroupInfo(
"h1",
PHYSICAL_PREFIX,
QUESTIONS_PHYSICAL,
"physical_wellbeing",
"Physical wellbeing subscore",
MAX_SCORE_PHYSICAL,
reverse_score_all=True,
),
FactgGroupInfo(
"h2",
SOCIAL_PREFIX,
QUESTIONS_SOCIAL,
"social_family_wellbeing",
"Social/family wellbeing subscore",
MAX_SCORE_SOCIAL,
),
FactgGroupInfo(
"h3",
EMOTIONAL_PREFIX,
QUESTIONS_EMOTIONAL,
"emotional_wellbeing",
"Emotional wellbeing subscore",
MAX_SCORE_EMOTIONAL,
reverse_score_all_but_q2=True,
),
FactgGroupInfo(
"h4",
FUNCTIONAL_PREFIX,
QUESTIONS_FUNCTIONAL,
"functional_wellbeing",
"Functional wellbeing subscore",
MAX_SCORE_FUNCTIONAL,
),
]
OPTIONAL_Q = "s_q7"
ignore_s_q7 = CamcopsColumn(
"ignore_s_q7", Boolean, permitted_value_checker=BIT_CHECKER
)
[docs] @staticmethod
def longname(req: "CamcopsRequest") -> str:
_ = req.gettext
return _("Functional Assessment of Cancer Therapy — General")
[docs] def is_complete(self) -> bool:
questions_social = self.QUESTIONS_SOCIAL.copy()
if self.ignore_s_q7:
questions_social.remove(self.OPTIONAL_Q)
all_qs = [
self.QUESTIONS_PHYSICAL,
questions_social,
self.QUESTIONS_EMOTIONAL,
self.QUESTIONS_FUNCTIONAL,
]
for qlist in all_qs:
if self.any_fields_none(qlist):
return False
if not self.field_contents_valid():
return False
return True
[docs] def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
return [
TrackerInfo(
value=self.total_score(),
plot_label="FACT-G total score (rating well-being)",
axis_label=f"Total score (out of {self.MAX_SCORE_TOTAL})",
axis_min=-0.5,
axis_max=self.MAX_SCORE_TOTAL + 0.5,
axis_ticks=[
TrackerAxisTick(108, "108"),
TrackerAxisTick(100, "100"),
TrackerAxisTick(80, "80"),
TrackerAxisTick(60, "60"),
TrackerAxisTick(40, "40"),
TrackerAxisTick(20, "20"),
TrackerAxisTick(0, "0"),
],
horizontal_lines=[80, 60, 40, 20],
)
]
[docs] def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
elements = self.standard_task_summary_fields()
for info in self.GROUPS:
subscore = info.subscore(self)
elements.append(
SummaryElement(
name=info.summary_fieldname,
coltype=Float(),
value=subscore,
comment=f"{info.summary_description} "
f"(out of {info.max_score})",
)
)
elements.append(
SummaryElement(
name="total_score",
coltype=Float(),
value=self.total_score(),
comment=f"Total score (out of {self.MAX_SCORE_TOTAL})",
)
)
return elements
def subscores(self) -> List[float]:
sscores = []
for info in self.GROUPS:
sscores.append(info.subscore(self))
return sscores
def total_score(self) -> float:
return sum(self.subscores())
[docs] def get_task_html(self, req: CamcopsRequest) -> str:
answers = {
None: None,
0: "0 — " + self.wxstring(req, "a0"),
1: "1 — " + self.wxstring(req, "a1"),
2: "2 — " + self.wxstring(req, "a2"),
3: "3 — " + self.wxstring(req, "a3"),
4: "4 — " + self.wxstring(req, "a4"),
}
subscore_html = ""
answer_html = ""
for info in self.GROUPS:
heading = self.wxstring(req, info.heading_xstring_name)
subscore = info.subscore(self)
subscore_html += tr(
heading,
(answer(round(subscore, DISPLAY_DP)) + f" / {info.max_score}"),
)
answer_html += subheading_spanning_two_columns(heading)
for q in info.fieldnames:
if q == self.OPTIONAL_Q:
# insert additional row
answer_html += tr_qa(
self.xstring(req, "prefer_no_answer"), self.ignore_s_q7
)
answer_val = getattr(self, q)
answer_html += tr_qa(
self.wxstring(req, q), get_from_dict(answers, answer_val)
)
tscore = round(self.total_score(), DISPLAY_DP)
tr_total_score = tr(
req.sstring(SS.TOTAL_SCORE),
answer(tscore) + f" / {self.MAX_SCORE_TOTAL}",
)
return f"""
<div class="{CssClass.SUMMARY}">
<table class="{CssClass.SUMMARY}">
{self.get_is_complete_tr(req)}
{tr_total_score}
{subscore_html}
</table>
</div>
<table class="{CssClass.TASKDETAIL}">
<tr>
<th width="50%">Question</th>
<th width="50%">Answer</th>
</tr>
{answer_html}
</table>
"""