Coverage for cc_modules/cc_blob.py: 54%

85 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_blob.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28**BLOB (binary large object) handling.** 

29 

30""" 

31 

32import logging 

33from typing import Optional, Type, TYPE_CHECKING 

34 

35from cardinal_pythonlib.httpconst import MimeType 

36from cardinal_pythonlib.logs import BraceStyleAdapter 

37from pendulum import DateTime as Pendulum 

38from sqlalchemy.orm import relationship 

39from sqlalchemy.orm import Session as SqlASession 

40from sqlalchemy.orm.relationships import RelationshipProperty 

41from sqlalchemy.sql.schema import Column 

42from sqlalchemy.sql.sqltypes import Integer, Text 

43import wand.image 

44 

45from camcops_server.cc_modules.cc_db import ( 

46 GenericTabletRecordMixin, 

47 TaskDescendant, 

48) 

49from camcops_server.cc_modules.cc_html import ( 

50 get_data_url, 

51 get_embedded_img_tag, 

52) 

53from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions 

54from camcops_server.cc_modules.cc_sqla_coltypes import ( 

55 CamcopsColumn, 

56 MimeTypeColType, 

57 TableNameColType, 

58) 

59from camcops_server.cc_modules.cc_sqla_coltypes import ( 

60 LongBlob, 

61 RelationshipInfo, 

62) 

63from camcops_server.cc_modules.cc_sqlalchemy import Base 

64from camcops_server.cc_modules.cc_xml import get_xml_blob_element, XmlElement 

65 

66if TYPE_CHECKING: 

67 from camcops_server.cc_modules.cc_request import ( 

68 CamcopsRequest, # noqa: F401 

69 ) 

70 from camcops_server.cc_modules.cc_task import Task # noqa: F401 

71 

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

73 

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

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

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

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

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

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

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

81# Also, rotation is not simple! 

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

83 

84 

85# ============================================================================= 

86# Blob class 

87# ============================================================================= 

88 

89 

90class Blob(GenericTabletRecordMixin, TaskDescendant, Base): 

91 """ 

92 Class representing a binary large object (BLOB). 

93 

94 Has helper functions for PNG image processing. 

95 """ 

96 

97 __tablename__ = "blobs" 

98 id = Column( 

99 "id", 

100 Integer, 

101 nullable=False, 

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

103 "tablet device", 

104 ) 

105 tablename = Column( 

106 "tablename", 

107 TableNameColType, 

108 nullable=False, 

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

110 ) 

111 tablepk = Column( 

112 "tablepk", 

113 Integer, 

114 nullable=False, 

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

116 "referring to this BLOB", 

117 ) 

118 fieldname = Column( 

119 "fieldname", 

120 TableNameColType, 

121 nullable=False, 

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

123 ) 

124 filename = CamcopsColumn( 

125 "filename", 

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

127 exempt_from_anonymisation=True, 

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

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

130 "the database)", 

131 ) 

132 mimetype = Column( 

133 "mimetype", MimeTypeColType, comment="MIME type of the BLOB" 

134 ) 

135 image_rotation_deg_cw = Column( 

136 "image_rotation_deg_cw", 

137 Integer, 

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

139 ) 

140 theblob = Column( 

141 "theblob", 

142 LongBlob, 

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

144 "information (such as a picture)", 

145 ) # type: Optional[bytes] 

146 

147 @classmethod 

148 def get_current_blob_by_client_info( 

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

150 ) -> Optional["Blob"]: 

151 """ 

152 Returns the current Blob object, or None. 

153 """ 

154 # noinspection PyPep8 

155 blob = ( 

156 dbsession.query(cls) 

157 .filter(cls.id == clientpk) 

158 .filter(cls._device_id == device_id) 

159 .filter(cls._era == era) 

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

161 .first() 

162 ) # type: Optional[Blob] 

163 return blob 

164 

165 @classmethod 

166 def get_contemporaneous_blob_by_client_info( 

167 cls, 

168 dbsession: SqlASession, 

169 device_id: int, 

170 clientpk: int, 

171 era: str, 

172 referrer_added_utc: Pendulum, 

173 referrer_removed_utc: Optional[Pendulum], 

174 ) -> Optional["Blob"]: 

175 """ 

176 Returns a contemporaneous Blob object, or None. 

177 

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

179 """ 

180 blob = ( 

181 dbsession.query(cls) 

182 .filter(cls.id == clientpk) 

183 .filter(cls._device_id == device_id) 

184 .filter(cls._era == era) 

185 .filter(cls._when_added_batch_utc <= referrer_added_utc) 

186 .filter(cls._when_removed_batch_utc == referrer_removed_utc) 

187 .first() 

188 ) # type: Optional[Blob] 

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

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

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

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

193 return blob 

194 

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

196 """ 

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

198 """ 

199 if not self.theblob: 

200 return None 

201 rotation = self.image_rotation_deg_cw 

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

203 return self.theblob 

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

205 img.rotate(rotation) 

206 return img.make_blob() 

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

208 

209 def get_img_html(self) -> str: 

210 """ 

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

212 """ 

213 image_bits = self.get_rotated_image() 

214 if not image_bits: 

215 return "" 

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

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

218 

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

220 """ 

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

222 representing this BLOB. 

223 """ 

224 options = TaskExportOptions( 

225 xml_skip_fields=["theblob"], 

226 xml_include_plain_columns=True, 

227 include_blobs=False, 

228 ) 

229 branches = self._get_xml_branches(req, options) 

230 blobdata = self._get_xml_theblob_value_binary() 

231 branches.append( 

232 get_xml_blob_element( 

233 name="theblob", blobdata=blobdata, comment=Blob.theblob.comment 

234 ) 

235 ) 

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

237 

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

239 """ 

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

241 """ 

242 image_bits = self.get_rotated_image() 

243 return image_bits 

244 

245 def get_data_url(self) -> str: 

246 """ 

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

248 """ 

249 if not self.theblob: 

250 return "" 

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

252 

253 # ------------------------------------------------------------------------- 

254 # TaskDescendant overrides 

255 # ------------------------------------------------------------------------- 

256 

257 @classmethod 

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

259 return None 

260 

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

262 from camcops_server.cc_modules.cc_task import ( 

263 tablename_to_task_class_dict, 

264 ) # noqa # delayed import 

265 

266 d = tablename_to_task_class_dict() 

267 try: 

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

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

270 except KeyError: 

271 return None 

272 

273 

274# ============================================================================= 

275# Relationships 

276# ============================================================================= 

277 

278 

279def blob_relationship( 

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

281) -> RelationshipProperty: 

282 """ 

283 Simplifies creation of BLOB relationships. 

284 In a class definition, use like this: 

285 

286 .. code-block:: python 

287 

288 class Something(Base): 

289 

290 photo_blobid = CamcopsColumn( 

291 "photo_blobid", Integer, 

292 is_blob_id_field=True, blob_field_xml_name="photo_blob" 

293 ) 

294 

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

296 

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

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

299 # relationship here without the need to use metaclasses. 

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

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

302 # at least for its fancier things: 

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

304 

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

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

307 

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

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

310 

311 """ 

312 return relationship( 

313 Blob, 

314 primaryjoin=( 

315 "and_(" 

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

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

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

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

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

321 ), 

322 uselist=False, 

323 viewonly=read_only, 

324 info={RelationshipInfo.IS_BLOB: True}, 

325 ) 

326 

327 

328# ============================================================================= 

329# Unit tests 

330# ============================================================================= 

331 

332 

333def get_blob_img_html( 

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

335) -> str: 

336 """ 

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

338 error message. 

339 """ 

340 if blob is None: 

341 return html_if_missing 

342 return blob.get_img_html() or html_if_missing