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
+
+
+
+
+
+
+
+
+
+
+
+
+