diff --git a/fs_image/README.rst b/fs_image/README.rst new file mode 100644 index 0000000000..7e4eb0ed09 --- /dev/null +++ b/fs_image/README.rst @@ -0,0 +1,268 @@ +======== +Fs Image +======== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:83bf3d081cee121382989912937b99966b63f14d37195a0258d4221c3bb34ee3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_image + :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_image + :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 **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. + +.. 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 +===== + +This new field type can be used in the same way as the odoo 'Image' +field type. + +.. code:: 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:: 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:: 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:: 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:: python + + from odoo import models + + class MyModel(models.Model): + _name = 'my.model' + _inherit = ['fs_image.mixin'] + +.. code:: xml + + + my.model.form + my.model + +
+ + + + + +
+
+
+ +Changelog +========= + +16.0.1.0.3 (2024-02-23) +----------------------- + +**Bugfixes** + +- (`#305 `__) + +16.0.1.0.2 (2023-12-02) +----------------------- + +**Bugfixes** + +- Fix view crash when uploading an image + + The rawCacheKey is appropriately managed by the base class and + reflects the record's last update datetime (write_date). Since it + lacks a setter, attempting to invalidate its value results in a view + crash. Nevertheless, the value will automatically be updated upon + saving the record. + (`#305 `__) + +16.0.1.0.1 (2023-12-02) +----------------------- + +**Bugfixes** + +- Avoid to generate an SQL update query when an image field is read. + + Fix a bug in the initialization of the image field value object when + the field is read. Before this fix, every time the value object was + initialized with an attachment, an assignment of the alt text was done + into the constructor. This assignment triggered the mark of the field + as modified and an SQL update query was generated at the end of the + request. The alt text in the constructor of the FSImageValue class + must only be used when the class is initialized without an attachment. + We now check if an attachment and an alt text are provided at the same + time and throw an exception if this is the case. + (`#307 `__) + +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 +- 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_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..566b5a7649 --- /dev/null +++ b/fs_image/__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 Image", + "summary": """ + Field to store images 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_file", "web"], + "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..dd6c98ecfb --- /dev/null +++ b/fs_image/fields.py @@ -0,0 +1,228 @@ +# 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.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) + if self._attachment and alt_text is not None: + raise ValueError( + "FSImageValue cannot be initialized with an attachment and an" + " alt_text at the same time. When initializing with an attachment," + " you can't pass any other argument." + ) + 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 + 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, + ) + + 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 + if cache_value.alt_text: + 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, env): + """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, env) + 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( + record.env._( + "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/i18n/es.po b/fs_image/i18n/es.po new file mode 100644 index 0000000000..51fbe0162f --- /dev/null +++ b/fs_image/i18n/es.po @@ -0,0 +1,117 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_image +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-10-29 00:15+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_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.esm.js:0 +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Alt Text" +msgstr "Texto Alt" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_ir_attachment__alt_text +#: model:ir.model.fields,field_description:fs_image.field_product_document__alt_text +msgid "Alternative Text" +msgstr "Texto Alternativo" + +#. module: fs_image +#: model:ir.model.fields,help:fs_image.field_ir_attachment__alt_text +#: model:ir.model.fields,help:fs_image.field_product_document__alt_text +msgid "Alternative text for the image. Only used for images on a website." +msgstr "" +"Texto alternativo para la imagen. Solo se utiliza para imágenes de un sitio " +"web." + +#. module: fs_image +#: model:ir.model,name:fs_image.model_ir_attachment +msgid "Attachment" +msgstr "Archivo Adjunto" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Binary file" +msgstr "Archivo binario" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/dialogs/alttext_dialog.xml:0 +#, python-format +msgid "Cancel" +msgstr "Cancelar" + +#. module: fs_image +#. odoo-python +#: code:addons/fs_image/fields.py:0 +#, python-format +msgid "Cannot set alt_text on empty image (record %(record)s.%(field_name)s)" +msgstr "" +"No se puede establecer alt_text en una imagen vacía (record %(record)s." +"%(field_name)s)" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Clear" +msgstr "Limpiar" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Download" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Edit" +msgstr "Editar" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_fs_image_mixin__image +msgid "Image" +msgstr "Imagen" + +#. module: fs_image +#: model:ir.model,name:fs_image.model_fs_image_mixin +msgid "Image Mixin" +msgstr "Mezcla de Imágenes" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_fs_image_mixin__image_medium +msgid "Image medium" +msgstr "Imagen mediana" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/dialogs/alttext_dialog.xml:0 +#, python-format +msgid "Save changes" +msgstr "Guardar cambios" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Set Alt Text" +msgstr "Establecer Texto Alt" diff --git a/fs_image/i18n/fr.po b/fs_image/i18n/fr.po new file mode 100644 index 0000000000..8b29ab3e90 --- /dev/null +++ b/fs_image/i18n/fr.po @@ -0,0 +1,117 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_image +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-02-15 17:37+0000\n" +"Last-Translator: \"Benjamin Willig (ACSONE)\" \n" +"Language-Team: none\n" +"Language: fr\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_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.esm.js:0 +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Alt Text" +msgstr "" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_ir_attachment__alt_text +#: model:ir.model.fields,field_description:fs_image.field_product_document__alt_text +msgid "Alternative Text" +msgstr "Texte alternatif" + +#. module: fs_image +#: model:ir.model.fields,help:fs_image.field_ir_attachment__alt_text +#: model:ir.model.fields,help:fs_image.field_product_document__alt_text +msgid "Alternative text for the image. Only used for images on a website." +msgstr "" +"Texte alternatif pour une image. Utilisé seulement pour les images sur un " +"site web." + +#. module: fs_image +#: model:ir.model,name:fs_image.model_ir_attachment +msgid "Attachment" +msgstr "Pièce jointe" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Binary file" +msgstr "Fichier binaire" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/dialogs/alttext_dialog.xml:0 +#, python-format +msgid "Cancel" +msgstr "Annuler" + +#. module: fs_image +#. odoo-python +#: code:addons/fs_image/fields.py:0 +#, python-format +msgid "Cannot set alt_text on empty image (record %(record)s.%(field_name)s)" +msgstr "" +"Impossible d'appliquer un texte alternatif sur une image vide " +"(enregistrement %(record)s.%(field_name)s)" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Clear" +msgstr "Effacer" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Download" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Edit" +msgstr "Modifier" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_fs_image_mixin__image +msgid "Image" +msgstr "Image" + +#. module: fs_image +#: model:ir.model,name:fs_image.model_fs_image_mixin +msgid "Image Mixin" +msgstr "" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_fs_image_mixin__image_medium +msgid "Image medium" +msgstr "Image moyenne" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/dialogs/alttext_dialog.xml:0 +#, python-format +msgid "Save changes" +msgstr "Sauvegarder les changements" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Set Alt Text" +msgstr "" diff --git a/fs_image/i18n/fs_image.pot b/fs_image/i18n/fs_image.pot new file mode 100644 index 0000000000..e34bae0cdb --- /dev/null +++ b/fs_image/i18n/fs_image.pot @@ -0,0 +1,113 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_image +# +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_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.esm.js:0 +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Alt Text" +msgstr "" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_ir_attachment__alt_text +#: model:ir.model.fields,field_description:fs_image.field_product_document__alt_text +msgid "Alternative Text" +msgstr "" + +#. module: fs_image +#: model:ir.model.fields,help:fs_image.field_ir_attachment__alt_text +#: model:ir.model.fields,help:fs_image.field_product_document__alt_text +msgid "Alternative text for the image. Only used for images on a website." +msgstr "" + +#. module: fs_image +#: model:ir.model,name:fs_image.model_ir_attachment +msgid "Attachment" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Binary file" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/dialogs/alttext_dialog.xml:0 +#, python-format +msgid "Cancel" +msgstr "" + +#. module: fs_image +#. odoo-python +#: code:addons/fs_image/fields.py:0 +#, python-format +msgid "Cannot set alt_text on empty image (record %(record)s.%(field_name)s)" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Clear" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Download" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Edit" +msgstr "" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_fs_image_mixin__image +msgid "Image" +msgstr "" + +#. module: fs_image +#: model:ir.model,name:fs_image.model_fs_image_mixin +msgid "Image Mixin" +msgstr "" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_fs_image_mixin__image_medium +msgid "Image medium" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/dialogs/alttext_dialog.xml:0 +#, python-format +msgid "Save changes" +msgstr "" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Set Alt Text" +msgstr "" diff --git a/fs_image/i18n/it.po b/fs_image/i18n/it.po new file mode 100644 index 0000000000..7ffaca24c4 --- /dev/null +++ b/fs_image/i18n/it.po @@ -0,0 +1,117 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_image +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-02-12 16:06+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 5.6.2\n" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.esm.js:0 +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Alt Text" +msgstr "Testo alternativo" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_ir_attachment__alt_text +#: model:ir.model.fields,field_description:fs_image.field_product_document__alt_text +msgid "Alternative Text" +msgstr "Testo alternativo" + +#. module: fs_image +#: model:ir.model.fields,help:fs_image.field_ir_attachment__alt_text +#: model:ir.model.fields,help:fs_image.field_product_document__alt_text +msgid "Alternative text for the image. Only used for images on a website." +msgstr "" +"Testo alternativo per l'immagine. Utilizzato solo per le immagini nel sito " +"web." + +#. module: fs_image +#: model:ir.model,name:fs_image.model_ir_attachment +msgid "Attachment" +msgstr "Allegato" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Binary file" +msgstr "file binario" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/dialogs/alttext_dialog.xml:0 +#, python-format +msgid "Cancel" +msgstr "Annulla" + +#. module: fs_image +#. odoo-python +#: code:addons/fs_image/fields.py:0 +#, python-format +msgid "Cannot set alt_text on empty image (record %(record)s.%(field_name)s)" +msgstr "" +"Non si può impostare il campo alt_text nelle immagini vuote (record " +"%(record)s.%(field_name)s)" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Clear" +msgstr "Pulisci" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Download" +msgstr "Scarica" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Edit" +msgstr "Modifica" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_fs_image_mixin__image +msgid "Image" +msgstr "Immagine" + +#. module: fs_image +#: model:ir.model,name:fs_image.model_fs_image_mixin +msgid "Image Mixin" +msgstr "Mixin immagine" + +#. module: fs_image +#: model:ir.model.fields,field_description:fs_image.field_fs_image_mixin__image_medium +msgid "Image medium" +msgstr "Immagine media" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/dialogs/alttext_dialog.xml:0 +#, python-format +msgid "Save changes" +msgstr "Salva modifiche" + +#. module: fs_image +#. odoo-javascript +#: code:addons/fs_image/static/src/views/fields/fsimage_field.xml:0 +#, python-format +msgid "Set Alt Text" +msgstr "Imposta testo alternativo" 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..0ce5afea7f --- /dev/null +++ b/fs_image/models/ir_attachment.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 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/pyproject.toml b/fs_image/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/fs_image/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fs_image/readme/CONTRIBUTORS.md b/fs_image/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..829016a80c --- /dev/null +++ b/fs_image/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Laurent Mignon \<\> +- Nguyen Minh Chien \<\> +- Denis Roussel \<\> diff --git a/fs_image/readme/DESCRIPTION.md b/fs_image/readme/DESCRIPTION.md new file mode 100644 index 0000000000..8214c3fa44 --- /dev/null +++ b/fs_image/readme/DESCRIPTION.md @@ -0,0 +1,19 @@ +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/HISTORY.md b/fs_image/readme/HISTORY.md new file mode 100644 index 0000000000..784ed81ecf --- /dev/null +++ b/fs_image/readme/HISTORY.md @@ -0,0 +1,35 @@ +## 16.0.1.0.3 (2024-02-23) + +**Bugfixes** + +- ([\#305](https://github.com/OCA/storage/issues/305)) + +## 16.0.1.0.2 (2023-12-02) + +**Bugfixes** + +- Fix view crash when uploading an image + + The rawCacheKey is appropriately managed by the base class and + reflects the record's last update datetime (write_date). Since it + lacks a setter, attempting to invalidate its value results in a view + crash. Nevertheless, the value will automatically be updated upon + saving the record. + ([\#305](https://github.com/OCA/storage/issues/305)) + +## 16.0.1.0.1 (2023-12-02) + +**Bugfixes** + +- Avoid to generate an SQL update query when an image field is read. + + Fix a bug in the initialization of the image field value object when + the field is read. Before this fix, every time the value object was + initialized with an attachment, an assignment of the alt text was done + into the constructor. This assignment triggered the mark of the field + as modified and an SQL update query was generated at the end of the + request. The alt text in the constructor of the FSImageValue class + must only be used when the class is initialized without an attachment. + We now check if an attachment and an alt text are provided at the same + time and throw an exception if this is the case. + ([\#307](https://github.com/OCA/storage/issues/307)) diff --git a/fs_image/readme/USAGE.md b/fs_image/readme/USAGE.md new file mode 100644 index 0000000000..abfd872a43 --- /dev/null +++ b/fs_image/readme/USAGE.md @@ -0,0 +1,113 @@ +This new field type can be used in the same way as the odoo 'Image' +field type. + +``` 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) +``` + +``` 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: + +``` 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) +``` + +``` 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. + +``` python +from odoo import models + +class MyModel(models.Model): + _name = 'my.model' + _inherit = ['fs_image.mixin'] +``` + +``` xml + + my.model.form + my.model + +
+ + + + + +
+
+
+``` diff --git a/fs_image/readme/newsfragments/.gitignore b/fs_image/readme/newsfragments/.gitignore new file mode 100644 index 0000000000..e69de29bb2 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/description/index.html b/fs_image/static/description/index.html new file mode 100644 index 0000000000..88a0eb394c --- /dev/null +++ b/fs_image/static/description/index.html @@ -0,0 +1,606 @@ + + + + + +Fs Image + + + +
+

Fs Image

+ + +

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

+

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.

+
+

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

+

This new field type can be used in the same way as the odoo ‘Image’ +field type.

+
+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)
+
+
+<record id="my_model_form" model="ir.ui.view">
+    <field name="name">my.model.form</field>
+    <field name="model">my.model</field>
+    <field name="arch" type="xml">
+        <form>
+            <sheet>
+                <group>
+                    <field name="image" class="oe_avatar"/>
+                </group>
+            </sheet>
+        </form>
+    </field>
+</record>
+
+

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:

+
+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)
+
+
+<record id="my_model_form" model="ir.ui.view">
+    <field name="name">my.model.form</field>
+    <field name="model">my.model</field>
+    <field name="arch" type="xml">
+        <form>
+            <sheet>
+                <group>
+                    <field
+                        name="image_1920"
+                        class="oe_avatar"
+                         options="{'preview_image': 'image_128', 'zoom': true}"
+                     />
+                </group>
+            </sheet>
+        </form>
+    </field>
+</record>
+
+

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.

+
+from odoo import models
+
+class MyModel(models.Model):
+    _name = 'my.model'
+    _inherit = ['fs_image.mixin']
+
+
+<record id="my_model_form" model="ir.ui.view">
+    <field name="name">my.model.form</field>
+    <field name="model">my.model</field>
+    <field name="arch" type="xml">
+        <form>
+            <sheet>
+                <group>
+                    <field
+                        name="image"
+                        class="oe_avatar"
+                        options="{'preview_image': 'image_medium', 'zoom': true}"
+                    />
+                </group>
+            </sheet>
+        </form>
+    </field>
+</record>
+
+
+
+

Changelog

+
+

16.0.1.0.3 (2024-02-23)

+

Bugfixes

+ +
+
+

16.0.1.0.2 (2023-12-02)

+

Bugfixes

+
    +
  • Fix view crash when uploading an image

    +

    The rawCacheKey is appropriately managed by the base class and +reflects the record’s last update datetime (write_date). Since it +lacks a setter, attempting to invalidate its value results in a view +crash. Nevertheless, the value will automatically be updated upon +saving the record. +(#305)

    +
  • +
+
+
+

16.0.1.0.1 (2023-12-02)

+

Bugfixes

+
    +
  • Avoid to generate an SQL update query when an image field is read.

    +

    Fix a bug in the initialization of the image field value object when +the field is read. Before this fix, every time the value object was +initialized with an attachment, an assignment of the alt text was done +into the constructor. This assignment triggered the mark of the field +as modified and an SQL update query was generated at the end of the +request. The alt text in the constructor of the FSImageValue class +must only be used when the class is initialized without an attachment. +We now check if an attachment and an alt text are provided at the same +time and throw an exception if this is the case. +(#307)

    +
  • +
+
+
+
+

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_image/static/src/scss/fsimage_field.scss b/fs_image/static/src/scss/fsimage_field.scss new file mode 100644 index 0000000000..053fd35479 --- /dev/null +++ b/fs_image/static/src/scss/fsimage_field.scss @@ -0,0 +1,5 @@ +.fs_file_download_button { + top: 10% !important; + left: 50% !important; + position: absolute !important; +} 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..ecdc476de3 --- /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..250fbd3066 --- /dev/null +++ b/fs_image/static/src/views/fields/fsimage_field.esm.js @@ -0,0 +1,113 @@ +/** @odoo-module */ + +/** + * Copyright 2023 ACSONE SA/NV + */ +import { + ImageField, + fileTypeMagicWordMap, + imageField, +} from "@web/views/fields/image/image_field"; +import {AltTextDialog} from "../dialogs/alttext_dialog.esm"; +import {_t} from "@web/core/l10n/translation"; +import {download, downloadFile} from "@web/core/network/download"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +import {url as utilUrl} from "@web/core/utils/urls"; + +const placeholder = "/web/static/img/placeholder.png"; + +export class FSImageField extends ImageField { + setup() { + // Call super.setup() to initialize the state + super.setup(...arguments); + this.dialogService = useService("dialog"); + } + + getUrl(previewFieldName) { + if ( + this.state.isValid && + this.props.record.data[this.props.name] && + typeof this.props.record.data[this.props.name] === "object" + ) { + // Check if value is a dict + if (this.props.record.data[this.props.name].content) { + // We use the binary content of the value + // Use magic-word technique for detecting image type + const magic = + fileTypeMagicWordMap[ + this.props.record.data[this.props.name].content[0] + ] || "png"; + return `data:image/${magic};base64,${ + this.props.record.data[this.props.name].content + }`; + } + const model = this.props.record.resModel; + const id = this.props.record.resId; + let base_url = this.props.record.data[this.props.name].url; + if (id !== undefined && id !== null && id !== false) { + const field = previewFieldName; + const filename = this.props.record.data[this.props.name].filename; + base_url = `/web/image/${model}/${id}/${field}/${filename}`; + } + return utilUrl(base_url, {unique: this.rawCacheKey}); + } + return placeholder; + } + + async onFileUploaded(info) { + this.props.record.update({ + [this.props.name]: { + filename: info.name, + content: info.data, + }, + }); + } + onAltTextEdit() { + const self = this; + const altText = this.props.record.data[this.props.name].alt_text || ""; + const dialogProps = { + title: _t("Alt Text"), + altText: altText, + confirm: (value) => { + self.props.record.update({ + [self.props.name]: { + ...this.props.record.data[this.props.name], + alt_text: value, + }, + }); + }, + }; + this.dialogService.add(AltTextDialog, dialogProps); + } + async onFileDownload() { + if (this.props.value.content) { + const magic = fileTypeMagicWordMap[this.props.value.content[0]] || "png"; + await downloadFile( + `data:image/${magic};base64,${this.props.value.content}`, + this.state.filename, + `image/${magic}` + ); + } else { + await download({ + data: { + model: this.props.record.resModel, + id: this.props.record.resId, + field: this.props.name, + filename: this.state.filename || "download", + download: true, + }, + url: "/web/image", + }); + } + } +} + +FSImageField.template = "fs_image.FSImageField"; + +export const fSImageField = { + ...imageField, + component: 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..5edd3cd01c --- /dev/null +++ b/fs_image/static/src/views/fields/fsimage_field.xml @@ -0,0 +1,74 @@ + + + + +
+
+ + + + + + + + + + + +
+ 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..7efda51d17 --- /dev/null +++ b/fs_image/tests/models.py @@ -0,0 +1,30 @@ +# 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..fb9a9b8f64 --- /dev/null +++ b/fs_image/tests/test_fs_image.py @@ -0,0 +1,239 @@ +# 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, users, warmup + +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"}}) + + @users("__system__") + @warmup + def test_generated_sql_commands(self): + # The following tests will never fail, but they will output a warning + # if the number of SQL queries changes into the logs. They + # are to help us keep track of the number of SQL queries generated + # by the module. + with self.assertQueryCount(__system__=3): + instance = self.env["test.image.model"].create( + {"fs_image": FSImageValue(name=self.filename, value=self.image_w)} + ) + + instance.invalidate_recordset() + with self.assertQueryCount(__system__=1): + self.assertEqual(instance.fs_image.getvalue(), self.image_w) + self.env.flush_all() diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..6a58871771 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +odoo_test_helper + +odoo-addon-fs-file @ git+https://github.com/OCA/storage.git@refs/pull/445/head#subdirectory=fs_file