diff --git a/fs_image_thumbnail/README.rst b/fs_image_thumbnail/README.rst new file mode 100644 index 0000000000..38929e8775 --- /dev/null +++ b/fs_image_thumbnail/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_thumbnail/__init__.py b/fs_image_thumbnail/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/fs_image_thumbnail/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fs_image_thumbnail/__manifest__.py b/fs_image_thumbnail/__manifest__.py new file mode 100644 index 0000000000..ac56be61ba --- /dev/null +++ b/fs_image_thumbnail/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Fs Image Thumbnail", + "summary": """ + Generate and store thumbnail for images""", + "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_image", "base_partition"], + "data": [ + "views/ir_attachment.xml", + "security/fs_thumbnail.xml", + "views/fs_image_thumbnail_mixin.xml", + "views/fs_thumbnail.xml", + ], + "demo": [], + "maintainers": ["lmignon"], + "development_status": "Alpha", + "external_dependencies": {"python": ["python_slugify"]}, +} diff --git a/fs_image_thumbnail/models/__init__.py b/fs_image_thumbnail/models/__init__.py new file mode 100644 index 0000000000..0ef2d28a1d --- /dev/null +++ b/fs_image_thumbnail/models/__init__.py @@ -0,0 +1,3 @@ +from . import fs_image_thumbnail_mixin +from . import fs_thumbnail +from . import ir_attachment diff --git a/fs_image_thumbnail/models/fs_image_thumbnail_mixin.py b/fs_image_thumbnail/models/fs_image_thumbnail_mixin.py new file mode 100644 index 0000000000..5a42730e2c --- /dev/null +++ b/fs_image_thumbnail/models/fs_image_thumbnail_mixin.py @@ -0,0 +1,238 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from slugify import slugify + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.fs_image.fields import FSImage, FSImageValue + + +class FsImageThumbnailMixin(models.AbstractModel): + """Mixin defining what is a thumbnail image and providing a + method to generate a thumbnail image from an image. + + """ + + _name = "fs.image.thumbnail.mixin" + _description = "Fs Image Thumbnail Mixin" + + image = FSImage("Image", required=True) + original_image = FSImage("Original Image", compute="_compute_original_image") + size_x = fields.Integer("X size", required=True) + size_y = fields.Integer("Y size", required=True) + base_name = fields.Char( + "The base name of the thumbnail image (without extension)", + required=True, + help="The thumbnail image will be named as base_name " + "+ _ + size_x + _ + size_y + . + extension.\n" + "If not set, the base name will be the name of the original image." + "This base name is used to find all existing thumbnail of an image generated " + "for the same base name.", + ) + + attachment_id = fields.Many2one( + comodel_name="ir.attachment", + string="Attachment", + help="Attachment containing the original image", + required=True, + ) + name = fields.Char( + compute="_compute_name", + store=True, + ) + mimetype = fields.Char( + compute="_compute_mimetype", + store=True, + ) + + @api.depends("image") + def _compute_name(self): + for record in self: + record.name = record.image.name if record.image else None + + @api.depends("image") + def _compute_mimetype(self): + for record in self: + record.mimetype = record.image.mimetype if record.image else None + + @api.depends("attachment_id") + def _compute_original_image(self): + original_image_field = self._fields["original_image"] + for record in self: + value = None + if record.attachment_id: + value = original_image_field._convert_attachment_to_cache( + record.attachment_id + ) + record.original_image = value + + @api.model + def _resize(self, image: FSImage, size_x: int, size_y: int, fmt: str = "") -> bytes: + """Resize the given image to the given size. + + :param image: the image to resize + :param size_x: the new width of the image + :param size_y: the new height of the image + :param fmt: the output format of the image. 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 resized image + """ + # image_process only accept PNG, JPEG, GIF, or ICO as output format + # in uppercase. Remove the dot if present and convert to uppercase. + fmt = fmt.upper().replace(".", "") + return image.image_process(size=(size_x, size_y), output_format=fmt) + + @api.model + def _get_resize_format(self, image: FSImage) -> str: + """Get the format to use to resize an image. + + :return: the format to use to resize an image + """ + fmt = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("fs_image_thumbnail.resize_format") + ) + return fmt or image.extension + + @api.model + def _prepare_tumbnail( + self, image: FSImage, size_x: int, size_y: int, base_name: str + ) -> dict: + """Prepare the values to create a thumbnail image from the given image. + + :param image: the image to resize + :param size_x: the new width of the image + :param size_y: the new height of the image + :param base_name: the base name of the thumbnail image (without extension) + :return: the values to create a thumbnail image + """ + fmt = self._get_resize_format(image) + extension = fmt + # Add a dot before the extension if needed and convert to lowercase. + extension = extension.lower() + if extension and not extension.startswith("."): + extension = "." + extension + new_image = FSImageValue( + value=self._resize(image, size_x, size_y, fmt), + name="%s_%s_%s%s" % (base_name, size_x, size_y, extension), + alt_text=image.alt_text, + ) + return { + "image": new_image, + "size_x": size_x, + "size_y": size_y, + "base_name": base_name, + "attachment_id": image.attachment.id, + } + + @api.model + def _slugify_base_name(self, base_name: str) -> str: + """Slugify the given base name. + + :param base_name: the base name to slugify + :return: the slugified base name + """ + return slugify(base_name) if base_name else base_name + + @api.model + def _get_existing_thumbnail_domain( + self, *images: tuple[FSImageValue], base_name: str = "" + ) -> list: + """Get the domain to find existing thumbnail images from the given image. + + :param images: a list of images we want to find existing thumbnails + :param base_name: the base name of the thumbnail image (without extension) + The base name must be set when multiple images are given. + :return: the domain to find existing thumbnail images + """ + attachment_ids = [] + for image in images: + if image.attachment: + attachment_ids.append(image.attachment.id) + else: + raise UserError( + _( + "The image %(name)s must be attached to an attachment", + name=image.name, + ) + ) + base_name = self._get_slugified_base_name(*images, base_name=base_name) + return [ + ("attachment_id", "in", attachment_ids), + ("base_name", "=", base_name), + ] + + @api.model + def get_thumbnails( + self, *images: tuple[FSImageValue], base_name: str = "" + ) -> list["FsImageThumbnailMixin"]: + """Get existing thumbnail images from the given image. + + :param images: a list of images we want to find existing thumbnails + :param base_name: the base name of the thumbnail image (without extension) + The base name must be set when multiple images are given. + :return: a recordset of thumbnail images + """ + domain = self._get_existing_thumbnail_domain(*images, base_name=base_name) + return self.search(domain) + + @api.model + def get_or_create_thumbnails( + self, + *images: tuple[FSImageValue], + sizes: list[tuple[int, int]], + base_name: str = "" + ) -> list["FsImageThumbnailMixin"]: + """Get or create a thumbnail images from the given image. + + :param images: the list of images we want to get or create thumbnails + :param sizes: the list of sizes to use to resize the image + (list of tuple (size_x, size_y)) + :param base_name: the base name of the thumbnail image (without extension) + The base name must be set when multiple images are given. + :return: a dictionary where the key is the original image and the value is + a recordset of thumbnail images + """ + base_name = self._get_slugified_base_name(*images, base_name=base_name) + thumbnails = self.get_thumbnails(*images, base_name=base_name) + thumbnails_by_attachment_id = thumbnails.partition("attachment_id") + ret = {} + for image in images: + thumbnails_by_size = { + (thumbnail.size_x, thumbnail.size_y): thumbnail + for thumbnail in thumbnails_by_attachment_id.get(image.attachment, []) + } + ids_to_return = [] + for size_x, size_y in sizes: + thumbnail = thumbnails_by_size.get((size_x, size_y)) + if not thumbnail: + values = self._prepare_tumbnail(image, size_x, size_y, base_name) + # no creation possible outside of this method -> sudo() is + # required since no access rights defined on create + thumbnail = self.sudo().create(values) + ids_to_return.append(thumbnail.id) + # return the thumbnails browsed in the same security context as the method + # caller + ret[image] = self.browse(ids_to_return) + return ret + + @api.model + def _get_slugified_base_name( + self, *images: tuple[FSImageValue], base_name: str + ) -> str: + """Get the base name of the thumbnail image (without extension). + + :param images: the list of images we want to get the base name + :return: the base name of the thumbnail image + """ + if not base_name: + if len(images) > 1: + raise UserError( + _("The base name must be set when multiple images are given") + ) + base_name = images[0].name + return self._slugify_base_name(base_name) diff --git a/fs_image_thumbnail/models/fs_thumbnail.py b/fs_image_thumbnail/models/fs_thumbnail.py new file mode 100644 index 0000000000..bf2df3d097 --- /dev/null +++ b/fs_image_thumbnail/models/fs_thumbnail.py @@ -0,0 +1,11 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class FsThumbnail(models.Model): + + _name = "fs.thumbnail" + _inherit = "fs.image.thumbnail.mixin" + _description = "Image Thumbnail" diff --git a/fs_image_thumbnail/models/ir_attachment.py b/fs_image_thumbnail/models/ir_attachment.py new file mode 100644 index 0000000000..23116ecb9b --- /dev/null +++ b/fs_image_thumbnail/models/ir_attachment.py @@ -0,0 +1,16 @@ +# 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" + + thumbnail_ids = fields.One2many( + comodel_name="fs.thumbnail", + inverse_name="attachment_id", + string="Thumbnails", + readonly=True, + ) diff --git a/fs_image_thumbnail/readme/CONTEXT.rst b/fs_image_thumbnail/readme/CONTEXT.rst new file mode 100644 index 0000000000..4c7e0559ac --- /dev/null +++ b/fs_image_thumbnail/readme/CONTEXT.rst @@ -0,0 +1,17 @@ +In some specific cases you may need to generate and store thumbnails of images in Odoo. +This is the case for example when you want to provide image in specific sizes for a website +or a mobile application. + +This module provides a generic way to generate thumbnails of images and store them in a +specific filesystem storage. Indeed, you could need to store the thumbnails in a different +storage than the original image (eg: store the thumbnails in a CDN) to make sure the +thumbnails are served quickly when requested by an external application and to +avoid to expose the original image storage. + +This module uses the `fs_image `_ +module to store the thumbnails in a filesystem storage. + +The `shopinvader_product_image `_ addon uses this module to generate and +store the thumbnails of the images of the products and categories to be accessible +by the website. diff --git a/fs_image_thumbnail/readme/CONTRIBUTORS.rst b/fs_image_thumbnail/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..1480ca2b76 --- /dev/null +++ b/fs_image_thumbnail/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Laurent Mignon (https://acsone.eu) diff --git a/fs_image_thumbnail/readme/CREDITS.rst b/fs_image_thumbnail/readme/CREDITS.rst new file mode 100644 index 0000000000..82c081d258 --- /dev/null +++ b/fs_image_thumbnail/readme/CREDITS.rst @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +* `Alcyon Belux `_ diff --git a/fs_image_thumbnail/readme/DESCRIPTION.rst b/fs_image_thumbnail/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..6ce408a456 --- /dev/null +++ b/fs_image_thumbnail/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +This module extends the **fs_image** addon to support the creation and the storage of +thumbnails for images. This module is a **technical module** and is not +meant to be installed by end-users. It only provides a mixin to be used +by other modules and a model to store the thumbnails. diff --git a/fs_image_thumbnail/readme/USAGE.rst b/fs_image_thumbnail/readme/USAGE.rst new file mode 100644 index 0000000000..4a539166fe --- /dev/null +++ b/fs_image_thumbnail/readme/USAGE.rst @@ -0,0 +1,57 @@ +This addon provides a convenient way to get and create if not exists image +thumbnails. All the logic is implemented by the abstract model +`fs.image.thumbnail.mixin`. The main method is `get_or_create_thumbnails` which +accepts a *FSImageValue* instance, a list of thumbnail sizes and a base name. + +When the method is called, it will check if the thumbnail exists for the given +sizes and base name. If not, it will create it. + +The `fs.thumbnail` model provided by this addon is a concrete implementation of +the abstract model `fs.image.thumbnail.mixin`. The motivation to implement all the +logic in an abstract model is to allow developers to create their own thumbnail +models. This could be useful if you want to store the thumbnails in a different +storage since you can specify the storage to use by model on the `fs.storage` +form view. + +Creating / retrieving thumbnails is as simple as: + +.. code-block:: python + + from odoo.addons.fs_image.fields import FSImageValue + + # create an attachment with a image file + attachment = self.env['ir.attachment'].create({ + 'name': 'test', + 'datas': base64.b64encode(open('test.png', 'rb').read()), + 'datas_fname': 'test.png', + }) + + # create a FSImageValue instance for the attachment + image_value = FSImageValue(attachment) + + # get or create the thumbnails + thumbnails = self.env['fs.thumbnail'].get_or_create_thumbnails( + image_value, [(800,600), (400, 200)], 'my base name') + + + +If you've a model with a *FSImage* field, the call to `get_or_create_thumbnails` +is even simpler: + +.. 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') + + my_record = cls.env['my.model'].create({ + 'image': open('test.png', 'rb'), + }) + + # get or create the thumbnails + thumbnails = record.image.get_or_create_thumbnails(my_record.image, + [(800,600), (400, 200)], 'my base name') diff --git a/fs_image_thumbnail/security/fs_thumbnail.xml b/fs_image_thumbnail/security/fs_thumbnail.xml new file mode 100644 index 0000000000..7690d990f4 --- /dev/null +++ b/fs_image_thumbnail/security/fs_thumbnail.xml @@ -0,0 +1,16 @@ + + + + + + fs.thumbnail access read + + + + + + + + + diff --git a/fs_image_thumbnail/static/description/icon.png b/fs_image_thumbnail/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/fs_image_thumbnail/static/description/icon.png differ diff --git a/fs_image_thumbnail/tests/__init__.py b/fs_image_thumbnail/tests/__init__.py new file mode 100644 index 0000000000..919947aec7 --- /dev/null +++ b/fs_image_thumbnail/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fs_image_thumbnail diff --git a/fs_image_thumbnail/tests/test_fs_image_thumbnail.py b/fs_image_thumbnail/tests/test_fs_image_thumbnail.py new file mode 100644 index 0000000000..3caa4b5265 --- /dev/null +++ b/fs_image_thumbnail/tests/test_fs_image_thumbnail.py @@ -0,0 +1,81 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 +import io + +from PIL import Image + +from odoo.tests.common import TransactionCase + +from odoo.addons.fs_image.fields import FSImageValue + + +class TestFsImageThumbnail(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.white_image = cls._create_image(32, 32, color="#FFFFFF") + + cls.image_attachment = cls.env["ir.attachment"].create( + { + "name": "Test Image", + "datas": base64.b64encode(cls.white_image), + "mimetype": "image/png", + } + ) + + cls.fs_image_value = FSImageValue(attachment=cls.image_attachment) + cls.fs_thumbnail_model = cls.env["fs.thumbnail"] + + def setUp(self): + super().setUp() + self.temp_dir = self.env["fs.storage"].create( + { + "name": "Temp FS Storage", + "protocol": "memory", + "code": "mem_dir", + "directory_path": "/tmp/", + "model_xmlids": "fs_image_thumbnail.model_fs_thumbnail", + } + ) + + @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 assert_image_size(self, value: bytes, width, height): + self.assertEqual(Image.open(io.BytesIO(value)).size, (width, height)) + + def test_create_multi(self): + self.assertFalse(self.image_attachment.thumbnail_ids) + thumbnails = self.fs_thumbnail_model.get_or_create_thumbnails( + self.fs_image_value, sizes=[(16, 16), (8, 8)], base_name="My super test" + )[self.fs_image_value] + self.assertEqual(len(thumbnails), 2) + self.assertEqual(thumbnails[0].name, "my-super-test_16_16.png") + self.assert_image_size(thumbnails[0].image.getvalue(), 16, 16) + self.assertEqual(thumbnails[1].name, "my-super-test_8_8.png") + self.assert_image_size(thumbnails[1].image.getvalue(), 8, 8) + + self.assertEqual(self.image_attachment.thumbnail_ids, thumbnails) + + # if we call the method again for the same size, we should get the same thumbnail + new_thumbnails = self.fs_thumbnail_model.get_or_create_thumbnails( + self.fs_image_value, sizes=[(16, 16), (8, 8)], base_name="My super test" + )[self.fs_image_value] + self.assertEqual(new_thumbnails, thumbnails) + + def test_create_with_specific_format(self): + self.env["ir.config_parameter"].set_param( + "fs_image_thumbnail.resize_format", "JPEG" + ) + thumbnail = self.fs_thumbnail_model.get_or_create_thumbnails( + self.fs_image_value, sizes=[(8, 8)], base_name="My super test" + )[self.fs_image_value] + self.assertEqual(thumbnail[0].name, "my-super-test_8_8.jpeg") + self.assertEqual(thumbnail[0].mimetype, "image/jpeg") + self.assert_image_size(thumbnail[0].image.getvalue(), 8, 8) diff --git a/fs_image_thumbnail/views/fs_image_thumbnail_mixin.xml b/fs_image_thumbnail/views/fs_image_thumbnail_mixin.xml new file mode 100644 index 0000000000..8dfac21a0f --- /dev/null +++ b/fs_image_thumbnail/views/fs_image_thumbnail_mixin.xml @@ -0,0 +1,84 @@ + + + + + + fs.image.thumbnail.mixin.form (in fs_image_thumbnail) + fs.image.thumbnail.mixin + +
+
+
+ + +
+
+
+ + + fs.image.thumbnail.mixin.search (in fs_image_thumbnail) + fs.image.thumbnail.mixin + + + + + + + + + + + + + + + fs.image.thumbnail.mixin.tree (in fs_image_thumbnail) + fs.image.thumbnail.mixin + + + + + + + + + + + +
diff --git a/fs_image_thumbnail/views/fs_thumbnail.xml b/fs_image_thumbnail/views/fs_thumbnail.xml new file mode 100644 index 0000000000..9de1fd6cf4 --- /dev/null +++ b/fs_image_thumbnail/views/fs_thumbnail.xml @@ -0,0 +1,62 @@ + + + + + + fs.thumbnail.form + fs.thumbnail + + primary + + + + + + + + + + fs.thumbnail.search + fs.thumbnail + + primary + + + + + + + + + fs.thumbnail.tree + fs.thumbnail + + primary + + + + + + + + + + Fs Thumbnail + fs.thumbnail + tree,form + [] + {} + + + + Fs Image Thumbnails + + + + + diff --git a/fs_image_thumbnail/views/ir_attachment.xml b/fs_image_thumbnail/views/ir_attachment.xml new file mode 100644 index 0000000000..2855a1c64f --- /dev/null +++ b/fs_image_thumbnail/views/ir_attachment.xml @@ -0,0 +1,19 @@ + + + + + + ir.attachment.form (in fs_image_thumbnail) + ir.attachment + + + + + + + + + + + diff --git a/setup/fs_image_thumbnail/odoo/addons/fs_image_thumbnail b/setup/fs_image_thumbnail/odoo/addons/fs_image_thumbnail new file mode 120000 index 0000000000..0836fde3d0 --- /dev/null +++ b/setup/fs_image_thumbnail/odoo/addons/fs_image_thumbnail @@ -0,0 +1 @@ +../../../../fs_image_thumbnail \ No newline at end of file diff --git a/setup/fs_image_thumbnail/setup.py b/setup/fs_image_thumbnail/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/fs_image_thumbnail/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)