diff --git a/.travis.yml b/.travis.yml index 2e883db..e9123b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,8 +18,10 @@ addons: env: matrix: - LINT_CHECK="1" - - TESTS="1" ODOO_REPO="odoo/odoo" - - TESTS="1" ODOO_REPO="OCA/OCB" + - TESTS="1" ODOO_REPO="odoo/odoo" EXCLUDE=test_base_fileurl_field + - TESTS="1" ODOO_REPO="OCA/OCB" EXCLUDE=test_base_fileurl_field + - TESTS="1" ODOO_REPO="odoo/odoo" INCLUDE=test_base_fileurl_field + - TESTS="1" ODOO_REPO="OCA/OCB" INCLUDE=test_base_fileurl_field global: - VERSION="12.0" LINT_CHECK="0" TESTS="0" diff --git a/base_fileurl_field/README.rst b/base_fileurl_field/README.rst new file mode 100644 index 0000000..ae841f5 --- /dev/null +++ b/base_fileurl_field/README.rst @@ -0,0 +1,37 @@ +Base FileURL Field +================== + +This module adds a new field type FileURL to Odoo. +FileURL is an extension of field type Binary, with the aim to store its +value on any kind of external storage. +It's been built with the focus on Amazon S3 but could be used with +other storage solution as long as it extends the functionality of +base_attachment_object_storage. + +Usage +----- + +FileURL fields is intended to store Binary data on an external storage + with the possibility to be accessed outside of Odoo. + +:param storage_location: Required external storage that must be + activated on the system (cf base_attachment_storage) + +:param storage_path: Path to be used as a prefix to the filename in the + storage solution (must be used with filename) + +:param filename: Field on the same model which stores the filename. + Will be used to set fname on ir.attachment and, if storage_path is + defined, will be passed to force the storage key. + +Limitations / Issues +-------------------- + +* Filename must be stored in a separate field on the same model defining a FileURL field. +* While using storage_path and filename attributes, there's a risk existing storage object + are overwritten if files with the same filename are added on different records. + +Example +------- + +cf `test_base_fileurl_field` module in https://github.com/camptocamp/odoo-cloud-platform diff --git a/base_fileurl_field/__init__.py b/base_fileurl_field/__init__.py new file mode 100644 index 0000000..08405c5 --- /dev/null +++ b/base_fileurl_field/__init__.py @@ -0,0 +1,2 @@ +from . import fields + diff --git a/base_fileurl_field/__manifest__.py b/base_fileurl_field/__manifest__.py new file mode 100644 index 0000000..7490bb6 --- /dev/null +++ b/base_fileurl_field/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2012-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +{ + "name": "Base FileURL Field", + "summary": "Implementation of FileURL type fields", + "version": "12.0.1.0.0", + "category": "Technical Settings", + 'author': 'Camptocamp, Odoo Community Association (OCA)', + 'license': 'AGPL-3', + "depends": [ + "base_attachment_object_storage", + ], + "auto_install": False, + "installable": True, +} diff --git a/base_fileurl_field/fields.py b/base_fileurl_field/fields.py new file mode 100644 index 0000000..0d2b4b6 --- /dev/null +++ b/base_fileurl_field/fields.py @@ -0,0 +1,99 @@ +# Copyright 2012-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +import unicodedata + +from odoo import fields + + +fields.Field.__doc__ += """ + + .. _field-fileurl: + + .. rubric:: FileURL fields + + FileURL fields is intended to store Binary data on an external storage + with the possibility to be accessed outside of odoo. + + :param storage_location: Required external storage that must be + activated on the system (cf base_attachment_storage) + + :param storage_path: Path to be used as a prefix to the filename in the + storage solution (must be used with filename) + + :param filename: Field on the same model which stores the filename. + Will be used to set fname on ir.attachment and, if storage_path is + defined, will be passed to force the storage key. +""" + + +class FileURL(fields.Binary): + + _slots = { + 'attachment': True, # Override default with True + 'storage_location': '', # External storage activated on the system (cf base_attachment_storage) # noqa + 'storage_path': '', # Path to be used as storage key (prefix of filename) # noqa + 'filename': '', # Field to use to store the filename on ir.attachment + } + + def create(self, record_values): + assert self.attachment + if not record_values: + return + # create the attachments that store the values + env = record_values[0][0].env + with env.norecompute(): + for record, value in record_values: + if not value: + continue + vals = { + 'name': self.name, + 'res_model': self.model_name, + 'res_field': self.name, + 'res_id': record.id, + 'type': 'binary', + 'datas': value, + } + fname = False + if self.filename: + fname = record[self.filename] + vals['datas_fname'] = fname + if fname and self.storage_path: + storage_key = self._build_storage_key(fname) + if not fname: + storage_key = False + env['ir.attachment'].sudo().with_context( + binary_field_real_user=env.user, + storage_location=self.storage_location, + force_storage_key=storage_key, + ).create(vals) + + def write(self, records, value): + for record in records: + storage_key = False + if self.filename: + fname = record[self.filename] + if fname and self.storage_path: + storage_key = self._build_storage_key(fname) + super().write( + records.with_context( + storage_location=self.storage_location, + force_storage_key=storage_key, + ), + value + ) + return True + + def _setup_regular_base(self, model): + super()._setup_regular_base(model) + if self.storage_path: + assert self.filename is not None, \ + "Field %s defines storage_path without filename" % self + + def _build_storage_key(self, filename): + return '/'.join([ + self.storage_path.rstrip('/'), + unicodedata.normalize('NFKC', filename) + ]) + + +fields.FileURL = FileURL diff --git a/test_base_fileurl_field/README.rst b/test_base_fileurl_field/README.rst new file mode 100644 index 0000000..0061d73 --- /dev/null +++ b/test_base_fileurl_field/README.rst @@ -0,0 +1,4 @@ +Test Base FileURL Field +======================= + +This module serves as implementation example for `base_fileurl_field` and to run its tests. diff --git a/test_base_fileurl_field/__init__.py b/test_base_fileurl_field/__init__.py new file mode 100644 index 0000000..7967689 --- /dev/null +++ b/test_base_fileurl_field/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from . import models diff --git a/test_base_fileurl_field/__manifest__.py b/test_base_fileurl_field/__manifest__.py new file mode 100644 index 0000000..fecd344 --- /dev/null +++ b/test_base_fileurl_field/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +{ + 'name': 'test base fileurl fields', + 'summary': """A module to verify fileurl field.""", + 'version': '12.0.1.0.0', + 'category': 'Tests', + 'author': 'Camptocamp,Odoo Community Association (OCA)', + 'license': 'AGPL-3', + 'depends': [ + 'base_fileurl_field' + ], + 'data': [ + "views/res_partner.xml", + "views/res_users.xml", + ], + 'installable': True, + 'auto_install': False, +} diff --git a/test_base_fileurl_field/data/pattern.png b/test_base_fileurl_field/data/pattern.png new file mode 100644 index 0000000..ce52599 Binary files /dev/null and b/test_base_fileurl_field/data/pattern.png differ diff --git a/test_base_fileurl_field/data/sample.txt b/test_base_fileurl_field/data/sample.txt new file mode 100644 index 0000000..5251e0f --- /dev/null +++ b/test_base_fileurl_field/data/sample.txt @@ -0,0 +1 @@ +This is a simple text file. \ No newline at end of file diff --git a/test_base_fileurl_field/models/__init__.py b/test_base_fileurl_field/models/__init__.py new file mode 100644 index 0000000..906bfc7 --- /dev/null +++ b/test_base_fileurl_field/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_partner +from . import res_users diff --git a/test_base_fileurl_field/models/res_partner.py b/test_base_fileurl_field/models/res_partner.py new file mode 100644 index 0000000..359843c --- /dev/null +++ b/test_base_fileurl_field/models/res_partner.py @@ -0,0 +1,44 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + + +class ResPartner(models.Model): + + _inherit = 'res.partner' + + name = fields.Char() + url_file = fields.FileURL( + storage_location='s3', + filename='url_file_fname', + storage_path='partner' + ) + url_file_fname = fields.Char() + + url_image = fields.FileURL( + storage_location='s3', + filename='url_image_fname', + storage_path='partner_image', + ) + url_image_fname = fields.Char() + + @api.constrains('url_file', 'url_file_fname') + def _check_url_file_fname(self): + rec = self.search([('url_file_fname', '=', self.url_file_fname)]) + if len(rec) > 1: + raise ValidationError(_( + "This file name is already used on an existing record. " + "Please use another file name or delete the url_file on :\n" + "Model: %s Id: %s" % (self._name, rec.id) + )) + + @api.constrains('url_image', 'url_image_fname') + def _check_url_image_fname(self): + rec = self.search([('url_image_fname', '=', self.url_image_fname)]) + if len(rec) > 1: + raise ValidationError(_( + "This file name is already used on an existing record. " + "Please use another file name or delete the url_image on :\n" + "Model: %s Id: %s" % (self._name, rec.id) + )) diff --git a/test_base_fileurl_field/models/res_users.py b/test_base_fileurl_field/models/res_users.py new file mode 100644 index 0000000..da0b80b --- /dev/null +++ b/test_base_fileurl_field/models/res_users.py @@ -0,0 +1,11 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import models, fields + + +class ResUsers(models.Model): + + _inherit = 'res.users' + + partner_url_file = fields.FileURL(related='partner_id.url_file') + partner_url_file_fname = fields.Char(related='partner_id.url_file_fname') diff --git a/test_base_fileurl_field/tests/__init__.py b/test_base_fileurl_field/tests/__init__.py new file mode 100644 index 0000000..56a55f4 --- /dev/null +++ b/test_base_fileurl_field/tests/__init__.py @@ -0,0 +1,2 @@ +from . import ir_attachment +from . import test_fileurl_fields diff --git a/test_base_fileurl_field/tests/ir_attachment.py b/test_base_fileurl_field/tests/ir_attachment.py new file mode 100644 index 0000000..d07017d --- /dev/null +++ b/test_base_fileurl_field/tests/ir_attachment.py @@ -0,0 +1,44 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +import logging + +from odoo import models, api + +_logger = logging.getLogger(__name__) + +FAKE_S3_BUCKET = {} + + +class IrAttachment(models.Model): + _inherit = "ir.attachment" + + def _get_stores(self): + l = ['s3'] + l += super(IrAttachment, self)._get_stores() + return l + + @api.model + def _store_file_read(self, fname, bin_size=False): + if fname.startswith('s3://'): + return FAKE_S3_BUCKET.get(fname) + else: + return super(IrAttachment, self)._store_file_read(fname, bin_size) + + @api.model + def _store_file_write(self, key, bin_data): + location = self.env.context.get('storage_location') or self._storage() + if location == 's3': + FAKE_S3_BUCKET[key] = bin_data + filename = 's3://fake_bucket/%s' % key + else: + _super = super(IrAttachment, self) + filename = _super._store_file_write(key, bin_data) + return filename + + @api.model + def _store_file_delete(self, fname): + if fname.startswith('s3://'): + FAKE_S3_BUCKET.pop(fname) + else: + super(IrAttachment, self)._store_file_delete(fname) diff --git a/test_base_fileurl_field/tests/test_fileurl_fields.py b/test_base_fileurl_field/tests/test_fileurl_fields.py new file mode 100644 index 0000000..c56bbe1 --- /dev/null +++ b/test_base_fileurl_field/tests/test_fileurl_fields.py @@ -0,0 +1,39 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +import base64 + +from odoo.tests import TransactionCase +from odoo.modules.module import get_module_resource +from odoo.exceptions import ValidationError + + +class TestFileUrlFields(TransactionCase): + + def test_fileurl_fields(self): + file_path = get_module_resource('test_base_fileurl_field', 'data', + 'sample.txt') + image_path = get_module_resource('test_base_fileurl_field', 'data', + 'pattern.png') + partner = self.env.ref('base.main_partner') + with open(file_path, 'rb') as f: + with open(image_path, 'rb') as i: + partner.write({ + 'url_file': base64.b64encode(f.read()), + 'url_file_fname': 'sample.txt', + 'url_image': base64.b64encode(i.read()), + 'url_image_fname': 'pattern.png', + }) + + with open(file_path, 'rb') as f: + self.assertEqual(base64.decodebytes(partner.url_file), f.read()) + + with open(image_path, 'rb') as i: + self.assertEqual(base64.decodebytes(partner.url_image), i.read()) + + partner2 = self.env.ref('base.partner_admin') + with open(file_path, 'rb') as f: + with self.assertRaises(ValidationError): + partner2.write({ + 'url_file': base64.b64encode(f.read()), + 'url_file_fname': 'sample.txt', + }) diff --git a/test_base_fileurl_field/views/res_partner.xml b/test_base_fileurl_field/views/res_partner.xml new file mode 100644 index 0000000..08c33e4 --- /dev/null +++ b/test_base_fileurl_field/views/res_partner.xml @@ -0,0 +1,22 @@ + + + + res.partner.form.inherit + res.partner + + + + + + + + + + + + + + + + + diff --git a/test_base_fileurl_field/views/res_users.xml b/test_base_fileurl_field/views/res_users.xml new file mode 100644 index 0000000..ffb5675 --- /dev/null +++ b/test_base_fileurl_field/views/res_users.xml @@ -0,0 +1,18 @@ + + + + res.users.form.inherit + res.users + + + + + + + + + + + + +