Coverage for tasks/photo.py: 63%

84 statements  

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

1""" 

2camcops_server/tasks/photo.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""" 

27 

28from typing import List, Optional, Type 

29 

30import cardinal_pythonlib.rnc_web as ws 

31from sqlalchemy.orm import Mapped, mapped_column 

32from sqlalchemy.sql.sqltypes import UnicodeText 

33 

34from camcops_server.cc_modules.cc_blob import ( 

35 blob_relationship, 

36 get_blob_img_html, 

37) 

38from camcops_server.cc_modules.cc_constants import CssClass 

39from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

40from camcops_server.cc_modules.cc_db import ( 

41 ancillary_relationship, 

42 GenericTabletRecordMixin, 

43 TaskDescendant, 

44) 

45from camcops_server.cc_modules.cc_html import answer, tr_qa 

46from camcops_server.cc_modules.cc_request import CamcopsRequest 

47from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

48from camcops_server.cc_modules.cc_sqla_coltypes import mapped_camcops_column 

49from camcops_server.cc_modules.cc_sqlalchemy import Base 

50from camcops_server.cc_modules.cc_task import ( 

51 Task, 

52 TaskHasClinicianMixin, 

53 TaskHasPatientMixin, 

54) 

55 

56 

57# ============================================================================= 

58# Photo 

59# ============================================================================= 

60 

61 

62class Photo(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501 

63 """ 

64 Server implementation of the Photo task. 

65 """ 

66 

67 __tablename__ = "photo" 

68 shortname = "Photo" 

69 info_filename_stem = "clinical" 

70 

71 description: Mapped[Optional[str]] = mapped_column( 

72 UnicodeText, comment="Description of the photograph" 

73 ) 

74 photo_blobid: Mapped[Optional[int]] = mapped_camcops_column( 

75 is_blob_id_field=True, 

76 blob_relationship_attr_name="photo", 

77 comment="ID of the BLOB (foreign key to blobs.id, given " 

78 "matching device and current/frozen record status)", 

79 ) 

80 # IGNORED. REMOVE WHEN ALL PRE-2.0.0 TABLETS GONE: 

81 rotation: Mapped[Optional[int]] = mapped_column( # DEFUNCT as of v2.0.0 

82 comment="Rotation (clockwise, in degrees) to be applied for viewing", 

83 ) 

84 

85 photo = blob_relationship("Photo", "photo_blobid") # type: ignore[assignment] # noqa: E501 

86 

87 @staticmethod 

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

89 _ = req.gettext 

90 return _("Photograph") 

91 

92 def is_complete(self) -> bool: 

93 return self.photo_blobid is not None 

94 

95 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

96 if not self.is_complete(): 

97 return CTV_INCOMPLETE 

98 if not self.description: 

99 return [] 

100 return [CtvInfo(content=self.description)] 

101 

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

103 # noinspection PyTypeChecker 

104 return """ 

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

106 <tr class="{CssClass.SUBHEADING}"><td>Description</td></tr> 

107 <tr><td>{description}</td></tr> 

108 <tr class="{CssClass.SUBHEADING}"><td>Photo</td></tr> 

109 <tr><td>{photo}</td></tr> 

110 </table> 

111 """.format( 

112 CssClass=CssClass, 

113 description=answer( 

114 ws.webify(self.description), 

115 default="(No description)", 

116 default_for_blank_strings=True, 

117 ), 

118 # ... xhtml2pdf crashes if the contents are empty... 

119 photo=get_blob_img_html(self.photo), # type: ignore[arg-type] 

120 ) 

121 

122 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

123 if not self.is_complete(): 

124 return [] 

125 return [ 

126 SnomedExpression(req.snomed(SnomedLookup.PHOTOGRAPH_PROCEDURE)), 

127 SnomedExpression( 

128 req.snomed(SnomedLookup.PHOTOGRAPH_PHYSICAL_OBJECT) 

129 ), 

130 ] 

131 

132 

133# ============================================================================= 

134# PhotoSequence 

135# ============================================================================= 

136 

137 

138class PhotoSequenceSinglePhoto(GenericTabletRecordMixin, TaskDescendant, Base): 

139 __tablename__ = "photosequence_photos" 

140 

141 photosequence_id: Mapped[int] = mapped_column( 

142 comment="Tablet FK to photosequence", 

143 ) 

144 seqnum: Mapped[int] = mapped_column( 

145 comment="Sequence number of this photo " 

146 "(consistently 1-based as of 2018-12-01)", 

147 ) 

148 description: Mapped[Optional[str]] = mapped_column( 

149 UnicodeText, comment="Description of the photograph" 

150 ) 

151 photo_blobid: Mapped[Optional[int]] = mapped_camcops_column( 

152 is_blob_id_field=True, 

153 blob_relationship_attr_name="photo", 

154 comment="ID of the BLOB (foreign key to blobs.id, given " 

155 "matching device and current/frozen record status)", 

156 ) 

157 # IGNORED. REMOVE WHEN ALL PRE-2.0.0 TABLETS GONE: 

158 rotation: Mapped[Optional[int]] = mapped_column( # DEFUNCT as of v2.0.0 

159 comment="(DEFUNCT COLUMN) " 

160 "Rotation (clockwise, in degrees) to be applied for viewing", 

161 ) 

162 

163 photo = blob_relationship("PhotoSequenceSinglePhoto", "photo_blobid") 

164 

165 def get_html_table_rows(self) -> str: 

166 # noinspection PyTypeChecker 

167 return """ 

168 <tr class="{CssClass.SUBHEADING}"> 

169 <td>Photo {num}: <b>{description}</b></td> 

170 </tr> 

171 <tr><td>{photo}</td></tr> 

172 """.format( 

173 CssClass=CssClass, 

174 num=self.seqnum, 

175 description=ws.webify(self.description), 

176 photo=get_blob_img_html(self.photo), # type: ignore[arg-type] 

177 ) 

178 

179 # ------------------------------------------------------------------------- 

180 # TaskDescendant overrides 

181 # ------------------------------------------------------------------------- 

182 

183 @classmethod 

184 def task_ancestor_class(cls) -> Optional[Type["Task"]]: 

185 return PhotoSequence 

186 

187 def task_ancestor(self) -> Optional["PhotoSequence"]: 

188 return PhotoSequence.get_linked(self.photosequence_id, self) # type: ignore[return-value] # noqa: E501 

189 

190 

191class PhotoSequence(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501 

192 """ 

193 Server implementation of the PhotoSequence task. 

194 """ 

195 

196 __tablename__ = "photosequence" 

197 shortname = "PhotoSequence" 

198 info_filename_stem = "clinical" 

199 

200 sequence_description: Mapped[Optional[str]] = mapped_column( 

201 UnicodeText, 

202 comment="Description of the sequence of photographs", 

203 ) 

204 

205 photos = ancillary_relationship( # type: ignore[assignment] 

206 parent_class_name="PhotoSequence", 

207 ancillary_class_name="PhotoSequenceSinglePhoto", 

208 ancillary_fk_to_parent_attr_name="photosequence_id", 

209 ancillary_order_by_attr_name="seqnum", 

210 ) # type: List[PhotoSequenceSinglePhoto] 

211 

212 @staticmethod 

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

214 _ = req.gettext 

215 return _("Photograph sequence") 

216 

217 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

218 infolist = [CtvInfo(content=self.sequence_description)] 

219 for p in self.photos: 

220 infolist.append(CtvInfo(content=p.description)) 

221 return infolist 

222 

223 def get_num_photos(self) -> int: 

224 return len(self.photos) 

225 

226 def is_complete(self) -> bool: 

227 # If you're wondering why this is being called unexpectedly: it may be 

228 # because this task is being displayed in the task list, at which point 

229 # we colour it by its complete-or-not status. 

230 return bool(self.sequence_description) and self.get_num_photos() > 0 

231 

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

233 html = f""" 

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

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

236 {self.get_is_complete_tr(req)} 

237 {tr_qa("Number of photos", self.get_num_photos())} 

238 {tr_qa("Description", self.sequence_description)} 

239 </table> 

240 </div> 

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

242 """ 

243 for p in self.photos: 

244 html += p.get_html_table_rows() 

245 html += """ 

246 </table> 

247 """ 

248 return html 

249 

250 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

251 if not self.is_complete(): 

252 return [] 

253 return [ 

254 SnomedExpression(req.snomed(SnomedLookup.PHOTOGRAPH_PROCEDURE)), 

255 SnomedExpression( 

256 req.snomed(SnomedLookup.PHOTOGRAPH_PHYSICAL_OBJECT) 

257 ), 

258 ]