diff --git a/fs_file/fields.py b/fs_file/fields.py index 29a92f12f3..7ec3cff2fa 100644 --- a/fs_file/fields.py +++ b/fs_file/fields.py @@ -3,11 +3,159 @@ # pylint: disable=method-required-super import base64 import itertools -from io import IOBase +import os.path +from io import BytesIO, IOBase from odoo import fields -from .io import FSFileBytesIO +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") and name: + self._buffer.name = name + elif not name: + 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") + + @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 mimetype(self) -> str | None: + return self._attachment.mimetype if self._attachment else None + + @property + def size(self) -> int: + return self._attachment.file_size if self._attachment else len(self._buffer) + + @property + def url(self) -> str | None: + return self._attachment.url if self._attachment else None + + @property + def internal_url(self) -> str | None: + return self._attachment.internal_url 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 read_buffer(self) -> BytesIO: + if self._buffer is None: + content = b"" + name = None + if self._attachment: + content = self._attachment.raw + 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): @@ -19,7 +167,7 @@ class FSFile(fields.Binary): is not encoded in base64 but is a bytes object. Moreover, the field is designed to always return an instance of - :class:`FSFileBytesIO` when reading the value. This class is a file-like + :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, ...). @@ -29,7 +177,7 @@ class FSFile(fields.Binary): - a dict with the following keys: - ``filename``: the filename of the file - ``content``: the content of the file encoded in base64 - - a FSFileBytesIO instance + - 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 @@ -60,10 +208,8 @@ class FSFile(fields.Binary): type = "fs_file" attachment: bool = True - storage_code: str = None - def __init__(self, storage_code: str = None, **kwargs): - self.storage_code = storage_code + def __init__(self, **kwargs): kwargs["attachment"] = True super().__init__(**kwargs) @@ -74,7 +220,7 @@ def read(self, records): ("res_id", "in", records.ids), ] data = { - att.res_id: FSFileBytesIO(attachment=att) + att.res_id: FSFileValue(attachment=att) for att in records.env["ir.attachment"].sudo().search(domain) } records.env.cache.insert_missing(records, self, map(data.get, records._ids)) @@ -88,7 +234,6 @@ def create(self, record_values): env["ir.attachment"] .sudo() .with_context( - storage_code=self.storage_code, binary_field_real_user=env.user, ) ) @@ -108,7 +253,7 @@ def create(self, record_values): record.env.cache.update( record, self, - [FSFileBytesIO(attachment=attachment)], + [FSFileValue(attachment=attachment)], dirty=False, ) @@ -117,8 +262,6 @@ def write(self, records, value): # with the following changes: # - the value is not encoded in base64 and we therefore write on # ir.attachment.raw instead of ir.attachment.datas - # - we use the storage_code to store the attachment in the right - # filesystem storage # discard recomputation of self on records records.env.remove_to_compute(self, records) @@ -141,7 +284,6 @@ def write(self, records, value): records.env["ir.attachment"] .sudo() .with_context( - storage_code=self.storage_code, binary_field_real_user=records.env.user, ) ) @@ -178,7 +320,7 @@ def write(self, records, value): for att in created: record = records.browse(att.res_id) record.env.cache.update( - record, self, [FSFileBytesIO(attachment=att)], dirty=False + record, self, [FSFileValue(attachment=att)], dirty=False ) else: atts.unlink() @@ -191,19 +333,19 @@ def _get_filename(self, record): def convert_to_cache(self, value, record, validate=True): if value is None or value is False: return None - if isinstance(value, FSFileBytesIO): + if isinstance(value, FSFileValue): return value if isinstance(value, dict): - return FSFileBytesIO( + 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 FSFileBytesIO(name=name, value=value) + return FSFileValue(name=name, value=value) if isinstance(value, bytes): - return FSFileBytesIO( + return FSFileValue( name=self._get_filename(record), value=base64.b64decode(value) ) raise ValueError( @@ -238,7 +380,7 @@ def __convert_to_record(self, value, record): if isinstance(value, IOBase): return value if isinstance(value, bytes): - return FSFileBytesIO(value=value) + return FSFileValue(value=value) raise ValueError( "Invalid value for %s: %r\n" "Should be base64 encoded bytes or a file-like object" % (self, value) @@ -247,7 +389,7 @@ def __convert_to_record(self, 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, FSFileBytesIO): + if isinstance(value, FSFileValue): return { "filename": value.name, "url": value.internal_url, diff --git a/fs_file/io.py b/fs_file/io.py deleted file mode 100644 index c5b2e873bf..0000000000 --- a/fs_file/io.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright 2023 ACSONE SA/NV -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -# pylint: disable=method-required-super -import io -import os.path - -from odoo.addons.fs_attachment.models.ir_attachment import IrAttachment - - -class FSFileBytesIO: - def __init__( - self, - attachment: IrAttachment = None, - name: str = None, - value: bytes | io.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: io.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, io.IOBase): - self._buffer = value - if not hasattr(value, "name") and name: - self._buffer.name = name - elif not name: - 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 = io.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") - - @property - def write_buffer(self) -> io.BytesIO: - if self._buffer is None: - name = self._attachment.name if self._attachment else None - self._buffer = io.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 mimetype(self) -> str | None: - return self._attachment.mimetype if self._attachment else None - - @property - def size(self) -> int: - return self._attachment.file_size if self._attachment else len(self._buffer) - - @property - def url(self) -> str | None: - return self._attachment.url if self._attachment else None - - @property - def internal_url(self) -> str | None: - return self._attachment.internal_url 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 - - def open( - self, - mode="rb", - block_size=None, - cache_options=None, - compression=None, - new_version=True, - **kwargs - ) -> io.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, - ) diff --git a/fs_file/readme/CONTRIBUTORS.rst b/fs_file/readme/CONTRIBUTORS.rst index 7106cfd089..ce84680771 100644 --- a/fs_file/readme/CONTRIBUTORS.rst +++ b/fs_file/readme/CONTRIBUTORS.rst @@ -1 +1,2 @@ Laurent Mignon +Marie Lejeune diff --git a/fs_file/readme/USAGE.rst b/fs_file/readme/USAGE.rst index 5c387cd131..affe202714 100644 --- a/fs_file/readme/USAGE.rst +++ b/fs_file/readme/USAGE.rst @@ -27,7 +27,7 @@ Concretely, this design allows you to write code like this: _name = 'my.model' name = fields.Char() - file = FSFile(field_name='filename', storage_code="my_storage") + file = FSFile() # Create a new record with a raw content my_model = MyModel.create({ diff --git a/fs_file/tests/models.py b/fs_file/tests/models.py index ce035df6a2..145c85c343 100644 --- a/fs_file/tests/models.py +++ b/fs_file/tests/models.py @@ -9,6 +9,7 @@ class TestModel(models.Model): _name = "test.model" + _description = "Test Model" _log_access = False - fs_file = FSFile(storage_code="mem_dir") + fs_file = FSFile() diff --git a/fs_file/tests/test_fs_file.py b/fs_file/tests/test_fs_file.py index b57ce2fc9d..193e31375d 100644 --- a/fs_file/tests/test_fs_file.py +++ b/fs_file/tests/test_fs_file.py @@ -11,7 +11,7 @@ from odoo.addons.fs_storage.models.fs_storage import FSStorage -from ..io import FSFileBytesIO +from ..fields import FSFileValue class TestFsFile(TransactionCase): @@ -54,19 +54,21 @@ def tearDownClass(cls): 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, FSFileBytesIO)) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) self.assertEqual(instance.fs_file.getvalue(), self.create_content) self.assertEqual(instance.fs_file.name, self.filename) - def _test_write(self, fs_file_value): - instance = self.env["test.model"].create({"fs_file": fs_file_value}) + 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": FSFileBytesIO(name=self.filename, value=self.create_content)} + {"fs_file": FSFileValue(name=self.filename, value=self.create_content)} ) info = instance.read(["fs_file"])[0] self.assertDictEqual( @@ -80,7 +82,7 @@ def test_read(self): ) def test_create_with_fsfilebytesio(self): - self._test_create(FSFileBytesIO(name=self.filename, value=self.create_content)) + self._test_create(FSFileValue(name=self.filename, value=self.create_content)) def test_create_with_dict(self): self._test_create( @@ -106,25 +108,25 @@ 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, FSFileBytesIO)) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) self.assertEqual(instance.fs_file.getvalue(), self.create_content) def test_write_in_b64(self): instance = self.env["test.model"].create({"fs_file": b"test"}) instance.write({"fs_file": base64.b64encode(self.create_content)}) - self.assertTrue(isinstance(instance.fs_file, FSFileBytesIO)) + 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} + 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, FSFileBytesIO)) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) self.assertEqual(instance.fs_file.getvalue(), self.create_content) def test_write_with_io(self): @@ -132,19 +134,20 @@ def test_write_with_io(self): {"fs_file": io.BytesIO(self.create_content)} ) instance.write({"fs_file": io.BytesIO(b"test3")}) - self.assertTrue(isinstance(instance.fs_file, io.IOBase)) + self.assertTrue(isinstance(instance.fs_file, FSFileValue)) self.assertEqual(instance.fs_file.getvalue(), b"test3") def test_modify_fsfilebytesio(self): - """If you modify the content of the FSFileBytesIO using the write - method on the FSFileBytesIO object, the changes will be directly applied - and a new file in the storage must be created for the new content. + """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": FSFileBytesIO(name=self.filename, value=self.create_content)} + {"fs_file": FSFileValue(name=self.filename, value=self.create_content)} ) initial_store_fname = instance.fs_file.attachment.store_fname - instance.fs_file.write(b"new_content") + 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 ) diff --git a/fs_file_demo/models/fs_file.py b/fs_file_demo/models/fs_file.py index 72a6155aa4..8fd747a9b8 100644 --- a/fs_file_demo/models/fs_file.py +++ b/fs_file_demo/models/fs_file.py @@ -12,6 +12,4 @@ class FsFile(models.Model): _description = "Fs File" name = fields.Char() - file = fs_fields.FSFile( - string="File", storage_code="odoofs" - ) # TODO: remove storage_code + file = fs_fields.FSFile(string="File")