ir_binary.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import logging
  2. import werkzeug.http
  3. from datetime import datetime
  4. from mimetypes import guess_extension
  5. from odoo import models
  6. from odoo.exceptions import MissingError, UserError
  7. from odoo.http import Stream, request
  8. from odoo.tools import file_open, replace_exceptions
  9. from odoo.tools.image import image_process, image_guess_size_from_field_name
  10. from odoo.tools.mimetypes import guess_mimetype, get_extension
  11. DEFAULT_PLACEHOLDER_PATH = 'web/static/img/placeholder.png'
  12. _logger = logging.getLogger(__name__)
  13. class IrBinary(models.AbstractModel):
  14. _name = 'ir.binary'
  15. _description = "File streaming helper model for controllers"
  16. def _find_record(
  17. self, xmlid=None, res_model='ir.attachment', res_id=None,
  18. access_token=None,
  19. ):
  20. """
  21. Find and return a record either using an xmlid either a model+id
  22. pair. This method is an helper for the ``/web/content`` and
  23. ``/web/image`` controllers and should not be used in other
  24. contextes.
  25. :param Optional[str] xmlid: xmlid of the record
  26. :param Optional[str] res_model: model of the record,
  27. ir.attachment by default.
  28. :param Optional[id] res_id: id of the record
  29. :param Optional[str] access_token: access token to use instead
  30. of the access rights and access rules.
  31. :returns: single record
  32. :raises MissingError: when no record was found.
  33. """
  34. record = None
  35. if xmlid:
  36. record = self.env.ref(xmlid, False)
  37. elif res_id is not None and res_model in self.env:
  38. record = self.env[res_model].browse(res_id).exists()
  39. if not record:
  40. raise MissingError(f"No record found for xmlid={xmlid}, res_model={res_model}, id={res_id}")
  41. record = self._find_record_check_access(record, access_token)
  42. return record
  43. def _find_record_check_access(self, record, access_token):
  44. if record._name == 'ir.attachment':
  45. return record.validate_access(access_token)
  46. record.check_access_rights('read')
  47. record.check_access_rule('read')
  48. return record
  49. def _record_to_stream(self, record, field_name):
  50. """
  51. Low level method responsible for the actual conversion from a
  52. model record to a stream. This method is an extensible hook for
  53. other modules. It is not meant to be directly called from
  54. outside or the ir.binary model.
  55. :param record: the record where to load the data from.
  56. :param str field_name: the binary field where to load the data
  57. from.
  58. :rtype: odoo.http.Stream
  59. """
  60. if record._name == 'ir.attachment' and field_name in ('raw', 'datas', 'db_datas'):
  61. return Stream.from_attachment(record)
  62. record.check_field_access_rights('read', [field_name])
  63. field_def = record._fields[field_name]
  64. # fields.Binary(attachment=False) or compute/related
  65. if not field_def.attachment or field_def.compute or field_def.related:
  66. return Stream.from_binary_field(record, field_name)
  67. # fields.Binary(attachment=True)
  68. field_attachment = self.env['ir.attachment'].sudo().search(
  69. domain=[('res_model', '=', record._name),
  70. ('res_id', '=', record.id),
  71. ('res_field', '=', field_name)],
  72. limit=1)
  73. if not field_attachment:
  74. raise MissingError("The related attachment does not exist.")
  75. return Stream.from_attachment(field_attachment)
  76. def _get_stream_from(
  77. self, record, field_name='raw', filename=None, filename_field='name',
  78. mimetype=None, default_mimetype='application/octet-stream',
  79. ):
  80. """
  81. Create a :class:odoo.http.Stream: from a record's binary field.
  82. :param record: the record where to load the data from.
  83. :param str field_name: the binary field where to load the data
  84. from.
  85. :param Optional[str] filename: when the stream is downloaded by
  86. a browser, what filename it should have on disk. By default
  87. it is ``{model}-{id}-{field}.{extension}``, the extension is
  88. determined thanks to mimetype.
  89. :param Optional[str] filename_field: like ``filename`` but use
  90. one of the record's char field as filename.
  91. :param Optional[str] mimetype: the data mimetype to use instead
  92. of the stored one (attachment) or the one determined by
  93. magic.
  94. :param str default_mimetype: the mimetype to use when the
  95. mimetype couldn't be determined. By default it is
  96. ``application/octet-stream``.
  97. :rtype: odoo.http.Stream
  98. """
  99. with replace_exceptions(ValueError, by=UserError(f'Expected singleton: {record}')):
  100. record.ensure_one()
  101. try:
  102. field_def = record._fields[field_name]
  103. except KeyError:
  104. raise UserError(f"Record has no field {field_name!r}.")
  105. if field_def.type != 'binary':
  106. raise UserError(
  107. f"Field {field_def!r} is type {field_def.type!r} but "
  108. f"it is only possible to stream Binary or Image fields."
  109. )
  110. stream = self._record_to_stream(record, field_name)
  111. if stream.type in ('data', 'path'):
  112. if mimetype:
  113. stream.mimetype = mimetype
  114. elif not stream.mimetype:
  115. if stream.type == 'data':
  116. head = stream.data[:1024]
  117. else:
  118. with open(stream.path, 'rb') as file:
  119. head = file.read(1024)
  120. stream.mimetype = guess_mimetype(head, default=default_mimetype)
  121. if filename:
  122. stream.download_name = filename
  123. elif filename_field in record:
  124. stream.download_name = record[filename_field]
  125. if not stream.download_name:
  126. stream.download_name = f'{record._table}-{record.id}-{field_name}'
  127. stream.download_name = stream.download_name.replace('\n', '_').replace('\r', '_')
  128. if (not get_extension(stream.download_name)
  129. and stream.mimetype != 'application/octet-stream'):
  130. stream.download_name += guess_extension(stream.mimetype) or ''
  131. return stream
  132. def _get_image_stream_from(
  133. self, record, field_name='raw', filename=None, filename_field='name',
  134. mimetype=None, default_mimetype='image/png', placeholder=None,
  135. width=0, height=0, crop=False, quality=0,
  136. ):
  137. """
  138. Create a :class:odoo.http.Stream: from a record's binary field,
  139. equivalent of :meth:`~get_stream_from` but for images.
  140. In case the record does not exist or is not accessible, the
  141. alternative ``placeholder`` path is used instead. If not set,
  142. a path is determined via
  143. :meth:`~odoo.models.BaseModel._get_placeholder_filename` which
  144. ultimately fallbacks on ``web/static/img/placeholder.png``.
  145. In case the arguments ``width``, ``height``, ``crop`` or
  146. ``quality`` are given, the image will be post-processed and the
  147. ETags (the unique cache http header) will be updated
  148. accordingly. See also :func:`odoo.tools.image.image_process`.
  149. :param record: the record where to load the data from.
  150. :param str field_name: the binary field where to load the data
  151. from.
  152. :param Optional[str] filename: when the stream is downloaded by
  153. a browser, what filename it should have on disk. By default
  154. it is ``{table}-{id}-{field}.{extension}``, the extension is
  155. determined thanks to mimetype.
  156. :param Optional[str] filename_field: like ``filename`` but use
  157. one of the record's char field as filename.
  158. :param Optional[str] mimetype: the data mimetype to use instead
  159. of the stored one (attachment) or the one determined by
  160. magic.
  161. :param str default_mimetype: the mimetype to use when the
  162. mimetype couldn't be determined. By default it is
  163. ``image/png``.
  164. :param Optional[pathlike] placeholder: in case the image is not
  165. found or unaccessible, the path of an image to use instead.
  166. By default the record ``_get_placeholder_filename`` on the
  167. requested field or ``web/static/img/placeholder.png``.
  168. :param int width: if not zero, the width of the resized image.
  169. :param int height: if not zero, the height of the resized image.
  170. :param bool crop: if true, crop the image instead of rezising
  171. it.
  172. :param int quality: if not zero, the quality of the resized
  173. image.
  174. """
  175. stream = None
  176. try:
  177. stream = self._get_stream_from(
  178. record, field_name, filename, filename_field, mimetype,
  179. default_mimetype
  180. )
  181. except UserError:
  182. if request.params.get('download'):
  183. raise
  184. if not stream or stream.size == 0:
  185. if not placeholder:
  186. placeholder = record._get_placeholder_filename(field_name)
  187. stream = self._get_placeholder_stream(placeholder)
  188. if (width, height) == (0, 0):
  189. width, height = image_guess_size_from_field_name(field_name)
  190. if stream.type == 'url':
  191. return stream # Rezising an external URL is not supported
  192. stream.etag += f'-{width}x{height}-crop={crop}-quality={quality}'
  193. if isinstance(stream.last_modified, (int, float)):
  194. stream.last_modified = datetime.utcfromtimestamp(stream.last_modified)
  195. modified = werkzeug.http.is_resource_modified(
  196. request.httprequest.environ,
  197. etag=stream.etag,
  198. last_modified=stream.last_modified
  199. )
  200. if modified and (width or height or crop):
  201. if stream.type == 'path':
  202. with open(stream.path, 'rb') as file:
  203. stream.type = 'data'
  204. stream.path = None
  205. stream.data = file.read()
  206. stream.data = image_process(
  207. stream.data,
  208. size=(width, height),
  209. crop=crop,
  210. quality=quality,
  211. )
  212. stream.size = len(stream.data)
  213. return stream
  214. def _get_placeholder_stream(self, path=None):
  215. if not path:
  216. path = DEFAULT_PLACEHOLDER_PATH
  217. return Stream.from_path(path, filter_ext=('.png', '.jpg'))
  218. def _placeholder(self, path=False):
  219. if not path:
  220. path = DEFAULT_PLACEHOLDER_PATH
  221. with file_open(path, 'rb', filter_ext=('.png', '.jpg')) as file:
  222. return file.read()