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
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_blob.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28**BLOB (binary large object) handling.**
30"""
32import logging
33from typing import Optional, Type, TYPE_CHECKING
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
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
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
72log = BraceStyleAdapter(logging.getLogger(__name__))
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/
85# =============================================================================
86# Blob class
87# =============================================================================
90class Blob(GenericTabletRecordMixin, TaskDescendant, Base):
91 """
92 Class representing a binary large object (BLOB).
94 Has helper functions for PNG image processing.
95 """
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]
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
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.
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
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
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
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)
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
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)
253 # -------------------------------------------------------------------------
254 # TaskDescendant overrides
255 # -------------------------------------------------------------------------
257 @classmethod
258 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
259 return None
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
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
274# =============================================================================
275# Relationships
276# =============================================================================
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:
286 .. code-block:: python
288 class Something(Base):
290 photo_blobid = CamcopsColumn(
291 "photo_blobid", Integer,
292 is_blob_id_field=True, blob_field_xml_name="photo_blob"
293 )
295 photo = blob_relationship("Something", "photo_blobid")
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
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.
308 Compare :class:`camcops_server.cc_modules.cc_task.TaskHasPatientMixin`,
309 which uses the same strategy, as do several other similar functions.
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 )
328# =============================================================================
329# Unit tests
330# =============================================================================
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