Coverage for tasks/shaps.py: 55%

55 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/tasks/shaps.py 

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**Snaith–Hamilton Pleasure Scale (SHAPS) task.** 

27 

28""" 

29 

30from typing import Any, List, Type 

31 

32from cardinal_pythonlib.stringfunc import strseq 

33from sqlalchemy import Integer 

34 

35from camcops_server.cc_modules.cc_constants import CssClass 

36from camcops_server.cc_modules.cc_db import add_multiple_columns 

37from camcops_server.cc_modules.cc_html import tr_qa, tr, answer 

38from camcops_server.cc_modules.cc_request import CamcopsRequest 

39from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

40from camcops_server.cc_modules.cc_task import ( 

41 get_from_dict, 

42 TaskHasPatientMixin, 

43 Task, 

44) 

45from camcops_server.cc_modules.cc_text import SS 

46 

47 

48class Shaps( # type: ignore[misc] 

49 TaskHasPatientMixin, 

50 Task, 

51): 

52 __tablename__ = "shaps" 

53 shortname = "SHAPS" 

54 

55 N_QUESTIONS = 14 

56 MAX_SCORE = 14 

57 

58 @classmethod 

59 def extend_columns(cls: Type["Shaps"], **kwargs: Any) -> None: 

60 

61 add_multiple_columns( 

62 cls, 

63 "q", 

64 1, 

65 cls.N_QUESTIONS, 

66 minimum=0, 

67 maximum=3, 

68 comment_fmt="Q{n} - {s}", 

69 comment_strings=[ 

70 "television", 

71 "family", 

72 "hobbies", 

73 "meal", 

74 "bath", 

75 "flowers", 

76 "smiling", 

77 "smart", 

78 "book", 

79 "tea", 

80 "sunny", 

81 "landscape", 

82 "helping", 

83 "praise", 

84 ], 

85 ) 

86 

87 ALL_QUESTIONS = strseq("q", 1, N_QUESTIONS) 

88 

89 STRONGLY_DISAGREE = 0 

90 DISAGREE = 1 

91 AGREE = 2 

92 STRONGLY_OR_DEFINITELY_AGREE = 3 

93 

94 # Q11 in British Journal of Psychiatry (1995), 167, 99-103 

95 # actually has two "Strongly disagree" options. Assuming this 

96 # is not intentional! 

97 REVERSE_QUESTIONS = {2, 4, 5, 7, 9, 12, 14} 

98 

99 @staticmethod 

100 def longname(req: "CamcopsRequest") -> str: 

101 _ = req.gettext 

102 return _("Snaith–Hamilton Pleasure Scale") 

103 

104 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

105 return self.standard_task_summary_fields() + [ 

106 SummaryElement( 

107 name="total", 

108 coltype=Integer(), 

109 value=self.total_score(), 

110 comment=f"Total score (/{self.MAX_SCORE})", 

111 ) 

112 ] 

113 

114 def is_complete(self) -> bool: 

115 if self.any_fields_none(self.ALL_QUESTIONS): 

116 return False 

117 if not self.field_contents_valid(): 

118 return False 

119 return True 

120 

121 def total_score(self) -> int: 

122 # Consistent with client implementation 

123 return self.count_where( 

124 self.ALL_QUESTIONS, [self.STRONGLY_DISAGREE, self.DISAGREE] 

125 ) 

126 

127 def get_task_html(self, req: CamcopsRequest) -> str: 

128 strongly_disagree = self.wxstring(req, "strongly_disagree") 

129 disagree = self.wxstring(req, "disagree") 

130 agree = self.wxstring(req, "agree") 

131 

132 # We store the actual answers given but these are scored 1 or 0 

133 forward_answer_dict = { 

134 None: None, 

135 self.STRONGLY_DISAGREE: "1 — " + strongly_disagree, 

136 self.DISAGREE: "1 — " + disagree, 

137 self.AGREE: "0 — " + agree, 

138 self.STRONGLY_OR_DEFINITELY_AGREE: "0 — " 

139 + self.wxstring(req, "strongly_agree"), 

140 } 

141 

142 # Subtle difference in wording when options presented in reverse 

143 reverse_answer_dict = { 

144 None: None, 

145 self.STRONGLY_OR_DEFINITELY_AGREE: "0 — " 

146 + self.wxstring(req, "definitely_agree"), 

147 self.AGREE: "0 — " + agree, 

148 self.DISAGREE: "1 — " + disagree, 

149 self.STRONGLY_DISAGREE: "1 — " + strongly_disagree, 

150 } 

151 

152 rows = "" 

153 for q_num in range(1, self.N_QUESTIONS + 1): 

154 q_field = "q" + str(q_num) 

155 question_cell = "{}. {}".format(q_num, self.wxstring(req, q_field)) 

156 

157 answer_dict = forward_answer_dict 

158 

159 if q_num in self.REVERSE_QUESTIONS: 

160 answer_dict = reverse_answer_dict 

161 

162 answer_cell = get_from_dict(answer_dict, getattr(self, q_field)) 

163 rows += tr_qa(question_cell, answer_cell) 

164 

165 html = """ 

166 <div class="{CssClass.SUMMARY}"> 

167 <table class="{CssClass.SUMMARY}"> 

168 {tr_is_complete} 

169 {total_score} 

170 </table> 

171 </div> 

172 <table class="{CssClass.TASKDETAIL}"> 

173 <tr> 

174 <th width="60%">Question</th> 

175 <th width="40%">Answer</th> 

176 </tr> 

177 {rows} 

178 </table> 

179 <div class="{CssClass.FOOTNOTES}"> 

180 [1] Score 1 point for either ‘disagree’ option, 

181 0 points for either ‘agree’ option. 

182 </div> 

183 """.format( 

184 CssClass=CssClass, 

185 tr_is_complete=self.get_is_complete_tr(req), 

186 total_score=tr( 

187 req.sstring(SS.TOTAL_SCORE) + " <sup>[1]</sup>", 

188 "{} / {}".format(answer(self.total_score()), self.MAX_SCORE), 

189 ), 

190 rows=rows, 

191 ) 

192 return html