Coverage for tasks/photo.py: 63%

1"""

2 camcops_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

28 from typing import List, Optional, Type

29

30 import cardinal_pythonlib.rnc_web as ws

31 from sqlalchemy.orm import Mapped, mapped_column

32 from sqlalchemy.sql.sqltypes import UnicodeText

33

34 from camcops_server.cc_modules.cc_blob import (

35 blob_relationship,

36 get_blob_img_html,

37)

38 from camcops_server.cc_modules.cc_constants import CssClass

39 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

40 from camcops_server.cc_modules.cc_db import (

41 ancillary_relationship,

42 GenericTabletRecordMixin,

43 TaskDescendant,

44)

45 from camcops_server.cc_modules.cc_html import answer, tr_qa

46 from camcops_server.cc_modules.cc_request import CamcopsRequest

47 from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup

48 from camcops_server.cc_modules.cc_sqla_coltypes import mapped_camcops_column

49 from camcops_server.cc_modules.cc_sqlalchemy import Base

50 from camcops_server.cc_modules.cc_task import (

51 Task,

52 TaskHasClinicianMixin,

53 TaskHasPatientMixin,

54)

55

56

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

58# Photo

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

60

61

62 class 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

138 class 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

191 class 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 ]