diff --git a/fs_file/README.rst b/fs_file/README.rst new file mode 100644 index 0000000000..7ae00fbcef --- /dev/null +++ b/fs_file/README.rst @@ -0,0 +1,283 @@ +======= +Fs File +======= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1e4d767972e6eb4be41e096d5cdff0f0ba49274b1caa6121c9eaeb7dfc54b091 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github + :target: https://github.com/OCA/storage/tree/18.0/fs_file + :alt: OCA/storage +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-fs_file + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/storage&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon defines a new field type FSFile which is a file field that +stores a file in an external filesystem instead of the odoo's filestore. +This is useful for large files that you don't want to store in the +filestore. Moreover, the field value provides you an interface to access +the file's contents and metadata. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The new field **FSFile** has been developed to allows you to store files +in an external filesystem storage. Its design is based on the following +principles: + +- The content of the file must be read from the filesystem only when + needed. +- It must be possible to manipulate the file content as a stream by + default. +- Unlike Odoo's Binary field, the content is the raw file content by + default (no base64 encoding). +- To allows to exchange the file content with other systems, writing the + content as base64 is possible. The read operation will return a json + structure with the filename, the mimetype, the size and a url to + download the file. + +This design allows to minimize the memory consumption of the server when +manipulating large files and exchanging them with other systems through +the default jsonrpc interface. + +Concretely, this design allows you to write code like this: + +.. code:: python + + from IO import BytesIO + from odoo import models, fields + from odoo.addons.fs_file.fields import FSFile + + class MyModel(models.Model): + _name = 'my.model' + + name = fields.Char() + file = FSFile() + + # Create a new record with a raw content + my_model = MyModel.create({ + 'name': 'My File', + 'file': BytesIO(b"content"), + }) + + assert(my_model.file.read() == b"content") + + # Create a new record with a base64 encoded content + my_model = MyModel.create({ + 'name': 'My File', + 'file': b"content".encode('base64'), + }) + assert(my_model.file.read() == b"content") + + # Create a new record with a file content + my_model = MyModel.create({ + 'name': 'My File', + 'file': open('my_file.txt', 'rb'), + }) + assert(my_model.file.read() == b"content") + assert(my_model.file.name == "my_file.txt") + + # create a record with a file content as base64 encoded and a filename + # This method is useful to create a record from a file uploaded + # through the web interface. + my_model = MyModel.create({ + 'name': 'My File', + 'file': { + 'filename': 'my_file.txt', + 'content': base64.b64encode(b"content"), + }, + }) + assert(my_model.file.read() == b"content") + assert(my_model.file.name == "my_file.txt") + + # write the content of the file as base64 encoded and a filename + # This method is useful to update a record from a file uploaded + # through the web interface. + my_model.write({ + 'file': { + 'name': 'my_file.txt', + 'file': base64.b64encode(b"content"), + }, + }) + + # the call to read() will return a json structure with the filename, + # the mimetype, the size and a url to download the file. + info = my_model.file.read() + assert(info["file"] == { + "filename": "my_file.txt", + "mimetype": "text/plain", + "size": 7, + "url": "/web/content/1234/my_file.txt", + }) + + # use the field as a file stream + # In such a case, the content is read from the filesystem without being + # stored in memory. + with my_model.file.open("rb) as f: + assert(f.read() == b"content") + + # use the field as a file stream to write the content + # In such a case, the content is written to the filesystem without being + # stored in memory. This kind of approach is useful to manipulate large + # files and to avoid to use too much memory. + # Transactional behaviour is ensured by the implementation! + with my_model.file.open("wb") as f: + f.write(b"content") + +Changelog +========= + +16.0.1.0.6 (2024-02-23) +----------------------- + +**Bugfixes** + +- Fixes the creation of empty files. + + Before this change, the creation of empty files resulted in a + constraint violation error. This was due to the fact that even if a + name was given to the file it was not preserved into the FSFileValue + object if no content was given. As result, when the corresponding + ir.attachment was created in the database, the name was not set and + the 'required' constraint was violated. + (`#341 `__) + +16.0.1.0.5 (2023-11-30) +----------------------- + +**Bugfixes** + +- Ensure the cache is properly set when a new value is assigned to a + FSFile field. If the field is stored the value to the cache must be a + FSFileValue object linked to the attachment record used to store the + file. Otherwise the value must be one given since it could be the + result of a compute method. + (`#290 `__) + +16.0.1.0.4 (2023-10-17) +----------------------- + +**Bugfixes** + +- Browse attachment with sudo() to avoid read access errors + + In models that have a multi fs image relation, a new line in form will + trigger onchanges and will call the fs.file model 'convert_to_cache()' + method that will try to browse the attachment with user profile that + could have no read rights on attachment model. + (`#288 `__) + +16.0.1.0.3 (2023-10-05) +----------------------- + +**Bugfixes** + +- Fix the *mimetype* property on *FSFileValue* objects. + + The *mimetype* value is computed as follow: + + - If an attachment is set, the mimetype is taken from the attachment. + - If no attachment is set, the mimetype is guessed from the name of + the file. + - If the mimetype cannot be guessed from the name, the mimetype is + guessed from the content of the file. + (`#284 `__) + +16.0.1.0.1 (2023-09-29) +----------------------- + +**Features** + +- Add a *url_path* property on the *FSFileValue* object. This property + allows you to easily get access to the relative path of the file on + the filesystem. This value is only available if the filesystem storage + is configured with a *Base URL* value. + (`#281 `__) + +**Bugfixes** + +- The *url_path*, *url* and *internal_url* properties on the + *FSFileValue* object return *None* if the information is not available + (instead of *False*). + + The *url* property on the *FSFileValue* object returns the filesystem + url nor the url field of the attachment. + (`#281 `__) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Laurent Mignon +- Marie Lejeune +- Hugues Damry +- Nguyen Minh Chien +- Denis Roussel < + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-lmignon| image:: https://github.com/lmignon.png?size=40px + :target: https://github.com/lmignon + :alt: lmignon + +Current `maintainer `__: + +|maintainer-lmignon| + +This module is part of the `OCA/storage `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fs_file/__init__.py b/fs_file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fs_file/__manifest__.py b/fs_file/__manifest__.py new file mode 100644 index 0000000000..c5c7e7ed93 --- /dev/null +++ b/fs_file/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Fs File", + "summary": """ + Field to store files into filesystem storages""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/storage", + "depends": ["fs_attachment"], + "maintainers": ["lmignon"], + "development_status": "Alpha", + "assets": { + "web.assets_backend": [ + "fs_file/static/src/**/*", + ], + }, +} diff --git a/fs_file/fields.py b/fs_file/fields.py new file mode 100644 index 0000000000..150a9aa6ec --- /dev/null +++ b/fs_file/fields.py @@ -0,0 +1,445 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# pylint: disable=method-required-super +import base64 +import itertools +import mimetypes +import os.path +from io import BytesIO, IOBase + +from odoo import fields +from odoo.tools.mimetypes import guess_mimetype + +from odoo.addons.fs_attachment.models.ir_attachment import IrAttachment + + +class FSFileValue: + def __init__( + self, + attachment: IrAttachment = None, + name: str = None, + value: bytes | IOBase = None, + ) -> None: + """ + This class holds the information related to FSFile field. It can be + used to assign a value to a FSFile field. In such a case, you can pass + the name and the file content as parameters. + + When + + :param attachment: the attachment to use to store the file. + :param name: the name of the file. If not provided, the name will be + taken from the attachment or the io.IOBase. + :param value: the content of the file. It can be bytes or an io.IOBase. + """ + self._is_new: bool = attachment is None + self._buffer: IOBase = None + self._attachment: IrAttachment = attachment + if name and attachment: + raise ValueError("Cannot set name and attachment at the same time") + if value: + if isinstance(value, IOBase): + self._buffer = value + if not hasattr(value, "name"): + if name: + self._buffer.name = name + else: + raise ValueError( + "name must be set when value is an io.IOBase " + "and is not provided by the io.IOBase" + ) + elif isinstance(value, bytes): + self._buffer = BytesIO(value) + if not name: + raise ValueError("name must be set when value is bytes") + self._buffer.name = name + else: + raise ValueError("value must be bytes or io.BytesIO") + elif name: + self._buffer = BytesIO(b"") + self._buffer.name = name + + @property + def write_buffer(self) -> BytesIO: + if self._buffer is None: + name = self._attachment.name if self._attachment else None + self._buffer = BytesIO() + self._buffer.name = name + return self._buffer + + @property + def name(self) -> str | None: + name = ( + self._attachment.name + if self._attachment + else self._buffer.name + if self._buffer + else None + ) + if name: + return os.path.basename(name) + return None + + @name.setter + def name(self, value: str) -> None: + # the name should only be updatable while the file is not yet stored + # TODO, we could also allow to update the name of the file and rename + # the file in the external file system + if self._is_new: + self.write_buffer.name = value + else: + raise ValueError( + "The name of the file can only be updated while the file is not " + "yet stored" + ) + + @property + def is_new(self) -> bool: + return self._is_new + + @property + def mimetype(self) -> str | None: + """Return the mimetype of the file. + + If an attachment is set, the mimetype is taken from the attachment. + If no attachment is set, the mimetype is guessed from the name of the + file. + If no name is set or if the mimetype cannot be guessed from the name, + the mimetype is guessed from the content of the file. + """ + mimetype = None + if self._attachment: + mimetype = self._attachment.mimetype + elif self.name: + mimetype = mimetypes.guess_type(self.name)[0] + # at last, try to guess the mimetype from the content + return mimetype or guess_mimetype(self.getvalue()) + + @property + def size(self) -> int: + if self._attachment: + return self._attachment.file_size + # check if the object supports len + try: + return len(self._buffer) + except TypeError: # pylint: disable=except-pass + # the object does not support len + pass + # if we are on a BytesIO, we can get the size from the buffer + if isinstance(self._buffer, BytesIO): + return self._buffer.getbuffer().nbytes + # we cannot get the size + return 0 + + @property + def url(self) -> str | None: + return self._attachment.fs_url or None if self._attachment else None + + @property + def internal_url(self) -> str | None: + return self._attachment.internal_url or None if self._attachment else None + + @property + def url_path(self) -> str | None: + return self._attachment.fs_url_path or None if self._attachment else None + + @property + def attachment(self) -> IrAttachment | None: + return self._attachment + + @attachment.setter + def attachment(self, value: IrAttachment) -> None: + self._attachment = value + self._buffer = None + + @property + def extension(self) -> str | None: + # get extension from mimetype + ext = os.path.splitext(self.name)[1] + if not ext: + ext = mimetypes.guess_extension(self.mimetype) + ext = ext and ext[1:] + return ext + + @property + def read_buffer(self) -> BytesIO: + if self._buffer is None: + content = b"" + name = None + if self._attachment: + content = self._attachment.raw or b"" + name = self._attachment.name + self._buffer = BytesIO(content) + self._buffer.name = name + return self._buffer + + def getvalue(self) -> bytes: + buffer = self.read_buffer + current_pos = buffer.tell() + buffer.seek(0) + value = buffer.read() + buffer.seek(current_pos) + return value + + def open( + self, + mode="rb", + block_size=None, + cache_options=None, + compression=None, + new_version=True, + **kwargs, + ) -> IOBase: + """ + Return a file-like object that can be used to read and write the file content. + See the documentation of open() into the ir.attachment model from the + fs_attachment module for more information. + """ + if not self._attachment: + raise ValueError("Cannot open a file that is not stored") + return self._attachment.open( + mode=mode, + block_size=block_size, + cache_options=cache_options, + compression=compression, + new_version=new_version, + **kwargs, + ) + + +class FSFile(fields.Binary): + """ + This field is a binary field that stores the file content in an external + filesystem storage referenced by a storage code. + + A major difference with the standard Odoo binary field is that the value + is not encoded in base64 but is a bytes object. + + Moreover, the field is designed to always return an instance of + :class:`FSFileValue` when reading the value. This class is a file-like + object that can be used to read the file content and to get information + about the file (filename, mimetype, url, ...). + + To update the value of the field, the following values are accepted: + + - a bytes object (e.g. ``b"..."``) + - a dict with the following keys: + - ``filename``: the filename of the file + - ``content``: the content of the file encoded in base64 + - a FSFileValue instance + - a file-like object (e.g. an instance of :class:`io.BytesIO`) + + When the value is provided is a bytes object the filename is set to the + name of the field. You can override this behavior by providing specifying + a fs_filename key in the context. For example: + + .. code-block:: python + + record.with_context(fs_filename='my_file.txt').write({ + 'field': b'...', + }) + + The same applies when the value is provided as a file-like object but the + filename is set to the name of the file-like object or not a property of + the file-like object. (e.g. ``io.BytesIO(b'...')``). + + + When the value is converted to the read format, it's always an instance of + dict with the following keys: + + - ``filename``: the filename of the file + - ``mimetype``: the mimetype of the file + - ``size``: the size of the file + - ``url``: the url to access the file + + """ + + type = "fs_file" + + attachment: bool = True + + def __init__(self, *args, **kwargs): + kwargs["attachment"] = True + super().__init__(*args, **kwargs) + + def read(self, records): + domain = [ + ("res_model", "=", records._name), + ("res_field", "=", self.name), + ("res_id", "in", records.ids), + ] + data = { + att.res_id: self._convert_attachment_to_cache(att) + for att in records.env["ir.attachment"].sudo().search(domain) + } + records.env.cache.insert_missing(records, self, map(data.get, records._ids)) + + def create(self, record_values): + if not record_values: + return + for record, value in record_values: + if value: + cache_value = self.convert_to_cache(value, record) + attachment = self._create_attachment(record, cache_value) + cache_value = self._convert_attachment_to_cache(attachment) + record.env.cache.update( + record, + self, + [cache_value], + dirty=False, + ) + + def _create_attachment(self, record, cache_value: FSFileValue): + ir_attachment = ( + record.env["ir.attachment"] + .sudo() + .with_context( + binary_field_real_user=record.env.user, + ) + ) + create_value = self._prepare_attachment_create_values(record, cache_value) + return ir_attachment.create(create_value) + + def _prepare_attachment_create_values(self, record, cache_value: FSFileValue): + return { + "name": cache_value.name, + "raw": cache_value.getvalue(), + "res_model": record._name, + "res_field": self.name, + "res_id": record.id, + "type": "binary", + } + + def write(self, records, value): + # the code is copied from the standard Odoo Binary field + # with the following changes: + # - the value is not encoded in base64 and we therefore write on + # ir.attachment.raw instead of ir.attachment.datas + + # discard recomputation of self on records + records.env.remove_to_compute(self, records) + # update the cache, and discard the records that are not modified + cache = records.env.cache + cache_value = self.convert_to_cache(value, records) + records = cache.get_records_different_from(records, self, cache_value) + if not records: + return records + if self.store: + # determine records that are known to be not null + not_null = cache.get_records_different_from(records, self, None) + + if self.store: + # Be sure to invalidate the cache for the modified records since + # the value of the field has changed and the new value will be linked + # to the attachment record used to store the file in the storage. + cache.remove(records, self) + else: + # if the field is not stored and a value is set, we need to + # set the value in the cache since the value (the case for computed + # fields) + cache.update(records, self, itertools.repeat(cache_value)) + # retrieve the attachments that store the values, and adapt them + if self.store and any(records._ids): + real_records = records.filtered("id") + atts = ( + records.env["ir.attachment"] + .sudo() + .with_context( + binary_field_real_user=records.env.user, + ) + ) + if not_null: + atts = atts.search( + [ + ("res_model", "=", self.model_name), + ("res_field", "=", self.name), + ("res_id", "in", real_records.ids), + ] + ) + if value: + filename = cache_value.name + content = cache_value.getvalue() + # update the existing attachments + atts.write({"raw": content, "name": filename}) + atts_records = records.browse(atts.mapped("res_id")) + # set new value in the cache since we have the reference to the + # attachment record and a new access to the field will nomore + # require to load the attachment record + for record in atts_records: + new_cache_value = self._convert_attachment_to_cache( + atts.filtered(lambda att, rec=record: att.res_id == rec.id) + ) + cache.update(record, self, [new_cache_value], dirty=False) + # create the missing attachments + missing = real_records - atts_records + if missing: + created = atts.browse() + for record in missing: + created |= self._create_attachment(record, cache_value) + for att in created: + record = records.browse(att.res_id) + new_cache_value = self._convert_attachment_to_cache(att) + record.env.cache.update( + record, self, [new_cache_value], dirty=False + ) + else: + atts.unlink() + + return records + + def _convert_attachment_to_cache(self, attachment: IrAttachment) -> FSFileValue: + return FSFileValue(attachment=attachment) + + def _get_filename(self, record): + return record.env.context.get("fs_filename", self.name) + + def convert_to_cache(self, value, record, validate=True): + if value is None or value is False: + return None + if isinstance(value, FSFileValue): + return value + if isinstance(value, dict): + if "content" not in value and value.get("url"): + # we come from an onchange + # The id is the third element of the url + att_id = value["url"].split("/")[3] + attachment = record.env["ir.attachment"].sudo().browse(int(att_id)) + return self._convert_attachment_to_cache(attachment) + return FSFileValue( + name=value["filename"], value=base64.b64decode(value["content"]) + ) + if isinstance(value, IOBase): + name = getattr(value, "name", None) + if name is None: + name = self._get_filename(record) + return FSFileValue(name=name, value=value) + if isinstance(value, bytes): + return FSFileValue( + name=self._get_filename(record), value=base64.b64decode(value) + ) + raise ValueError( + f"Invalid value for {self}: {value}\n" + "Should be base64 encoded bytes or a file-like object" + ) + + def convert_to_write(self, value, record): + return self.convert_to_cache(value, record) + + def convert_to_read(self, value, record, use_name_get=True): + if value is None or value is False: + return None + if isinstance(value, FSFileValue): + res = { + "filename": value.name, + "size": value.size, + "mimetype": value.mimetype, + } + if value.attachment: + res["url"] = value.internal_url + else: + res["content"] = base64.b64encode(value.getvalue()).decode("ascii") + return res + raise ValueError( + f"Invalid value for {self}: {value}\n" + "Should be base64 encoded bytes or a file-like object" + ) diff --git a/fs_file/i18n/es.po b/fs_file/i18n/es.po new file mode 100644 index 0000000000..021230e1ec --- /dev/null +++ b/fs_file/i18n/es.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_file +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-01-27 14:36+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Clear" +msgstr "Limpiar" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Edit" +msgstr "Editar" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Upload your file" +msgstr "" + +#, python-format +#~ msgid "Could not display the selected image" +#~ msgstr "No se ha podido mostrar la imagen seleccionada" diff --git a/fs_file/i18n/fs_file.pot b/fs_file/i18n/fs_file.pot new file mode 100644 index 0000000000..60a3c15b45 --- /dev/null +++ b/fs_file/i18n/fs_file.pot @@ -0,0 +1,37 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_file +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Clear" +msgstr "" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Edit" +msgstr "" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Upload your file" +msgstr "" diff --git a/fs_file/i18n/it.po b/fs_file/i18n/it.po new file mode 100644 index 0000000000..19074bad5d --- /dev/null +++ b/fs_file/i18n/it.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_file +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-04-29 08:35+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Clear" +msgstr "Pulisci" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Edit" +msgstr "Modifica" + +#. module: fs_file +#. odoo-javascript +#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0 +#, python-format +msgid "Upload your file" +msgstr "Carica il tuo file" + +#, python-format +#~ msgid "Could not display the selected image" +#~ msgstr "Impossibile visualizzare l'immagine selezionata" diff --git a/fs_file/pyproject.toml b/fs_file/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/fs_file/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fs_file/readme/CONTRIBUTORS.md b/fs_file/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..57daf5f195 --- /dev/null +++ b/fs_file/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- Laurent Mignon \<\> +- Marie Lejeune \<\> +- Hugues Damry \<\> +- Nguyen Minh Chien \<\> +- Denis Roussel \< diff --git a/fs_file/readme/DESCRIPTION.md b/fs_file/readme/DESCRIPTION.md new file mode 100644 index 0000000000..8815aa0283 --- /dev/null +++ b/fs_file/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This addon defines a new field type FSFile which is a file field that +stores a file in an external filesystem instead of the odoo's filestore. +This is useful for large files that you don't want to store in the +filestore. Moreover, the field value provides you an interface to access +the file's contents and metadata. diff --git a/fs_file/readme/HISTORY.md b/fs_file/readme/HISTORY.md new file mode 100644 index 0000000000..bb1b6aef5c --- /dev/null +++ b/fs_file/readme/HISTORY.md @@ -0,0 +1,71 @@ +## 16.0.1.0.6 (2024-02-23) + +**Bugfixes** + +- Fixes the creation of empty files. + + Before this change, the creation of empty files resulted in a + constraint violation error. This was due to the fact that even if a + name was given to the file it was not preserved into the FSFileValue + object if no content was given. As result, when the corresponding + ir.attachment was created in the database, the name was not set and + the 'required' constraint was violated. + ([\#341](https://github.com/OCA/storage/issues/341)) + +## 16.0.1.0.5 (2023-11-30) + +**Bugfixes** + +- Ensure the cache is properly set when a new value is assigned to a + FSFile field. If the field is stored the value to the cache must be a + FSFileValue object linked to the attachment record used to store the + file. Otherwise the value must be one given since it could be the + result of a compute method. + ([\#290](https://github.com/OCA/storage/issues/290)) + +## 16.0.1.0.4 (2023-10-17) + +**Bugfixes** + +- Browse attachment with sudo() to avoid read access errors + + In models that have a multi fs image relation, a new line in form will + trigger onchanges and will call the fs.file model 'convert_to_cache()' + method that will try to browse the attachment with user profile that + could have no read rights on attachment model. + ([\#288](https://github.com/OCA/storage/issues/288)) + +## 16.0.1.0.3 (2023-10-05) + +**Bugfixes** + +- Fix the *mimetype* property on *FSFileValue* objects. + + The *mimetype* value is computed as follow: + + - If an attachment is set, the mimetype is taken from the attachment. + - If no attachment is set, the mimetype is guessed from the name of + the file. + - If the mimetype cannot be guessed from the name, the mimetype is + guessed from the content of the file. + ([\#284](https://github.com/OCA/storage/issues/284)) + +## 16.0.1.0.1 (2023-09-29) + +**Features** + +- Add a *url_path* property on the *FSFileValue* object. This property + allows you to easily get access to the relative path of the file on + the filesystem. This value is only available if the filesystem storage + is configured with a *Base URL* value. + ([\#281](https://github.com/OCA/storage/issues/281)) + +**Bugfixes** + +- The *url_path*, *url* and *internal_url* properties on the + *FSFileValue* object return *None* if the information is not available + (instead of *False*). + + The *url* property on the *FSFileValue* object returns the filesystem + url nor the url field of the attachment. + ([\#281](https://github.com/OCA/storage/issues/281)) diff --git a/fs_file/readme/USAGE.md b/fs_file/readme/USAGE.md new file mode 100644 index 0000000000..25bbf74d8e --- /dev/null +++ b/fs_file/readme/USAGE.md @@ -0,0 +1,102 @@ +The new field **FSFile** has been developed to allows you to store files +in an external filesystem storage. Its design is based on the following +principles: + +- The content of the file must be read from the filesystem only when + needed. +- It must be possible to manipulate the file content as a stream by + default. +- Unlike Odoo's Binary field, the content is the raw file content by + default (no base64 encoding). +- To allows to exchange the file content with other systems, writing the + content as base64 is possible. The read operation will return a json + structure with the filename, the mimetype, the size and a url to + download the file. + +This design allows to minimize the memory consumption of the server when +manipulating large files and exchanging them with other systems through +the default jsonrpc interface. + +Concretely, this design allows you to write code like this: + +``` python +from IO import BytesIO +from odoo import models, fields +from odoo.addons.fs_file.fields import FSFile + +class MyModel(models.Model): + _name = 'my.model' + + name = fields.Char() + file = FSFile() + +# Create a new record with a raw content +my_model = MyModel.create({ + 'name': 'My File', + 'file': BytesIO(b"content"), +}) + +assert(my_model.file.read() == b"content") + +# Create a new record with a base64 encoded content +my_model = MyModel.create({ + 'name': 'My File', + 'file': b"content".encode('base64'), +}) +assert(my_model.file.read() == b"content") + +# Create a new record with a file content +my_model = MyModel.create({ + 'name': 'My File', + 'file': open('my_file.txt', 'rb'), +}) +assert(my_model.file.read() == b"content") +assert(my_model.file.name == "my_file.txt") + +# create a record with a file content as base64 encoded and a filename +# This method is useful to create a record from a file uploaded +# through the web interface. +my_model = MyModel.create({ + 'name': 'My File', + 'file': { + 'filename': 'my_file.txt', + 'content': base64.b64encode(b"content"), + }, +}) +assert(my_model.file.read() == b"content") +assert(my_model.file.name == "my_file.txt") + +# write the content of the file as base64 encoded and a filename +# This method is useful to update a record from a file uploaded +# through the web interface. +my_model.write({ + 'file': { + 'name': 'my_file.txt', + 'file': base64.b64encode(b"content"), + }, +}) + +# the call to read() will return a json structure with the filename, +# the mimetype, the size and a url to download the file. +info = my_model.file.read() +assert(info["file"] == { + "filename": "my_file.txt", + "mimetype": "text/plain", + "size": 7, + "url": "/web/content/1234/my_file.txt", +}) + +# use the field as a file stream +# In such a case, the content is read from the filesystem without being +# stored in memory. +with my_model.file.open("rb) as f: + assert(f.read() == b"content") + +# use the field as a file stream to write the content +# In such a case, the content is written to the filesystem without being +# stored in memory. This kind of approach is useful to manipulate large +# files and to avoid to use too much memory. +# Transactional behaviour is ensured by the implementation! +with my_model.file.open("wb") as f: + f.write(b"content") +``` diff --git a/fs_file/readme/newsfragments/.gitignore b/fs_file/readme/newsfragments/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fs_file/static/description/icon.png b/fs_file/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/fs_file/static/description/icon.png differ diff --git a/fs_file/static/description/index.html b/fs_file/static/description/index.html new file mode 100644 index 0000000000..bf1a71c479 --- /dev/null +++ b/fs_file/static/description/index.html @@ -0,0 +1,632 @@ + + + + + +Fs File + + + +
+

Fs File

+ + +

Alpha License: AGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

This addon defines a new field type FSFile which is a file field that +stores a file in an external filesystem instead of the odoo’s filestore. +This is useful for large files that you don’t want to store in the +filestore. Moreover, the field value provides you an interface to access +the file’s contents and metadata.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+

The new field FSFile has been developed to allows you to store files +in an external filesystem storage. Its design is based on the following +principles:

+
    +
  • The content of the file must be read from the filesystem only when +needed.
  • +
  • It must be possible to manipulate the file content as a stream by +default.
  • +
  • Unlike Odoo’s Binary field, the content is the raw file content by +default (no base64 encoding).
  • +
  • To allows to exchange the file content with other systems, writing the +content as base64 is possible. The read operation will return a json +structure with the filename, the mimetype, the size and a url to +download the file.
  • +
+

This design allows to minimize the memory consumption of the server when +manipulating large files and exchanging them with other systems through +the default jsonrpc interface.

+

Concretely, this design allows you to write code like this:

+
+from IO import BytesIO
+from odoo import models, fields
+from odoo.addons.fs_file.fields import FSFile
+
+class MyModel(models.Model):
+    _name = 'my.model'
+
+    name = fields.Char()
+    file = FSFile()
+
+# Create a new record with a raw content
+my_model = MyModel.create({
+    'name': 'My File',
+    'file': BytesIO(b"content"),
+})
+
+assert(my_model.file.read() == b"content")
+
+# Create a new record with a base64 encoded content
+my_model = MyModel.create({
+    'name': 'My File',
+    'file': b"content".encode('base64'),
+})
+assert(my_model.file.read() == b"content")
+
+# Create a new record with a file content
+my_model = MyModel.create({
+    'name': 'My File',
+    'file': open('my_file.txt', 'rb'),
+})
+assert(my_model.file.read() == b"content")
+assert(my_model.file.name == "my_file.txt")
+
+# create a record with a file content as base64 encoded and a filename
+# This method is useful to create a record from a file uploaded
+# through the web interface.
+my_model = MyModel.create({
+    'name': 'My File',
+    'file': {
+        'filename': 'my_file.txt',
+        'content': base64.b64encode(b"content"),
+    },
+})
+assert(my_model.file.read() == b"content")
+assert(my_model.file.name == "my_file.txt")
+
+# write the content of the file as base64 encoded and a filename
+# This method is useful to update a record from a file uploaded
+# through the web interface.
+my_model.write({
+    'file': {
+        'name': 'my_file.txt',
+        'file': base64.b64encode(b"content"),
+    },
+})
+
+# the call to read() will return a json structure with the filename,
+# the mimetype, the size and a url to download the file.
+info = my_model.file.read()
+assert(info["file"] == {
+    "filename": "my_file.txt",
+    "mimetype": "text/plain",
+    "size": 7,
+    "url": "/web/content/1234/my_file.txt",
+})
+
+# use the field as a file stream
+# In such a case, the content is read from the filesystem without being
+# stored in memory.
+with my_model.file.open("rb) as f:
+  assert(f.read() == b"content")
+
+# use the field as a file stream to write the content
+# In such a case, the content is written to the filesystem without being
+# stored in memory. This kind of approach is useful to manipulate large
+# files and to avoid to use too much memory.
+# Transactional behaviour is ensured by the implementation!
+with my_model.file.open("wb") as f:
+    f.write(b"content")
+
+
+
+

Changelog

+
+

16.0.1.0.6 (2024-02-23)

+

Bugfixes

+
    +
  • Fixes the creation of empty files.

    +

    Before this change, the creation of empty files resulted in a +constraint violation error. This was due to the fact that even if a +name was given to the file it was not preserved into the FSFileValue +object if no content was given. As result, when the corresponding +ir.attachment was created in the database, the name was not set and +the ‘required’ constraint was violated. +(#341)

    +
  • +
+
+
+

16.0.1.0.5 (2023-11-30)

+

Bugfixes

+
    +
  • Ensure the cache is properly set when a new value is assigned to a +FSFile field. If the field is stored the value to the cache must be a +FSFileValue object linked to the attachment record used to store the +file. Otherwise the value must be one given since it could be the +result of a compute method. +(#290)
  • +
+
+
+

16.0.1.0.4 (2023-10-17)

+

Bugfixes

+
    +
  • Browse attachment with sudo() to avoid read access errors

    +

    In models that have a multi fs image relation, a new line in form will +trigger onchanges and will call the fs.file model ‘convert_to_cache()’ +method that will try to browse the attachment with user profile that +could have no read rights on attachment model. +(#288)

    +
  • +
+
+
+

16.0.1.0.3 (2023-10-05)

+

Bugfixes

+
    +
  • Fix the mimetype property on FSFileValue objects.

    +

    The mimetype value is computed as follow:

    +
      +
    • If an attachment is set, the mimetype is taken from the attachment.
    • +
    • If no attachment is set, the mimetype is guessed from the name of +the file.
    • +
    • If the mimetype cannot be guessed from the name, the mimetype is +guessed from the content of the file. +(#284)
    • +
    +
  • +
+
+
+

16.0.1.0.1 (2023-09-29)

+

Features

+
    +
  • Add a url_path property on the FSFileValue object. This property +allows you to easily get access to the relative path of the file on +the filesystem. This value is only available if the filesystem storage +is configured with a Base URL value. +(#281)
  • +
+

Bugfixes

+
    +
  • The url_path, url and internal_url properties on the +FSFileValue object return None if the information is not available +(instead of False).

    +

    The url property on the FSFileValue object returns the filesystem +url nor the url field of the attachment. +(#281)

    +
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

lmignon

+

This module is part of the OCA/storage project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/fs_file/static/src/scss/fsfile_field.scss b/fs_file/static/src/scss/fsfile_field.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fs_file/static/src/views/fields/fsfile_field.esm.js b/fs_file/static/src/views/fields/fsfile_field.esm.js new file mode 100644 index 0000000000..939219383d --- /dev/null +++ b/fs_file/static/src/views/fields/fsfile_field.esm.js @@ -0,0 +1,57 @@ +/** @odoo-module */ + +/** + * Copyright 2023 ACSONE SA/NV + */ +import {Component} from "@odoo/owl"; +import {FileUploader} from "@web/views/fields/file_handler"; +import {MAX_FILENAME_SIZE_BYTES} from "@web/views/fields/binary/binary_field"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; +import {toBase64Length} from "@web/core/utils/binary"; +import {useService} from "@web/core/utils/hooks"; + +export class FSFileField extends Component { + setup() { + this.notification = useService("notification"); + } + get filename() { + return (this.props.record.data[this.props.name].filename || "").slice( + 0, + toBase64Length(MAX_FILENAME_SIZE_BYTES) + ); + } + get url() { + return this.props.record.data[this.props.name].url || ""; + } + + onFileRemove() { + this.props.record.update({[this.props.name]: false}); + } + onFileUploaded(info) { + this.props.record.update({ + [this.props.name]: { + filename: info.name, + content: info.data, + }, + }); + } +} + +FSFileField.template = "fs_file.FSFileField"; +FSFileField.components = { + FileUploader, +}; +FSFileField.props = { + ...standardFieldProps, + acceptedFileExtensions: {type: String, optional: true}, +}; +FSFileField.defaultProps = { + acceptedFileExtensions: "*", +}; + +export const fSFileField = { + component: FSFileField, +}; + +registry.category("fields").add("fs_file", fSFileField); diff --git a/fs_file/static/src/views/fields/fsfile_field.xml b/fs_file/static/src/views/fields/fsfile_field.xml new file mode 100644 index 0000000000..6753c4528f --- /dev/null +++ b/fs_file/static/src/views/fields/fsfile_field.xml @@ -0,0 +1,64 @@ + + + + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + +
+ +
diff --git a/fs_file/tests/__init__.py b/fs_file/tests/__init__.py new file mode 100644 index 0000000000..9c12d36073 --- /dev/null +++ b/fs_file/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fs_file diff --git a/fs_file/tests/models.py b/fs_file/tests/models.py new file mode 100644 index 0000000000..c442507c76 --- /dev/null +++ b/fs_file/tests/models.py @@ -0,0 +1,14 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + +from ..fields import FSFile + + +class TestModel(models.Model): + _name = "test.model" + _description = "Test Model" + _log_access = False + + fs_file = FSFile() diff --git a/fs_file/tests/test_fs_file.py b/fs_file/tests/test_fs_file.py new file mode 100644 index 0000000000..7edb143efd --- /dev/null +++ b/fs_file/tests/test_fs_file.py @@ -0,0 +1,237 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 +import io +import os +import tempfile +from io import BytesIO + +from odoo_test_helper import FakeModelLoader +from PIL import Image + +from odoo.tests.common import TransactionCase + +from odoo.addons.fs_storage.models.fs_storage import FSStorage + +from ..fields import FSFileValue + + +class TestFsFile(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import TestModel + + cls.loader.update_registry((TestModel,)) + + cls.create_content = b"content" + cls.write_content = b"new content" + cls.tmpfile_path = tempfile.mkstemp(suffix=".txt")[1] + with open(cls.tmpfile_path, "wb") as f: + f.write(cls.create_content) + cls.filename = os.path.basename(cls.tmpfile_path) + f = BytesIO() + Image.new("RGB", (1, 1), color="red").save(f, "PNG") + f.seek(0) + cls.png_content = f + + def setUp(self): + super().setUp() + self.temp_dir: FSStorage = self.env["fs.storage"].create( + { + "name": "Temp FS Storage", + "protocol": "memory", + "code": "mem_dir", + "directory_path": "/tmp/", + "model_xmlids": "fs_file.model_test_model", + } + ) + + @classmethod + def tearDownClass(cls): + if os.path.exists(cls.tmpfile_path): + os.remove(cls.tmpfile_path) + cls.loader.restore_registry() + return super().tearDownClass() + + def _test_create(self, fs_file_value): + model = self.env["test.model"] + instance = model.create({"fs_file": fs_file_value}) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) + self.assertEqual(instance.fs_file.getvalue(), self.create_content) + self.assertEqual(instance.fs_file.name, self.filename) + self.assertEqual(instance.fs_file.url_path, None) + self.assertEqual(instance.fs_file.url, None) + + def _test_write(self, fs_file_value, **ctx): + instance = self.env["test.model"].create({}) + if ctx: + instance = instance.with_context(**ctx) + instance.fs_file = fs_file_value + self.assertEqual(instance.fs_file.getvalue(), self.write_content) + self.assertEqual(instance.fs_file.name, self.filename) + + def test_read(self): + instance = self.env["test.model"].create( + {"fs_file": FSFileValue(name=self.filename, value=self.create_content)} + ) + info = instance.read(["fs_file"])[0] + self.assertDictEqual( + info["fs_file"], + { + "filename": self.filename, + "mimetype": "text/plain", + "size": 7, + "url": instance.fs_file.internal_url, + }, + ) + + def test_create_with_fsfilebytesio(self): + self._test_create(FSFileValue(name=self.filename, value=self.create_content)) + + def test_create_with_dict(self): + self._test_create( + { + "filename": self.filename, + "content": base64.b64encode(self.create_content), + } + ) + + def test_write_with_dict(self): + self._test_write( + { + "filename": self.filename, + "content": base64.b64encode(self.write_content), + } + ) + + def test_create_with_file_like(self): + with open(self.tmpfile_path, "rb") as f: + self._test_create(f) + + def test_create_in_b64(self): + instance = self.env["test.model"].create( + {"fs_file": base64.b64encode(self.create_content)} + ) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) + self.assertEqual(instance.fs_file.getvalue(), self.create_content) + + def test_create_in_b64_check_size(self): + instance = self.env["test.model"].create( + {"fs_file": base64.b64encode(self.create_content)} + ) + self.assertEqual(7, instance.fs_file.size) + + def test_create_in_b64_check_extension(self): + instance = self.env["test.model"].create( + {"fs_file": base64.b64encode(self.create_content)} + ) + self.assertEqual("txt", instance.fs_file.extension) + + def test_create_in_b64_name_set(self): + instance = self.env["test.model"].create( + {"fs_file": base64.b64encode(self.create_content)} + ) + with self.assertRaises(ValueError) as raise_exception: + instance.fs_file.name = "fs_file_test" + self.assertEqual( + "The name of the file can only be updated while the file is not yet stored", + raise_exception.exception.args[0], + ) + + def test_write_in_b64(self): + instance = self.env["test.model"].create({"fs_file": b"test"}) + self.assertEqual("fs_file", instance.fs_file.write_buffer.name) + instance.write({"fs_file": base64.b64encode(self.create_content)}) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) + self.assertEqual(instance.fs_file.getvalue(), self.create_content) + + def test_write_in_b64_with_specified_filename(self): + self._test_write( + base64.b64encode(self.write_content), fs_filename=self.filename + ) + + def test_create_with_io(self): + instance = self.env["test.model"].create( + {"fs_file": io.BytesIO(self.create_content)} + ) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) + self.assertEqual(instance.fs_file.getvalue(), self.create_content) + + def test_write_with_io(self): + instance = self.env["test.model"].create( + {"fs_file": io.BytesIO(self.create_content)} + ) + instance.write({"fs_file": io.BytesIO(b"test3")}) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) + self.assertEqual(instance.fs_file.getvalue(), b"test3") + + def test_create_with_empty_value(self): + instance = self.env["test.model"].create( + {"fs_file": FSFileValue(name=self.filename, value=b"")} + ) + self.assertEqual(instance.fs_file.getvalue(), b"") + self.assertEqual(instance.fs_file.name, self.filename) + + def test_write_with_empty_value(self): + instance = self.env["test.model"].create( + {"fs_file": FSFileValue(name=self.filename, value=self.create_content)} + ) + instance.write({"fs_file": FSFileValue(name=self.filename, value=b"")}) + self.assertEqual(instance.fs_file.getvalue(), b"") + self.assertEqual(instance.fs_file.name, self.filename) + + def test_modify_fsfilebytesio(self): + """If you modify the content of the FSFileValue, + the changes will be directly applied + and a new file in the storage must be created for the new content. + """ + instance = self.env["test.model"].create( + {"fs_file": FSFileValue(name=self.filename, value=self.create_content)} + ) + initial_store_fname = instance.fs_file.attachment.store_fname + with instance.fs_file.open(mode="wb") as f: + f.write(b"new_content") + self.assertNotEqual( + instance.fs_file.attachment.store_fname, initial_store_fname + ) + self.assertEqual(instance.fs_file.getvalue(), b"new_content") + + def test_fs_value_mimetype(self): + """Test that the mimetype is correctly computed on a FSFileValue""" + value = FSFileValue(name="test.png", value=self.create_content) + # in this case, the mimetype is not computed from the filename + self.assertEqual(value.mimetype, "image/png") + + value = FSFileValue(value=open(self.tmpfile_path, "rb")) + # in this case, the mimetype is not computed from the content + self.assertEqual(value.mimetype, "text/plain") + + # if the mimetype is not found into the name, it should be computed + # from the content + value = FSFileValue(name="test", value=self.png_content) + self.assertEqual(value.mimetype, "image/png") + + def test_fs_value_no_name(self): + with self.assertRaises(ValueError) as raise_exception: + FSFileValue(value=self.create_content) + self.assertEqual( + "name must be set when value is bytes", raise_exception.exception.args[0] + ) + + def test_cache_invalidation(self): + """Test that the cache is invalidated when the FSFileValue is modified + When we assign a FSFileValue to a field, the value in the cache must + be invalidated and the new value must be computed. This is required + because the FSFileValue from the cache should always be linked to the + attachment record used to store the file in the storage. + """ + value = FSFileValue(name="test.png", value=self.create_content) + instance = self.env["test.model"].create({"fs_file": value}) + self.assertNotEqual(instance.fs_file, value) + value = FSFileValue(name="test.png", value=self.write_content) + instance.write({"fs_file": value}) + self.assertNotEqual(instance.fs_file, value) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..66bc2cbae3 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo_test_helper