/odoo/addons/base/ir/ir_attachment.py

https://bitbucket.org/lrm-lujosramirez/odoo10-lrm · Python · 463 lines · 377 code · 33 blank · 53 comment · 56 complexity · 827593b072c1f7de8970cafa7400826e MD5 · raw file

  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import hashlib
  4. import itertools
  5. import logging
  6. import mimetypes
  7. import os
  8. import re
  9. from collections import defaultdict
  10. from odoo import api, fields, models, tools, SUPERUSER_ID, _
  11. from odoo.exceptions import AccessError, ValidationError
  12. from odoo.tools import config, human_size, ustr, html_escape
  13. from odoo.tools.mimetypes import guess_mimetype
  14. _logger = logging.getLogger(__name__)
  15. class IrAttachment(models.Model):
  16. """Attachments are used to link binary files or url to any openerp document.
  17. External attachment storage
  18. ---------------------------
  19. The computed field ``datas`` is implemented using ``_file_read``,
  20. ``_file_write`` and ``_file_delete``, which can be overridden to implement
  21. other storage engines. Such methods should check for other location pseudo
  22. uri (example: hdfs://hadoopserver).
  23. The default implementation is the file:dirname location that stores files
  24. on the local filesystem using name based on their sha1 hash
  25. """
  26. _name = 'ir.attachment'
  27. _order = 'id desc'
  28. @api.depends('res_model', 'res_id')
  29. def _compute_res_name(self):
  30. for attachment in self:
  31. if attachment.res_model and attachment.res_id:
  32. record = self.env[attachment.res_model].browse(attachment.res_id)
  33. attachment.res_name = record.display_name
  34. @api.model
  35. def _storage(self):
  36. return self.env['ir.config_parameter'].sudo().get_param('ir_attachment.location', 'file')
  37. @api.model
  38. def _filestore(self):
  39. return config.filestore(self._cr.dbname)
  40. @api.model
  41. def force_storage(self):
  42. """Force all attachments to be stored in the currently configured storage"""
  43. if not self.env.user._is_admin():
  44. raise AccessError(_('Only administrators can execute this action.'))
  45. # domain to retrieve the attachments to migrate
  46. domain = {
  47. 'db': [('store_fname', '!=', False)],
  48. 'file': [('db_datas', '!=', False)],
  49. }[self._storage()]
  50. for attach in self.search(domain):
  51. attach.write({'datas': attach.datas})
  52. return True
  53. @api.model
  54. def _full_path(self, path):
  55. # sanitize path
  56. path = re.sub('[.]', '', path)
  57. path = path.strip('/\\')
  58. return os.path.join(self._filestore(), path)
  59. @api.model
  60. def _get_path(self, bin_data, sha):
  61. # retro compatibility
  62. fname = sha[:3] + '/' + sha
  63. full_path = self._full_path(fname)
  64. if os.path.isfile(full_path):
  65. return fname, full_path # keep existing path
  66. # scatter files across 256 dirs
  67. # we use '/' in the db (even on windows)
  68. fname = sha[:2] + '/' + sha
  69. full_path = self._full_path(fname)
  70. dirname = os.path.dirname(full_path)
  71. if not os.path.isdir(dirname):
  72. os.makedirs(dirname)
  73. return fname, full_path
  74. @api.model
  75. def _file_read(self, fname, bin_size=False):
  76. full_path = self._full_path(fname)
  77. r = ''
  78. try:
  79. if bin_size:
  80. r = human_size(os.path.getsize(full_path))
  81. else:
  82. r = open(full_path,'rb').read().encode('base64')
  83. except (IOError, OSError):
  84. _logger.info("_read_file reading %s", full_path, exc_info=True)
  85. return r
  86. @api.model
  87. def _file_write(self, value, checksum):
  88. bin_value = value.decode('base64')
  89. fname, full_path = self._get_path(bin_value, checksum)
  90. if not os.path.exists(full_path):
  91. try:
  92. with open(full_path, 'wb') as fp:
  93. fp.write(bin_value)
  94. # add fname to checklist, in case the transaction aborts
  95. self._mark_for_gc(fname)
  96. except IOError:
  97. _logger.info("_file_write writing %s", full_path, exc_info=True)
  98. return fname
  99. @api.model
  100. def _file_delete(self, fname):
  101. # simply add fname to checklist, it will be garbage-collected later
  102. self._mark_for_gc(fname)
  103. def _mark_for_gc(self, fname):
  104. """ Add ``fname`` in a checklist for the filestore garbage collection. """
  105. # we use a spooldir: add an empty file in the subdirectory 'checklist'
  106. full_path = os.path.join(self._full_path('checklist'), fname)
  107. if not os.path.exists(full_path):
  108. dirname = os.path.dirname(full_path)
  109. if not os.path.isdir(dirname):
  110. with tools.ignore(OSError):
  111. os.makedirs(dirname)
  112. open(full_path, 'ab').close()
  113. @api.model
  114. def _file_gc(self):
  115. """ Perform the garbage collection of the filestore. """
  116. if self._storage() != 'file':
  117. return
  118. # Continue in a new transaction. The LOCK statement below must be the
  119. # first one in the current transaction, otherwise the database snapshot
  120. # used by it may not contain the most recent changes made to the table
  121. # ir_attachment! Indeed, if concurrent transactions create attachments,
  122. # the LOCK statement will wait until those concurrent transactions end.
  123. # But this transaction will not see the new attachements if it has done
  124. # other requests before the LOCK (like the method _storage() above).
  125. cr = self._cr
  126. cr.commit()
  127. # prevent all concurrent updates on ir_attachment while collecting!
  128. cr.execute("LOCK ir_attachment IN SHARE MODE")
  129. # retrieve the file names from the checklist
  130. checklist = {}
  131. for dirpath, _, filenames in os.walk(self._full_path('checklist')):
  132. dirname = os.path.basename(dirpath)
  133. for filename in filenames:
  134. fname = "%s/%s" % (dirname, filename)
  135. checklist[fname] = os.path.join(dirpath, filename)
  136. # determine which files to keep among the checklist
  137. whitelist = set()
  138. for names in cr.split_for_in_conditions(checklist):
  139. cr.execute("SELECT store_fname FROM ir_attachment WHERE store_fname IN %s", [names])
  140. whitelist.update(row[0] for row in cr.fetchall())
  141. # remove garbage files, and clean up checklist
  142. removed = 0
  143. for fname, filepath in checklist.iteritems():
  144. if fname not in whitelist:
  145. try:
  146. os.unlink(self._full_path(fname))
  147. removed += 1
  148. except (OSError, IOError):
  149. _logger.info("_file_gc could not unlink %s", self._full_path(fname), exc_info=True)
  150. with tools.ignore(OSError):
  151. os.unlink(filepath)
  152. # commit to release the lock
  153. cr.commit()
  154. _logger.info("filestore gc %d checked, %d removed", len(checklist), removed)
  155. @api.depends('store_fname', 'db_datas')
  156. def _compute_datas(self):
  157. bin_size = self._context.get('bin_size')
  158. for attach in self:
  159. if attach.store_fname:
  160. attach.datas = self._file_read(attach.store_fname, bin_size)
  161. else:
  162. attach.datas = attach.db_datas
  163. def _inverse_datas(self):
  164. location = self._storage()
  165. for attach in self:
  166. # compute the fields that depend on datas
  167. value = attach.datas
  168. bin_data = value and value.decode('base64') or ''
  169. vals = {
  170. 'file_size': len(bin_data),
  171. 'checksum': self._compute_checksum(bin_data),
  172. 'index_content': self._index(bin_data, attach.datas_fname, attach.mimetype),
  173. 'store_fname': False,
  174. 'db_datas': value,
  175. }
  176. if value and location != 'db':
  177. # save it to the filestore
  178. vals['store_fname'] = self._file_write(value, vals['checksum'])
  179. vals['db_datas'] = False
  180. # take current location in filestore to possibly garbage-collect it
  181. fname = attach.store_fname
  182. # write as superuser, as user probably does not have write access
  183. super(IrAttachment, attach.sudo()).write(vals)
  184. if fname:
  185. self._file_delete(fname)
  186. def _compute_checksum(self, bin_data):
  187. """ compute the checksum for the given datas
  188. :param bin_data : datas in its binary form
  189. """
  190. # an empty file has a checksum too (for caching)
  191. return hashlib.sha1(bin_data or '').hexdigest()
  192. def _compute_mimetype(self, values):
  193. """ compute the mimetype of the given values
  194. :param values : dict of values to create or write an ir_attachment
  195. :return mime : string indicating the mimetype, or application/octet-stream by default
  196. """
  197. mimetype = None
  198. if values.get('mimetype'):
  199. mimetype = values['mimetype']
  200. if not mimetype and values.get('datas_fname'):
  201. mimetype = mimetypes.guess_type(values['datas_fname'])[0]
  202. if not mimetype and values.get('url'):
  203. mimetype = mimetypes.guess_type(values['url'])[0]
  204. if values.get('datas') and (not mimetype or mimetype == 'application/octet-stream'):
  205. mimetype = guess_mimetype(values['datas'].decode('base64'))
  206. return mimetype or 'application/octet-stream'
  207. def _check_contents(self, values):
  208. mimetype = values['mimetype'] = self._compute_mimetype(values)
  209. xml_like = 'ht' in mimetype or 'xml' in mimetype # hta, html, xhtml, etc.
  210. force_text = (xml_like and (not self.env.user._is_admin() or
  211. self.env.context.get('attachments_mime_plainxml')))
  212. if force_text:
  213. values['mimetype'] = 'text/plain'
  214. return values
  215. @api.model
  216. def _index(self, bin_data, datas_fname, file_type):
  217. """ compute the index content of the given filename, or binary data.
  218. This is a python implementation of the unix command 'strings'.
  219. :param bin_data : datas in binary form
  220. :return index_content : string containing all the printable character of the binary data
  221. """
  222. index_content = False
  223. if file_type:
  224. index_content = file_type.split('/')[0]
  225. if index_content == 'text': # compute index_content only for text type
  226. words = re.findall("[^\x00-\x1F\x7F-\xFF]{4,}", bin_data)
  227. index_content = ustr("\n".join(words))
  228. return index_content
  229. @api.model
  230. def get_serving_groups(self):
  231. """ An ir.attachment record may be used as a fallback in the
  232. http dispatch if its type field is set to "binary" and its url
  233. field is set as the request's url. Only the groups returned by
  234. this method are allowed to create and write on such records.
  235. """
  236. return ['base.group_system']
  237. name = fields.Char('Attachment Name', required=True)
  238. datas_fname = fields.Char('File Name')
  239. description = fields.Text('Description')
  240. res_name = fields.Char('Resource Name', compute='_compute_res_name', store=True)
  241. res_model = fields.Char('Resource Model', readonly=True, help="The database object this attachment will be attached to.")
  242. res_field = fields.Char('Resource Field', readonly=True)
  243. res_id = fields.Integer('Resource ID', readonly=True, help="The record id this is attached to.")
  244. create_date = fields.Datetime('Date Created', readonly=True)
  245. create_uid = fields.Many2one('res.users', string='Owner', readonly=True)
  246. company_id = fields.Many2one('res.company', string='Company', change_default=True,
  247. default=lambda self: self.env['res.company']._company_default_get('ir.attachment'))
  248. type = fields.Selection([('url', 'URL'), ('binary', 'File')],
  249. string='Type', required=True, default='binary', change_default=True,
  250. help="You can either upload a file from your computer or copy/paste an internet link to your file.")
  251. url = fields.Char('Url', index=True, size=1024)
  252. public = fields.Boolean('Is public document')
  253. # the field 'datas' is computed and may use the other fields below
  254. datas = fields.Binary(string='File Content', compute='_compute_datas', inverse='_inverse_datas')
  255. db_datas = fields.Binary('Database Data')
  256. store_fname = fields.Char('Stored Filename')
  257. file_size = fields.Integer('File Size', readonly=True)
  258. checksum = fields.Char("Checksum/SHA1", size=40, index=True, readonly=True)
  259. mimetype = fields.Char('Mime Type', readonly=True)
  260. index_content = fields.Text('Indexed Content', readonly=True, prefetch=False)
  261. @api.model_cr_context
  262. def _auto_init(self):
  263. res = super(IrAttachment, self)._auto_init()
  264. self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('ir_attachment_res_idx',))
  265. if not self._cr.fetchone():
  266. self._cr.execute('CREATE INDEX ir_attachment_res_idx ON ir_attachment (res_model, res_id)')
  267. self._cr.commit()
  268. return res
  269. @api.one
  270. @api.constrains('type', 'url')
  271. def _check_serving_attachments(self):
  272. # restrict writing on attachments that could be served by the
  273. # ir.http's dispatch exception handling
  274. if self.env.user._is_superuser():
  275. return
  276. if self.type == 'binary' and self.url:
  277. has_group = self.env.user.has_group
  278. if not any([has_group(g) for g in self.get_serving_groups()]):
  279. raise ValidationError("Sorry, you are not allowed to write on this document")
  280. @api.model
  281. def check(self, mode, values=None):
  282. """Restricts the access to an ir.attachment, according to referred model
  283. In the 'document' module, it is overriden to relax this hard rule, since
  284. more complex ones apply there.
  285. """
  286. # collect the records to check (by model)
  287. model_ids = defaultdict(set) # {model_name: set(ids)}
  288. require_employee = False
  289. if self:
  290. self._cr.execute('SELECT res_model, res_id, create_uid, public FROM ir_attachment WHERE id IN %s', [tuple(self.ids)])
  291. for res_model, res_id, create_uid, public in self._cr.fetchall():
  292. if public and mode == 'read':
  293. continue
  294. if not (res_model and res_id):
  295. if create_uid != self._uid:
  296. require_employee = True
  297. continue
  298. model_ids[res_model].add(res_id)
  299. if values and values.get('res_model') and values.get('res_id'):
  300. model_ids[values['res_model']].add(values['res_id'])
  301. # check access rights on the records
  302. for res_model, res_ids in model_ids.iteritems():
  303. # ignore attachments that are not attached to a resource anymore
  304. # when checking access rights (resource was deleted but attachment
  305. # was not)
  306. if res_model not in self.env:
  307. require_employee = True
  308. continue
  309. records = self.env[res_model].browse(res_ids).exists()
  310. if len(records) < len(res_ids):
  311. require_employee = True
  312. # For related models, check if we can write to the model, as unlinking
  313. # and creating attachments can be seen as an update to the model
  314. records.check_access_rights('write' if mode in ('create', 'unlink') else mode)
  315. records.check_access_rule(mode)
  316. if require_employee:
  317. if not (self.env.user._is_admin() or self.env.user.has_group('base.group_user')):
  318. raise AccessError(_("Sorry, you are not allowed to access this document."))
  319. @api.model
  320. def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
  321. # add res_field=False in domain if not present; the arg[0] trick below
  322. # works for domain items and '&'/'|'/'!' operators too
  323. if not any(arg[0] in ('id', 'res_field') for arg in args):
  324. args.insert(0, ('res_field', '=', False))
  325. ids = super(IrAttachment, self)._search(args, offset=offset, limit=limit, order=order,
  326. count=False, access_rights_uid=access_rights_uid)
  327. if self._uid == SUPERUSER_ID:
  328. # rules do not apply for the superuser
  329. return len(ids) if count else ids
  330. if not ids:
  331. return 0 if count else []
  332. # Work with a set, as list.remove() is prohibitive for large lists of documents
  333. # (takes 20+ seconds on a db with 100k docs during search_count()!)
  334. orig_ids = ids
  335. ids = set(ids)
  336. # For attachments, the permissions of the document they are attached to
  337. # apply, so we must remove attachments for which the user cannot access
  338. # the linked document.
  339. # Use pure SQL rather than read() as it is about 50% faster for large dbs (100k+ docs),
  340. # and the permissions are checked in super() and below anyway.
  341. model_attachments = defaultdict(lambda: defaultdict(set)) # {res_model: {res_id: set(ids)}}
  342. self._cr.execute("""SELECT id, res_model, res_id, public FROM ir_attachment WHERE id IN %s""", [tuple(ids)])
  343. for row in self._cr.dictfetchall():
  344. if not row['res_model'] or row['public']:
  345. continue
  346. # model_attachments = {res_model: {res_id: set(ids)}}
  347. model_attachments[row['res_model']][row['res_id']].add(row['id'])
  348. # To avoid multiple queries for each attachment found, checks are
  349. # performed in batch as much as possible.
  350. for res_model, targets in model_attachments.iteritems():
  351. if res_model not in self.env:
  352. continue
  353. if not self.env[res_model].check_access_rights('read', False):
  354. # remove all corresponding attachment ids
  355. ids.difference_update(itertools.chain(*targets.itervalues()))
  356. continue
  357. # filter ids according to what access rules permit
  358. target_ids = list(targets)
  359. allowed = self.env[res_model].with_context(active_test=False).search([('id', 'in', target_ids)])
  360. for res_id in set(target_ids).difference(allowed.ids):
  361. ids.difference_update(targets[res_id])
  362. # sort result according to the original sort ordering
  363. result = [id for id in orig_ids if id in ids]
  364. return len(result) if count else list(result)
  365. @api.multi
  366. def read(self, fields=None, load='_classic_read'):
  367. self.check('read')
  368. return super(IrAttachment, self).read(fields, load=load)
  369. @api.multi
  370. def write(self, vals):
  371. self.check('write', values=vals)
  372. # remove computed field depending of datas
  373. for field in ('file_size', 'checksum'):
  374. vals.pop(field, False)
  375. if 'mimetype' in vals or 'datas' in vals:
  376. vals = self._check_contents(vals)
  377. return super(IrAttachment, self).write(vals)
  378. @api.multi
  379. def copy(self, default=None):
  380. self.check('write')
  381. return super(IrAttachment, self).copy(default)
  382. @api.multi
  383. def unlink(self):
  384. self.check('unlink')
  385. # First delete in the database, *then* in the filesystem if the
  386. # database allowed it. Helps avoid errors when concurrent transactions
  387. # are deleting the same file, and some of the transactions are
  388. # rolled back by PostgreSQL (due to concurrent updates detection).
  389. to_delete = set(attach.store_fname for attach in self if attach.store_fname)
  390. res = super(IrAttachment, self).unlink()
  391. for file_path in to_delete:
  392. self._file_delete(file_path)
  393. return res
  394. @api.model
  395. def create(self, values):
  396. # remove computed field depending of datas
  397. for field in ('file_size', 'checksum'):
  398. values.pop(field, False)
  399. values = self._check_contents(values)
  400. self.browse().check('write', values=values)
  401. return super(IrAttachment, self).create(values)
  402. @api.model
  403. def action_get(self):
  404. return self.env['ir.actions.act_window'].for_xml_id('base', 'action_attachment')