Coverage for cc_modules/cc_blob.py: 53%

83 statements  

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

1""" 

2camcops_server/cc_modules/cc_blob.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**BLOB (binary large object) handling.** 

27 

28""" 

29 

30import logging 

31from typing import Optional, Type, TYPE_CHECKING 

32 

33from cardinal_pythonlib.httpconst import MimeType 

34from cardinal_pythonlib.logs import BraceStyleAdapter 

35from pendulum import DateTime as Pendulum 

36from sqlalchemy.orm import relationship 

37from sqlalchemy.orm import Mapped, mapped_column, Session as SqlASession 

38from sqlalchemy.orm.relationships import RelationshipProperty 

39from sqlalchemy.sql.sqltypes import Integer, Text 

40import wand.image 

41 

42from camcops_server.cc_modules.cc_db import ( 

43 GenericTabletRecordMixin, 

44 TaskDescendant, 

45) 

46from camcops_server.cc_modules.cc_html import ( 

47 get_data_url, 

48 get_embedded_img_tag, 

49) 

50from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions 

51from camcops_server.cc_modules.cc_sqla_coltypes import ( 

52 mapped_camcops_column, 

53 MimeTypeColType, 

54 TableNameColType, 

55) 

56from camcops_server.cc_modules.cc_sqla_coltypes import ( 

57 LongBlob, 

58 RelationshipInfo, 

59) 

60from camcops_server.cc_modules.cc_sqlalchemy import Base 

61from camcops_server.cc_modules.cc_xml import get_xml_blob_element, XmlElement 

62 

63if TYPE_CHECKING: 

64 from camcops_server.cc_modules.cc_request import ( 

65 CamcopsRequest, 

66 ) 

67 from camcops_server.cc_modules.cc_task import Task 

68 

69log = BraceStyleAdapter(logging.getLogger(__name__)) 

70 

71# ExactImage API documentation is a little hard to find. See: 

72# http://www.exactcode.com/site/open_source/exactimage 

73# man econvert # after sudo apt-get install exactimage 

74# https://exactcode.de/exact-image/trunk/api/api.hh <-- THIS ONE 

75# http://fossies.org/linux/privat/exact-image-0.8.9.tar.gz:a/exact-image-0.8.9/examples/test.py # noqa 

76# http://lickmychip.com/2012/07/26/playing-with-exactimage/ 

77# https://github.com/romainneutron/ExactImage-PHP 

78# Also, rotation is not simple! 

79# Wand seems much better: http://docs.wand-py.org/en/0.3.5/ 

80 

81 

82# ============================================================================= 

83# Blob class 

84# ============================================================================= 

85 

86 

87class Blob(GenericTabletRecordMixin, TaskDescendant, Base): 

88 """ 

89 Class representing a binary large object (BLOB). 

90 

91 Has helper functions for PNG image processing. 

92 """ 

93 

94 __tablename__ = "blobs" 

95 id: Mapped[int] = mapped_column( 

96 comment="BLOB (binary large object) primary key on the source " 

97 "tablet device", 

98 ) 

99 tablename: Mapped[str] = mapped_column( 

100 TableNameColType, 

101 comment="Name of the table referring to this BLOB", 

102 ) 

103 tablepk: Mapped[int] = mapped_column( 

104 comment="Client-perspective primary key (id field) of the row " 

105 "referring to this BLOB", 

106 ) 

107 fieldname: Mapped[str] = mapped_column( 

108 TableNameColType, 

109 comment="Field name of the field referring to this BLOB by ID", 

110 ) 

111 filename: Mapped[Optional[str]] = mapped_camcops_column( 

112 Text, # Text is correct; filenames can be long 

113 exempt_from_anonymisation=True, 

114 comment="Filename of the BLOB on the source tablet device (on " 

115 "the source device, BLOBs are stored in files, not in " 

116 "the database)", 

117 ) 

118 mimetype: Mapped[Optional[str]] = mapped_column( 

119 MimeTypeColType, comment="MIME type of the BLOB" 

120 ) 

121 image_rotation_deg_cw: Mapped[Optional[int]] = mapped_column( 

122 "image_rotation_deg_cw", 

123 Integer, 

124 comment="For images: rotation to be applied, clockwise, in degrees", 

125 ) 

126 theblob: Mapped[Optional[bytes]] = mapped_column( 

127 "theblob", 

128 LongBlob, 

129 comment="The BLOB itself, a binary object containing arbitrary " 

130 "information (such as a picture)", 

131 ) 

132 

133 @classmethod 

134 def get_current_blob_by_client_info( 

135 cls, dbsession: SqlASession, device_id: int, clientpk: int, era: str 

136 ) -> Optional["Blob"]: 

137 """ 

138 Returns the current Blob object, or None. 

139 """ 

140 # noinspection PyPep8 

141 blob = ( 

142 dbsession.query(cls) 

143 .filter(cls.id == clientpk) # type: ignore[arg-type] 

144 .filter(cls._device_id == device_id) 

145 .filter(cls._era == era) 

146 .filter(cls._current == True) # noqa: E712 

147 .first() 

148 ) # type: Optional[Blob] 

149 return blob 

150 

151 @classmethod 

152 def get_contemporaneous_blob_by_client_info( 

153 cls, 

154 dbsession: SqlASession, 

155 device_id: int, 

156 clientpk: int, 

157 era: str, 

158 referrer_added_utc: Pendulum, 

159 referrer_removed_utc: Optional[Pendulum], 

160 ) -> Optional["Blob"]: 

161 """ 

162 Returns a contemporaneous Blob object, or None. 

163 

164 Use particularly to look up BLOBs matching old task records. 

165 """ 

166 blob = ( 

167 dbsession.query(cls) 

168 .filter(cls.id == clientpk) # type: ignore[arg-type] 

169 .filter(cls._device_id == device_id) 

170 .filter(cls._era == era) 

171 .filter(cls._when_added_batch_utc <= referrer_added_utc) 

172 .filter(cls._when_removed_batch_utc == referrer_removed_utc) 

173 .first() 

174 ) # type: Optional[Blob] 

175 # Note, for referrer_removed_utc: if this is None, then the comparison 

176 # "field == None" is made; otherwise "field == value". 

177 # Since SQLAlchemy translates "== None" to "IS NULL", we're OK. 

178 # https://stackoverflow.com/questions/37445041/sqlalchemy-how-to-filter-column-which-contains-both-null-and-integer-values # noqa 

179 return blob 

180 

181 def get_rotated_image(self) -> Optional[bytes]: 

182 """ 

183 Returns a binary image, having rotated if necessary, or None. 

184 """ 

185 if not self.theblob: 

186 return None 

187 rotation = self.image_rotation_deg_cw 

188 if rotation is None or rotation % 360 == 0: 

189 return self.theblob 

190 with wand.image.Image(blob=self.theblob) as img: 

191 img.rotate(rotation) 

192 return img.make_blob() 

193 # ... no parameter => return in same format as supplied 

194 

195 def get_img_html(self) -> str: 

196 """ 

197 Returns an HTML IMG tag encoding the BLOB, or ''. 

198 """ 

199 image_bits = self.get_rotated_image() 

200 if not image_bits: 

201 return "" 

202 return get_embedded_img_tag(self.mimetype or MimeType.PNG, image_bits) 

203 # Historically, CamCOPS supported only PNG, so add this as a default 

204 

205 def get_xml_element(self, req: "CamcopsRequest") -> XmlElement: 

206 """ 

207 Returns an :class:`camcops_server.cc_modules.cc_xml.XmlElement` 

208 representing this BLOB. 

209 """ 

210 options = TaskExportOptions( 

211 xml_skip_fields=["theblob"], 

212 xml_include_plain_columns=True, 

213 include_blobs=False, 

214 ) 

215 branches = self._get_xml_branches(req, options) 

216 blobdata = self._get_xml_theblob_value_binary() 

217 branches.append( 

218 get_xml_blob_element( 

219 name="theblob", 

220 blobdata=blobdata, 

221 comment=Blob.theblob.comment, # type: ignore[attr-defined] 

222 ) 

223 ) 

224 return XmlElement(name=self.__tablename__, value=branches) 

225 

226 def _get_xml_theblob_value_binary(self) -> Optional[bytes]: 

227 """ 

228 Returns a binary value for this object, to be encoded into XML. 

229 """ 

230 image_bits = self.get_rotated_image() 

231 return image_bits 

232 

233 def get_data_url(self) -> str: 

234 """ 

235 Returns a data URL encapsulating the BLOB, or ''. 

236 """ 

237 if not self.theblob: 

238 return "" 

239 return get_data_url(self.mimetype or MimeType.PNG, self.theblob) 

240 

241 # ------------------------------------------------------------------------- 

242 # TaskDescendant overrides 

243 # ------------------------------------------------------------------------- 

244 

245 @classmethod 

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

247 return None 

248 

249 def task_ancestor(self) -> Optional["Task"]: 

250 from camcops_server.cc_modules.cc_task import ( 

251 tablename_to_task_class_dict, 

252 ) # delayed import 

253 

254 d = tablename_to_task_class_dict() 

255 try: 

256 cls = d[self.tablename] # may raise KeyError 

257 return cls.get_linked(self.tablepk, self) 

258 except KeyError: 

259 return None 

260 

261 

262# ============================================================================= 

263# Relationships 

264# ============================================================================= 

265 

266 

267def blob_relationship( 

268 classname: str, blob_id_col_attr_name: str, read_only: bool = True 

269) -> RelationshipProperty: 

270 """ 

271 Simplifies creation of BLOB relationships. 

272 In a class definition, use like this: 

273 

274 .. code-block:: python 

275 

276 class Something(Base): 

277 

278 photo_blobid = camcops_column( 

279 "photo_blobid", Integer, 

280 is_blob_id_field=True, blob_field_xml_name="photo_blob" 

281 ) 

282 

283 photo = blob_relationship("Something", "photo_blobid") 

284 

285 # ... can't use Something directly as it's not yet been fully 

286 # defined, but we want the convenience of defining this 

287 # relationship here without the need to use metaclasses. 

288 # ... SQLAlchemy's primaryjoin uses Python-side names (class and 

289 # attribute), rather than SQL-side names (table and column), 

290 # at least for its fancier things: 

291 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-primaryjoin # noqa 

292 

293 Note that this refers to the CURRENT version of the BLOB. If there is 

294 an editing chain, older BLOB versions are not retrieved. 

295 

296 Compare :class:`camcops_server.cc_modules.cc_task.TaskHasPatientMixin`, 

297 which uses the same strategy, as do several other similar functions. 

298 

299 """ 

300 return relationship( 

301 Blob, 

302 primaryjoin=( 

303 "and_(" 

304 " remote(Blob.id) == foreign({cls}.{fk}), " 

305 " remote(Blob._device_id) == foreign({cls}._device_id), " 

306 " remote(Blob._era) == foreign({cls}._era), " 

307 " remote(Blob._current) == True " 

308 ")".format(cls=classname, fk=blob_id_col_attr_name) 

309 ), 

310 uselist=False, 

311 viewonly=read_only, 

312 info={RelationshipInfo.IS_BLOB: True}, 

313 ) 

314 

315 

316# ============================================================================= 

317# Unit tests 

318# ============================================================================= 

319 

320 

321def get_blob_img_html( 

322 blob: Optional[Blob], html_if_missing: str = "<i>(No picture)</i>" 

323) -> str: 

324 """ 

325 For the specified BLOB, get an HTML IMG tag with embedded data, or an HTML 

326 error message. 

327 """ 

328 if blob is None: 

329 return html_if_missing 

330 return blob.get_img_html() or html_if_missing