diff --git a/fs_attachment/fs_stream.py b/fs_attachment/fs_stream.py index fafc233892..b1306b6b4a 100644 --- a/fs_attachment/fs_stream.py +++ b/fs_attachment/fs_stream.py @@ -21,18 +21,13 @@ def from_fs_attachment(cls, attachment: IrAttachment) -> FsStream: attachment.ensure_one() if not attachment.fs_filename: raise ValueError("Attachment is not stored into a filesystem storage") - size = 0 - if cls._check_use_x_sendfile(attachment): - fs, _storage, fname = attachment._get_fs_parts() - fs_info = fs.info(fname) - size = fs_info["size"] return cls( mimetype=attachment.mimetype, download_name=attachment.name, conditional=True, etag=attachment.checksum, type="fs", - size=size, + size=attachment.file_size, last_modified=attachment["__last_update"], fs_attachment=attachment, ) diff --git a/fs_attachment/models/ir_attachment.py b/fs_attachment/models/ir_attachment.py index ee97bee9d0..fd4a2a0e48 100644 --- a/fs_attachment/models/ir_attachment.py +++ b/fs_attachment/models/ir_attachment.py @@ -98,9 +98,14 @@ class IrAttachment(models.Model): def _compute_internal_url(self) -> None: for rec in self: filename, extension = os.path.splitext(rec.name) + # determine if the file is an image + pfx = "/web/content" + if rec.mimetype and rec.mimetype.startswith("image/"): + pfx = "/web/image" + if not extension: extension = mimetypes.guess_extension(rec.mimetype) - rec.internal_url = f"/web/content/{rec.id}/{filename}{extension}" + rec.internal_url = f"{pfx}/{rec.id}/{filename}{extension}" @api.depends("fs_filename") def _compute_fs_url(self) -> None: @@ -441,8 +446,6 @@ def _enforce_meaningful_storage_filename(self) -> None: if self.env["fs.storage"]._must_use_filename_obfuscation(storage): attachment.fs_filename = filename continue - if self._is_fs_filename_meaningful(filename): - continue new_filename = attachment._build_fs_filename() # we must keep the same full path as the original filename new_filename_with_path = os.path.join( @@ -491,17 +494,6 @@ def _fs_parse_store_fname( fname = partition[2] return fs, storage_code, fname - @api.model - def _is_fs_filename_meaningful(self, filename: str) -> bool: - """Return True if the filename is meaningful - A filename is meaningful if it's formatted as - """ - parsed = self._parse_fs_filename(filename) - if not parsed: - return False - name, res_id, version, extension = parsed - return bool(name and res_id and version is not None and extension) - @api.model def _parse_fs_filename(self, filename: str) -> tuple[str, int, int, str] | None: """Parse the filename and return the name, id, version and extension diff --git a/fs_attachment/models/ir_binary.py b/fs_attachment/models/ir_binary.py index d06d5f1db0..65ff04fd6e 100644 --- a/fs_attachment/models/ir_binary.py +++ b/fs_attachment/models/ir_binary.py @@ -2,7 +2,11 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import logging +import werkzeug.http + from odoo import models +from odoo.http import request +from odoo.tools.image import image_process from ..fs_stream import FsStream @@ -39,3 +43,69 @@ def _record_to_stream(self, record, field_name): if fs_attachment: return FsStream.from_fs_attachment(fs_attachment) return super()._record_to_stream(record, field_name) + + def _get_image_stream_from( + self, + record, + field_name="raw", + filename=None, + filename_field="name", + mimetype=None, + default_mimetype="image/png", + placeholder=None, + width=0, + height=0, + crop=False, + quality=0, + ): + # we need to override this method since if you pass a width or height or + # set crop=True, the stream data must be a bytes object, not a + # file-like object. In the base implementation, the stream data is + # passed to `image_process` method to transform it and this method + # expects a bytes object. + initial_width = width + initial_height = height + initial_crop = crop + if record._name != "ir.attachment" and field_name: + field_def = record._fields[field_name] + if field_def.type in ("fs_image", "fs_file"): + value = record[field_name] + if value: + record = value.attachment + field_name = "raw" + stream = super()._get_image_stream_from( + record, + field_name=field_name, + filename=filename, + filename_field=filename_field, + mimetype=mimetype, + default_mimetype=default_mimetype, + placeholder=placeholder, + width=0, + height=0, + crop=False, + quality=quality, + ) + modified = werkzeug.http.is_resource_modified( + request.httprequest.environ, + etag=stream.etag, + last_modified=stream.last_modified, + ) + if modified and (initial_width or initial_height or initial_crop): + if stream.type == "path": + with open(stream.path, "rb") as file: + stream.type = "data" + stream.path = None + stream.data = file.read() + elif stream.type == "fs": + stream.data = stream.read() + stream.type = "data" + stream.data = image_process( + stream.data, + size=(initial_width, initial_height), + crop=initial_crop, + quality=quality, + ) + stream.size = len(stream.data) + + return stream diff --git a/fs_attachment/tests/__init__.py b/fs_attachment/tests/__init__.py index b3a4b19e2e..75bdb802b1 100644 --- a/fs_attachment/tests/__init__.py +++ b/fs_attachment/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_fs_attachment_file_like_adapter from . import test_fs_attachment_internal_url from . import test_fs_storage +from . import test_stream diff --git a/fs_attachment/tests/test_stream.py b/fs_attachment/tests/test_stream.py new file mode 100644 index 0000000000..c7172877bf --- /dev/null +++ b/fs_attachment/tests/test_stream.py @@ -0,0 +1,138 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import io +import os +import shutil +import tempfile + +from PIL import Image + +from odoo.tests.common import HttpCase + + +class TestStream(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + temp_dir = tempfile.mkdtemp() + cls.temp_backend = cls.env["fs.storage"].create( + { + "name": "Temp FS Storage", + "protocol": "file", + "code": "tmp_dir", + "directory_path": temp_dir, + "base_url": "http://my.public.files/", + } + ) + cls.temp_dir = temp_dir + cls.content = b"This is a test attachment" + cls.attachment_binary = ( + cls.env["ir.attachment"] + .with_context( + storage_location=cls.temp_backend.code, + storage_file_path="test.txt", + ) + .create({"name": "test.txt", "raw": cls.content}) + ) + + cls.image = cls._create_image(128, 128) + cls.attachment_image = ( + cls.env["ir.attachment"] + .with_context( + storage_location=cls.temp_backend.code, + storage_file_path="test.png", + ) + .create({"name": "test.png", "raw": cls.image}) + ) + + @cls.addClassCleanup + def cleanup_tempdir(): + shutil.rmtree(temp_dir) + + assert cls.attachment_binary.fs_filename + assert cls.attachment_image.fs_filename + + def setUp(self): + super().setUp() + # enforce temp_backend field since it seems that they are reset on + # savepoint rollback when managed by server_environment -> TO Be investigated + self.temp_backend.write( + { + "protocol": "file", + "code": "tmp_dir", + "directory_path": self.temp_dir, + "base_url": "http://my.public.files/", + } + ) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + for f in os.listdir(cls.temp_dir): + os.remove(os.path.join(cls.temp_dir, f)) + + @classmethod + def _create_image(cls, width, height, color="#4169E1", img_format="PNG"): + f = io.BytesIO() + Image.new("RGB", (width, height), color).save(f, img_format) + f.seek(0) + return f.read() + + def assertDownload( + self, url, headers, assert_status_code, assert_headers, assert_content=None + ): + res = self.url_open(url, headers=headers) + res.raise_for_status() + self.assertEqual(res.status_code, assert_status_code) + for header_name, header_value in assert_headers.items(): + self.assertEqual( + res.headers.get(header_name), + header_value, + f"Wrong value for header {header_name}", + ) + if assert_content: + self.assertEqual(res.content, assert_content, "Wong content") + return res + + def test_content_url(self): + self.authenticate("admin", "admin") + url = f"/web/content/{self.attachment_binary.id}" + self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": "inline; filename=test.txt", + }, + assert_content=self.content, + ) + + def test_image_url(self): + self.authenticate("admin", "admin") + url = f"/web/image/{self.attachment_image.id}" + self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "image/png", + "Content-Disposition": "inline; filename=test.png", + }, + assert_content=self.image, + ) + + def test_image_url_with_size(self): + self.authenticate("admin", "admin") + url = f"/web/image/{self.attachment_image.id}?width=64&height=64" + res = self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "image/png", + "Content-Disposition": "inline; filename=test.png", + }, + ) + self.assertEqual(Image.open(io.BytesIO(res.content)).size, (64, 64)) diff --git a/fs_file/fields.py b/fs_file/fields.py index 7ec3cff2fa..bb53c2e424 100644 --- a/fs_file/fields.py +++ b/fs_file/fields.py @@ -3,10 +3,12 @@ # 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 @@ -87,13 +89,35 @@ def name(self, value: str) -> None: "yet stored" ) + @property + def is_new(self) -> bool: + return self._is_new + @property def mimetype(self) -> str | None: - return self._attachment.mimetype if self._attachment else None + # get mimetype from name + mimetype = None + if self._attachment: + mimetype = self._attachment.mimetype + elif self.name: + mimetype = guess_mimetype(self.name) + return mimetype or "application/octet-stream" @property def size(self) -> int: - return self._attachment.file_size if self._attachment else len(self._buffer) + 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: @@ -112,6 +136,15 @@ 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: @@ -209,9 +242,9 @@ class FSFile(fields.Binary): attachment: bool = True - def __init__(self, **kwargs): + def __init__(self, *args, **kwargs): kwargs["attachment"] = True - super().__init__(**kwargs) + super().__init__(*args, **kwargs) def read(self, records): domain = [ @@ -220,7 +253,7 @@ def read(self, records): ("res_id", "in", records.ids), ] data = { - att.res_id: FSFileValue(attachment=att) + 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)) @@ -230,33 +263,39 @@ def create(self, record_values): return env = record_values[0][0].env with env.norecompute(): - ir_attachment = ( - env["ir.attachment"] - .sudo() - .with_context( - binary_field_real_user=env.user, - ) - ) for record, value in record_values: if value: cache_value = self.convert_to_cache(value, record) - attachment = ir_attachment.create( - { - "name": cache_value.name, - "raw": cache_value.getvalue(), - "res_model": record._name, - "res_field": self.name, - "res_id": record.id, - "type": "binary", - } - ) + attachment = self._create_attachment(record, cache_value) + cache_value = self._convert_attachment_to_cache(attachment) record.env.cache.update( record, self, - [FSFileValue(attachment=attachment)], + [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: @@ -304,29 +343,23 @@ def write(self, records, value): # create the missing attachments missing = real_records - atts_records if missing: - create_vals = [] + created = atts.browse() for record in missing: - create_vals.append( - { - "name": filename, - "res_model": record._name, - "res_field": self.name, - "res_id": record.id, - "type": "binary", - "raw": content, - } - ) - created = atts.create(create_vals) + 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, [FSFileValue(attachment=att)], dirty=False + 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) @@ -336,6 +369,12 @@ def convert_to_cache(self, value, record, validate=True): 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"].browse(int(att_id)) + return self._convert_attachment_to_cache(attachment) return FSFileValue( name=value["filename"], value=base64.b64decode(value["content"]) ) @@ -356,46 +395,20 @@ def convert_to_cache(self, value, record, validate=True): def convert_to_write(self, value, record): return self.convert_to_cache(value, record) - def __convert_to_column(self, value, record, values=None, validate=True): - if value is None or value is False: - return None - if isinstance(value, IOBase): - if hasattr(value, "getvalue"): - value = value.getvalue() - else: - v = value.read() - value.seek(0) - value = v - return value - if isinstance(value, bytes): - return base64.b64decode(value) - raise ValueError( - "Invalid value for %s: %r\n" - "Should be base64 encoded bytes or a file-like object" % (self, value) - ) - - def __convert_to_record(self, value, record): - if value is None or value is False: - return None - if isinstance(value, IOBase): - return value - if isinstance(value, bytes): - return FSFileValue(value=value) - raise ValueError( - "Invalid value for %s: %r\n" - "Should be base64 encoded bytes or a file-like object" % (self, value) - ) - 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): - return { + res = { "filename": value.name, - "url": value.internal_url, "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( "Invalid value for %s: %r\n" "Should be base64 encoded bytes or a file-like object" % (self, value) diff --git a/fs_file/readme/DESCRIPTION.rst b/fs_file/readme/DESCRIPTION.rst index e69de29bb2..b48e44c1cd 100644 --- a/fs_file/readme/DESCRIPTION.rst +++ b/fs_file/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +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/static/src/views/fields/fsfile_field.esm.js b/fs_file/static/src/views/fields/fsfile_field.esm.js index 30149cf4c6..7ae3d13a2e 100644 --- a/fs_file/static/src/views/fields/fsfile_field.esm.js +++ b/fs_file/static/src/views/fields/fsfile_field.esm.js @@ -3,24 +3,20 @@ /** * Copyright 2023 ACSONE SA/NV */ +import {Component, onWillUpdateProps, useState} from "@odoo/owl"; -import {registry} from "@web/core/registry"; -import {session} from "@web/session"; -import {formatFloat} from "@web/views/fields/formatters"; -import {useService} from "@web/core/utils/hooks"; -import {sprintf} from "@web/core/utils/strings"; +import {FileUploader} from "@web/views/fields/file_handler"; import {getDataURLFromFile} from "@web/core/utils/urls"; +import {registry} from "@web/core/registry"; import {standardFieldProps} from "@web/views/fields/standard_field_props"; -import {Component, onWillUpdateProps, useState} from "@odoo/owl"; - -const DEFAULT_MAX_FILE_SIZE = 128 * 1024 * 1024; // 128MB +import {useService} from "@web/core/utils/hooks"; export class FSFileField extends Component { setup() { this.notification = useService("notification"); this.state = useState({ ...this.props.value, - isUploading: false, + isValid: true, }); onWillUpdateProps((nextProps) => { this.state.isUploading = false; @@ -30,34 +26,6 @@ export class FSFileField extends Component { this.state.url = url; }); } - get maxUploadSize() { - return session.max_file_upload_size || DEFAULT_MAX_FILE_SIZE; - } - - edit() { - var input = document.createElement("input"); - input.type = "file"; - input.accept = this.props.acceptedFileExtensions; - input.onchange = (e) => { - const file = e.target.files[0]; - if (file) { - if (file.size > this.maxUploadSize) { - this.notification.add( - sprintf( - this.env._t( - "The file size exceeds the maximum allowed size of %s MB." - ), - formatFloat(this.maxUploadSize / 1024 / 1024) - ), - {type: "danger"} - ); - return; - } - this.uploadFile(file); - } - }; - input.click(); - } async uploadFile(file) { this.state.isUploading = true; @@ -74,9 +42,30 @@ export class FSFileField extends Component { clear() { this.props.record.update({[this.props.name]: false}); } + + onFileRemove() { + this.state.isValid = true; + this.props.update(false); + } + onFileUploaded(info) { + this.state.isValid = true; + this.props.update({ + filename: info.name, + content: info.data, + }); + } + onLoadFailed() { + this.state.isValid = false; + this.notification.add(this.env._t("Could not display the selected image"), { + type: "danger", + }); + } } FSFileField.template = "fs_file.FSFileField"; +FSFileField.components = { + FileUploader, +}; FSFileField.props = { ...standardFieldProps, acceptedFileExtensions: {type: String, optional: true}, diff --git a/fs_file/static/src/views/fields/fsfile_field.xml b/fs_file/static/src/views/fields/fsfile_field.xml index 34840a5e5e..e52454d4f8 100644 --- a/fs_file/static/src/views/fields/fsfile_field.xml +++ b/fs_file/static/src/views/fields/fsfile_field.xml @@ -2,39 +2,41 @@ - Uploading...
- - - - - - - - +
diff --git a/fs_file_demo/__manifest__.py b/fs_file_demo/__manifest__.py index f987579cbc..5795e20737 100644 --- a/fs_file_demo/__manifest__.py +++ b/fs_file_demo/__manifest__.py @@ -3,13 +3,14 @@ { "name": "Fs File Demo", - "summary": """Demo addon for fs_file""", + "summary": """Demo addon for fs_file and fs_image""", "version": "16.0.1.0.0", "license": "AGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", "website": "https://github.com/OCA/storage", "depends": [ "fs_file", + "fs_image", ], "data": [ "security/fs_file.xml", diff --git a/fs_file_demo/models/fs_file.py b/fs_file_demo/models/fs_file.py index 8fd747a9b8..5c0fc83183 100644 --- a/fs_file_demo/models/fs_file.py +++ b/fs_file_demo/models/fs_file.py @@ -4,6 +4,7 @@ from odoo import fields, models from odoo.addons.fs_file import fields as fs_fields +from odoo.addons.fs_image import fields as fs_image_fields class FsFile(models.Model): @@ -13,3 +14,14 @@ class FsFile(models.Model): name = fields.Char() file = fs_fields.FSFile(string="File") + + fs_image_1920 = fs_image_fields.FSImage( + string="Image", max_width=1920, max_height=1920 + ) + fs_image_128 = fs_image_fields.FSImage( + string="Image", + max_width=128, + max_height=128, + related="fs_image_1920", + store=True, + ) diff --git a/fs_file_demo/views/fs_file.xml b/fs_file_demo/views/fs_file.xml index 030150e2d7..3efd6a710a 100644 --- a/fs_file_demo/views/fs_file.xml +++ b/fs_file_demo/views/fs_file.xml @@ -10,12 +10,16 @@
+ -
diff --git a/fs_image/README.rst b/fs_image/README.rst new file mode 100644 index 0000000000..38929e8775 --- /dev/null +++ b/fs_image/README.rst @@ -0,0 +1,35 @@ +**This file is going to be generated by oca-gen-addon-readme.** + +*Manual changes will be overwritten.* + +Please provide content in the ``readme`` directory: + +* **DESCRIPTION.rst** (required) +* INSTALL.rst (optional) +* CONFIGURE.rst (optional) +* **USAGE.rst** (optional, highly recommended) +* DEVELOP.rst (optional) +* ROADMAP.rst (optional) +* HISTORY.rst (optional, recommended) +* **CONTRIBUTORS.rst** (optional, highly recommended) +* CREDITS.rst (optional) + +Content of this README will also be drawn from the addon manifest, +from keys such as name, authors, maintainers, development_status, +and license. + +A good, one sentence summary in the manifest is also highly recommended. + + +Automatic changelog generation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`HISTORY.rst` can be auto generated using `towncrier `_. + +Just put towncrier compatible changelog fragments into `readme/newsfragments` +and the changelog file will be automatically generated and updated when a new fragment is added. + +Please refer to `towncrier` documentation to know more. + +NOTE: the changelog will be automatically generated when using `/ocabot merge $option`. +If you need to run it manually, refer to `OCA/maintainer-tools README `_. diff --git a/fs_image/__init__.py b/fs_image/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/fs_image/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fs_image/__manifest__.py b/fs_image/__manifest__.py new file mode 100644 index 0000000000..c415948adf --- /dev/null +++ b/fs_image/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Fs Image", + "summary": """ + Field to store images into filesystem storages""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/storage", + "depends": ["fs_file"], + "data": [], + "demo": [], + "maintainers": ["lmignon"], + "development_status": "Alpha", + "assets": { + "web.assets_backend": [ + "fs_image/static/src/**/*", + ], + }, +} diff --git a/fs_image/fields.py b/fs_image/fields.py new file mode 100644 index 0000000000..f746c0f1e4 --- /dev/null +++ b/fs_image/fields.py @@ -0,0 +1,226 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# pylint: disable=method-required-super +from contextlib import contextmanager +from io import BytesIO, IOBase + +from odoo import _ +from odoo.exceptions import UserError +from odoo.tools.image import image_process + +from odoo.addons.fs_attachment.models.ir_attachment import IrAttachment +from odoo.addons.fs_file.fields import FSFile, FSFileValue + + +class FSImageValue(FSFileValue): + """Value for the FSImage field""" + + def __init__( + self, + attachment: IrAttachment = None, + name: str = None, + value: bytes | IOBase = None, + alt_text: str = None, + ) -> None: + super().__init__(attachment, name, value) + self.alt_text = alt_text + + @property + def alt_text(self) -> str: + alt_text = self._attachment.alt_text if self._attachment else self._alt_text + return alt_text + + @alt_text.setter + def alt_text(self, value: str) -> None: + if self._attachment: + self._attachment.alt_text = value + else: + self._alt_text = value + + @classmethod + def from_fs_file_value(cls, fs_file_value: FSFileValue) -> "FSImageValue": + if isinstance(fs_file_value, FSImageValue): + return fs_file_value + alt_text = ( + fs_file_value.attachment.alt_text if fs_file_value.attachment else None + ) + alt_text = alt_text or None + return cls( + attachment=fs_file_value.attachment, + name=fs_file_value.name if not fs_file_value.attachment else None, + value=fs_file_value._buffer + if not fs_file_value.attachment + else fs_file_value._buffer, + alt_text=alt_text, + ) + + def image_process( + self, + size=(0, 0), + verify_resolution=False, + quality=0, + crop=None, + colorize=False, + output_format="", + ): + """ + Process the image to adapt it to the given parameters. + :param size: a tuple (max_width, max_height) containing the maximum + width and height of the processed image. + If one of the value is 0, it will be calculated to keep the aspect + ratio. + If both values are 0, the image will not be resized. + :param verify_resolution: if True, make sure the original image size is not + excessive before starting to process it. The max allowed resolution is + defined by `IMAGE_MAX_RESOLUTION` in :class:`odoo.tools.image.ImageProcess`. + :param int quality: quality setting to apply. Default to 0. + + - for JPEG: 1 is worse, 95 is best. Values above 95 should be + avoided. Falsy values will fallback to 95, but only if the image + was changed, otherwise the original image is returned. + - for PNG: set falsy to prevent conversion to a WEB palette. + - for other formats: no effect. + :param crop: (True | 'top' | 'bottom'): + * True, the image will be cropped to the given size. + * 'top', the image will be cropped at the top to the given size. + * 'bottom', the image will be cropped at the bottom to the given size. + Otherwise, it will be resized to fit the given size. + :param colorize: if True, the transparent background of the image + will be colorized in a random color. + :param str output_format: the output format. Can be PNG, JPEG, GIF, or ICO. + Default to the format of the original image. BMP is converted to + PNG, other formats than those mentioned above are converted to JPEG. + :return: the processed image as bytes + """ + return image_process( + self.getvalue(), + size=size, + crop=crop, + quality=quality, + verify_resolution=verify_resolution, + colorize=colorize, + output_format=output_format, + ) + + +class FSImage(FSFile): + """ + This field is a FSFile field with an alt_text attribute used to encapsulate + an image file stored in a filesystem storage. + + It's inspired by the 'image' field of odoo :class:`odoo.fields.Binary` but + is designed to store the image in a filesystem storage instead of the + database. + + If image size is greater than the ``max_width``/``max_height`` limit of pixels, + the image will be resized to the limit by keeping aspect ratio. + + :param int max_width: the maximum width of the image (default: ``0``, no limit) + :param int max_height: the maximum height of the image (default: ``0``, no limit) + :param bool verify_resolution: whether the image resolution should be verified + to ensure it doesn't go over the maximum image resolution + (default: ``True``). + See :class:`odoo.tools.image.ImageProcess` for maximum image resolution + (default: ``50e6``). + """ + + type = "fs_image" + + max_width = 0 + max_height = 0 + verify_resolution = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._image_process_mode = False + + def create(self, record_values): + with self._set_image_process_mode(): + return super().create(record_values) + + def write(self, records, value): + if isinstance(value, dict) and "content" not in value: + # we are writing on the alt_text field only + return self._update_alt_text(records, value) + with self._set_image_process_mode(): + return super().write(records, value) + + def convert_to_cache(self, value, record, validate=True): + if not value: + return None + if isinstance(value, FSImageValue): + cache_value = value + else: + cache_value = super().convert_to_cache(value, record, validate) + if not isinstance(cache_value, FSImageValue): + cache_value = FSImageValue.from_fs_file_value(cache_value) + if isinstance(value, dict) and "alt_text" in value: + cache_value.alt_text = value["alt_text"] + if self._image_process_mode and cache_value.is_new: + name = cache_value.name + new_value = BytesIO(self._image_process(cache_value)) + cache_value._buffer = new_value + cache_value.name = name + return cache_value + + def _create_attachment(self, record, cache_value): + attachment = super()._create_attachment(record, cache_value) + # odoo filter out additional fields in create method on ir.attachment + # so we need to write the alt_text after the creation + attachment.alt_text = cache_value.alt_text + return attachment + + def _convert_attachment_to_cache(self, attachment: IrAttachment) -> FSImageValue: + cache_value = super()._convert_attachment_to_cache(attachment) + return FSImageValue.from_fs_file_value(cache_value) + + def _image_process(self, cache_value: FSImageValue) -> bytes | None: + if self.readonly and not self.max_width and not self.max_height: + # no need to process images for computed fields, or related fields + return cache_value.getvalue() + return ( + cache_value.image_process( + size=(self.max_width, self.max_height), + verify_resolution=self.verify_resolution, + ) + or None + ) + + def convert_to_read(self, value, record, use_name_get=True) -> dict | None: + vals = super().convert_to_read(value, record, use_name_get) + if isinstance(value, FSImageValue): + vals["alt_text"] = value.alt_text or None + return vals + + @contextmanager + def _set_image_process_mode(self): + self._image_process_mode = True + try: + yield + finally: + self._image_process_mode = False + + def _process_related(self, value: FSImageValue): + """Override to resize the related value before saving it on self.""" + if not value: + return None + if self.readonly and not self.max_width and not self.max_height: + # no need to process images for computed fields, or related fields + # without max_width/max_height + return value + value = super()._process_related(value) + new_value = BytesIO(self._image_process(value)) + return FSImageValue(value=new_value, alt_text=value.alt_text, name=value.name) + + def _update_alt_text(self, records, value: dict): + for record in records: + if not record[self.name]: + raise UserError( + _( + "Cannot set alt_text on empty image (record %(record)s.%(field_name)s)", + record=record, + field_name=self.name, + ) + ) + record[self.name].alt_text = value["alt_text"] + return True diff --git a/fs_image/models/__init__.py b/fs_image/models/__init__.py new file mode 100644 index 0000000000..17f08cdf4d --- /dev/null +++ b/fs_image/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_attachment +from . import fs_image_mixin diff --git a/fs_image/models/fs_image_mixin.py b/fs_image/models/fs_image_mixin.py new file mode 100644 index 0000000000..eb5684069f --- /dev/null +++ b/fs_image/models/fs_image_mixin.py @@ -0,0 +1,17 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + +from ..fields import FSImage + + +class FSImageMixin(models.AbstractModel): + _name = "fs.image.mixin" + _description = "Image Mixin" + + image = FSImage("Image") + # resized fields stored (as attachment) for performance + image_medium = FSImage( + "Image medium", related="image", max_width=128, max_height=128, store=True + ) diff --git a/fs_image/models/ir_attachment.py b/fs_image/models/ir_attachment.py new file mode 100644 index 0000000000..2856e98c34 --- /dev/null +++ b/fs_image/models/ir_attachment.py @@ -0,0 +1,15 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrAttachment(models.Model): + + _inherit = "ir.attachment" + + alt_text = fields.Char( + "Alternative Text", + help="Alternative text for the image. Only used for images on a website.", + translate=False, + ) diff --git a/fs_image/readme/CONTRIBUTORS.rst b/fs_image/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..172b2d223c --- /dev/null +++ b/fs_image/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Laurent Mignon diff --git a/fs_image/readme/DESCRIPTION.rst b/fs_image/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..9315e90ab2 --- /dev/null +++ b/fs_image/readme/DESCRIPTION.rst @@ -0,0 +1,15 @@ +This addon defines a new field **FSImage** to use in your models. It is a +subclass of the **FSFile** field and comes with the same features. It extends +the **FSFile** field with specific properties dedicated to images. On the field +definition, the following additional properties are available: + +* **max_width** (int): maximum width of the image in pixels (default: ``0``, no limit) +* **max_height** (int): maximum height of the image in pixels (default: ``0``, no limit) +* **verify_resolution** (bool):whether the image resolution should be verified + to ensure it doesn't go over the maximum image resolution (default: ``True``). + See `odoo.tools.image.ImageProcess` for maximum image resolution (default: ``50e6``). + +On the field's value side, the value is an instance of a subclass of +`odoo.addons.fs_file.fields.FSFileValue`. It extends the class to allows +you to manage an alt_text for the image. The alt_text is a text that will be +displayed when the image cannot be displayed. diff --git a/fs_image/readme/USAGE.rst b/fs_image/readme/USAGE.rst new file mode 100644 index 0000000000..1db6920db7 --- /dev/null +++ b/fs_image/readme/USAGE.rst @@ -0,0 +1,113 @@ +This new field type can be used in the same way as the odoo 'Image' field type. + +.. code-block:: python + + from odoo import models + from odoo.addons.fs_image.fields import FSImage + + class MyModel(models.Model): + _name = 'my.model' + + image = FSImage('Image', max_width=1920, max_height=1920) + + +.. code-block:: xml + + + my.model.form + my.model + +
+ + + + + +
+
+
+ + +In the example above, the image will be resized to 1920x1920px if it is larger than that. +The widget used in the form view will also allow the user set an 'alt' text for the image. + + +A mode advanced and useful example is the following: + +.. code-block:: python + + from odoo import models + from odoo.addons.fs_image.fields import FSImage + + class MyModel(models.Model): + _name = 'my.model' + + image_1920 = FSImage('Image', max_width=1920, max_height=1920) + image_128 = FSImage('Image', max_width=128, max_height=128, related='image_1920', store=True) + + +.. code-block:: xml + + + my.model.form + my.model + +
+ + + + + +
+
+
+ +In the example above we have two fields, one for the original image and one for a thumbnail. +As the thumbnail is defined as a related stored field it's automatically generated +from the original image, resized at the given size and stored in the database. +The thumbnail is then used as a preview image for the original image in the form view. +The main advantage of this approach is that the original image is not loaded in the form view +and the thumbnail is used instead, which is much smaller in size and faster to load. +The 'zoom' option allows the user to see the original image in a popup when clicking on the thumbnail. + +For convenience, the 'fs_image' module also provides a 'FSImageMixin' mixin class +that can be used to add the 'image' and 'image_medium' fields to a model. It only +define the medium thumbnail as a 128x128px image since it's the most common use case. +When using an image field in a model, it's recommended to use this mixin class +in order ensure that the 'image_medium' field is always defined. A good practice +is to use the `image_medium` field as a preview image for the `image` field in +the form view to avoid to overload the form view with a large image and consume +too much bandwidth. + +.. code-block:: python + + from odoo import models + + class MyModel(models.Model): + _name = 'my.model' + _inherit = ['fs_image.mixin'] + + +.. code-block:: xml + + + my.model.form + my.model + +
+ + + + + +
+
+
diff --git a/fs_image/static/description/icon.png b/fs_image/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/fs_image/static/description/icon.png differ diff --git a/fs_image/static/src/scss/fsimage_field.scss b/fs_image/static/src/scss/fsimage_field.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fs_image/static/src/views/dialogs/alttext_dialog.esm.js b/fs_image/static/src/views/dialogs/alttext_dialog.esm.js new file mode 100644 index 0000000000..b648fb1791 --- /dev/null +++ b/fs_image/static/src/views/dialogs/alttext_dialog.esm.js @@ -0,0 +1,40 @@ +/** @odoo-module */ + +/** + * Copyright 2023 ACSONE SA/NV + */ + +import {Dialog} from "@web/core/dialog/dialog"; + +const {Component, useRef} = owl; + +export class AltTextDialog extends Component { + setup() { + this.altText = useRef("altText"); + } + + async onClose() { + if (this.props.close) { + this.props.close(); + } + } + + async onConfirm() { + try { + await this.props.confirm(this.altText.el.value); + } catch (e) { + this.props.close(); + throw e; + } + this.onClose(); + } +} + +AltTextDialog.components = {Dialog}; +AltTextDialog.template = "fs_image.AltTextDialog"; +AltTextDialog.props = { + title: String, + altText: String, + confirm: Function, + close: {type: Function, optional: true}, +}; diff --git a/fs_image/static/src/views/dialogs/alttext_dialog.xml b/fs_image/static/src/views/dialogs/alttext_dialog.xml new file mode 100644 index 0000000000..afdd22c838 --- /dev/null +++ b/fs_image/static/src/views/dialogs/alttext_dialog.xml @@ -0,0 +1,33 @@ + + + + +
+ + + +
+ +
+
+ + + + +
+
+
diff --git a/fs_image/static/src/views/fields/fsimage_field.esm.js b/fs_image/static/src/views/fields/fsimage_field.esm.js new file mode 100644 index 0000000000..adc4ffc618 --- /dev/null +++ b/fs_image/static/src/views/fields/fsimage_field.esm.js @@ -0,0 +1,100 @@ +/** @odoo-module */ + +/** + * Copyright 2023 ACSONE SA/NV + */ +import { + ImageField, + fileTypeMagicWordMap, + imageCacheKey, +} from "@web/views/fields/image/image_field"; +import {onWillUpdateProps, useState} from "@odoo/owl"; + +import {AltTextDialog} from "../dialogs/alttext_dialog.esm"; +import {registry} from "@web/core/registry"; +import {url} from "@web/core/utils/urls"; +import {useService} from "@web/core/utils/hooks"; + +const placeholder = "/web/static/img/placeholder.png"; + +export class FSImageField extends ImageField { + setup() { + // Call super.setup() to initialize the state + super.setup(); + this.state = useState({ + ...this.props.value, + ...this.state, + }); + onWillUpdateProps((nextProps) => { + this.state.isUploading = false; + const {filename, mimetype, alt_text, url} = nextProps.value || {}; + this.state.filename = filename; + this.state.mimetype = mimetype; + this.state.url = url; + this.state.alt_text = alt_text; + }); + this.dialogService = useService("dialog"); + } + + getUrl(previewFieldName) { + if ( + this.state.isValid && + this.props.value && + typeof this.props.value === "object" + ) { + // Check if value is a dict + if (this.props.value.content) { + // We use the binary content of the value + // Use magic-word technique for detecting image type + const magic = + fileTypeMagicWordMap[this.props.value.content[0]] || "png"; + return `data:image/${magic};base64,${this.props.value.content}`; + } + if (!this.rawCacheKey) { + this.rawCacheKey = this.props.record.data.__last_update; + } + const model = this.props.record.resModel; + const id = this.props.record.resId; + let base_url = this.props.value.url; + if (id !== undefined && id !== null && id !== false) { + const field = previewFieldName; + const filename = this.props.value.filename; + base_url = `/web/image/${model}/${id}/${field}/${filename}`; + } + return url(base_url, {unique: imageCacheKey(this.rawCacheKey)}); + } + return placeholder; + } + + get hasTooltip() { + return this.props.enableZoom && !this.props.isDebugMode && this.props.value; + } + + onFileUploaded(info) { + this.state.isValid = true; + // Invalidate the `rawCacheKey`. + this.rawCacheKey = null; + this.props.update({ + filename: info.name, + content: info.data, + }); + } + onAltTextEdit() { + const self = this; + const altText = this.props.value.alt_text || ""; + const dialogProps = { + title: this.env._t("Alt Text"), + altText: altText, + confirm: (value) => { + self.props.update({ + ...self.props.value, + alt_text: value, + }); + }, + }; + this.dialogService.add(AltTextDialog, dialogProps); + } +} + +FSImageField.template = "fs_image.FSImageField"; +registry.category("fields").add("fs_image", FSImageField); diff --git a/fs_image/static/src/views/fields/fsimage_field.xml b/fs_image/static/src/views/fields/fsimage_field.xml new file mode 100644 index 0000000000..cc1eeb507a --- /dev/null +++ b/fs_image/static/src/views/fields/fsimage_field.xml @@ -0,0 +1,65 @@ + + + + +
+
+ + + + + + + + + +
+ Binary file +
+
+ +
diff --git a/fs_image/tests/__init__.py b/fs_image/tests/__init__.py new file mode 100644 index 0000000000..fec1f1d8e7 --- /dev/null +++ b/fs_image/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fs_image diff --git a/fs_image/tests/models.py b/fs_image/tests/models.py new file mode 100644 index 0000000000..c48020241a --- /dev/null +++ b/fs_image/tests/models.py @@ -0,0 +1,32 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + +from ..fields import FSImage + + +class TestImageModel(models.Model): + + _name = "test.image.model" + _description = "Test Model" + _log_access = False + + fs_image = FSImage(verify_resolution=False) + fs_image_1024 = FSImage("Image 1024", max_width=1024, max_height=1024) + + +class TestRelatedImageModel(models.Model): + + _name = "test.related.image.model" + _description = "Test Related Image Model" + _log_access = False + + fs_image = FSImage(verify_resolution=False) + # resized fields stored (as attachment) for performance + fs_image_1024 = FSImage( + "Image 1024", related="fs_image", max_width=1024, max_height=1024, store=True + ) + fs_image_512 = FSImage( + "Image 512", related="fs_image", max_width=512, max_height=512, store=True + ) diff --git a/fs_image/tests/test_fs_image.py b/fs_image/tests/test_fs_image.py new file mode 100644 index 0000000000..296c6420d8 --- /dev/null +++ b/fs_image/tests/test_fs_image.py @@ -0,0 +1,222 @@ +# 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 odoo_test_helper import FakeModelLoader +from PIL import Image + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + +from odoo.addons.fs_storage.models.fs_storage import FSStorage + +from ..fields import FSImageValue + + +class TestFsImage(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.env["ir.config_parameter"].set_param( + "base.image_autoresize_max_px", "10000x10000" + ) + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import TestImageModel, TestRelatedImageModel + + cls.loader.update_registry((TestImageModel, TestRelatedImageModel)) + + cls.image_w = cls._create_image(4000, 2000) + cls.image_h = cls._create_image(2000, 4000) + + cls.create_content = cls.image_w + cls.write_content = cls.image_h + cls.tmpfile_path = tempfile.mkstemp(suffix=".png")[1] + with open(cls.tmpfile_path, "wb") as f: + f.write(cls.create_content) + cls.filename = os.path.basename(cls.tmpfile_path) + + 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() + + @classmethod + def _create_image(cls, width, height, color="#4169E1", img_format="PNG"): + f = io.BytesIO() + Image.new("RGB", (width, height), color).save(f, img_format) + f.seek(0) + return f.read() + + def _test_create(self, fs_image_value): + model = self.env["test.image.model"] + instance = model.create({"fs_image": fs_image_value}) + self.assertTrue(isinstance(instance.fs_image, FSImageValue)) + self.assertEqual(instance.fs_image.getvalue(), self.create_content) + self.assertEqual(instance.fs_image.name, self.filename) + return instance + + def _test_write(self, fs_image_value, **ctx): + instance = self.env["test.image.model"].create({}) + if ctx: + instance = instance.with_context(**ctx) + instance.fs_image = fs_image_value + self.assertEqual(instance.fs_image.getvalue(), self.write_content) + self.assertEqual(instance.fs_image.name, self.filename) + return instance + + def assert_image_size(self, value: bytes, width, height): + self.assertEqual(Image.open(io.BytesIO(value)).size, (width, height)) + + def test_read(self): + instance = self.env["test.image.model"].create( + {"fs_image": FSImageValue(name=self.filename, value=self.create_content)} + ) + info = instance.read(["fs_image"])[0] + self.assertDictEqual( + info["fs_image"], + { + "alt_text": None, + "filename": self.filename, + "mimetype": "image/png", + "size": len(self.create_content), + "url": instance.fs_image.internal_url, + }, + ) + + def test_create_with_FsImagebytesio(self): + self._test_create(FSImageValue(name=self.filename, value=self.create_content)) + + def test_create_with_dict(self): + instance = self._test_create( + { + "filename": self.filename, + "content": base64.b64encode(self.create_content), + "alt_text": "test", + } + ) + self.assertEqual(instance.fs_image.alt_text, "test") + + def test_write_with_dict(self): + instance = self._test_write( + { + "filename": self.filename, + "content": base64.b64encode(self.write_content), + "alt_text": "test_bis", + } + ) + self.assertEqual(instance.fs_image.alt_text, "test_bis") + + 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.image.model"].create( + {"fs_image": base64.b64encode(self.create_content)} + ) + self.assertTrue(isinstance(instance.fs_image, FSImageValue)) + self.assertEqual(instance.fs_image.getvalue(), self.create_content) + + def test_write_in_b64(self): + instance = self.env["test.image.model"].create({"fs_image": b"test"}) + instance.write({"fs_image": base64.b64encode(self.create_content)}) + self.assertTrue(isinstance(instance.fs_image, FSImageValue)) + self.assertEqual(instance.fs_image.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.image.model"].create( + {"fs_image": io.BytesIO(self.create_content)} + ) + self.assertTrue(isinstance(instance.fs_image, FSImageValue)) + self.assertEqual(instance.fs_image.getvalue(), self.create_content) + + def test_write_with_io(self): + instance = self.env["test.image.model"].create( + {"fs_image": io.BytesIO(self.create_content)} + ) + instance.write({"fs_image": io.BytesIO(b"test3")}) + self.assertTrue(isinstance(instance.fs_image, FSImageValue)) + self.assertEqual(instance.fs_image.getvalue(), b"test3") + + def test_modify_FsImagebytesio(self): + """If you modify the content of the FSImageValue, + the changes will be directly applied + and a new file in the storage must be created for the new content. + """ + instance = self.env["test.image.model"].create( + {"fs_image": FSImageValue(name=self.filename, value=self.create_content)} + ) + initial_store_fname = instance.fs_image.attachment.store_fname + with instance.fs_image.open(mode="wb") as f: + f.write(b"new_content") + self.assertNotEqual( + instance.fs_image.attachment.store_fname, initial_store_fname + ) + self.assertEqual(instance.fs_image.getvalue(), b"new_content") + + def test_image_resize(self): + instance = self.env["test.image.model"].create( + {"fs_image_1024": FSImageValue(name=self.filename, value=self.image_w)} + ) + # the image is resized to 1024x512 even if the field is 1024x1024 since + # we keep the ratio + self.assert_image_size(instance.fs_image_1024.getvalue(), 1024, 512) + + def test_image_resize_related(self): + instance = self.env["test.related.image.model"].create( + {"fs_image": FSImageValue(name=self.filename, value=self.image_w)} + ) + self.assert_image_size(instance.fs_image.getvalue(), 4000, 2000) + self.assert_image_size(instance.fs_image_1024.getvalue(), 1024, 512) + self.assert_image_size(instance.fs_image_512.getvalue(), 512, 256) + + def test_related_with_b64(self): + instance = self.env["test.related.image.model"].create( + {"fs_image": base64.b64encode(self.create_content)} + ) + self.assert_image_size(instance.fs_image.getvalue(), 4000, 2000) + self.assert_image_size(instance.fs_image_1024.getvalue(), 1024, 512) + self.assert_image_size(instance.fs_image_512.getvalue(), 512, 256) + + def test_write_alt_text(self): + instance = self.env["test.image.model"].create( + {"fs_image": FSImageValue(name=self.filename, value=self.image_w)} + ) + instance.fs_image.alt_text = "test" + self.assertEqual(instance.fs_image.alt_text, "test") + + def test_write_alt_text_with_dict(self): + instance = self.env["test.image.model"].create( + {"fs_image": FSImageValue(name=self.filename, value=self.image_w)} + ) + instance.write({"fs_image": {"alt_text": "test"}}) + self.assertEqual(instance.fs_image.alt_text, "test") + + def test_write_alt_text_on_empty_with_dict(self): + instance = self.env["test.image.model"].create({}) + with self.assertRaisesRegex(UserError, "Cannot set alt_text on empty image"): + instance.write({"fs_image": {"alt_text": "test"}}) diff --git a/setup/fs_image/odoo/addons/fs_image b/setup/fs_image/odoo/addons/fs_image new file mode 120000 index 0000000000..4bc86927ec --- /dev/null +++ b/setup/fs_image/odoo/addons/fs_image @@ -0,0 +1 @@ +../../../../fs_image \ No newline at end of file diff --git a/setup/fs_image/setup.py b/setup/fs_image/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/fs_image/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)