diff --git a/.oca/oca-port/blacklist/storage_thumbnail.json b/.oca/oca-port/blacklist/storage_thumbnail.json new file mode 100644 index 0000000000..fa7859b9bc --- /dev/null +++ b/.oca/oca-port/blacklist/storage_thumbnail.json @@ -0,0 +1,9 @@ +{ + "pull_requests": { + "orphaned_commits": "False positive", + "78": "False positive", + "106": "False positive", + "108": "False positive", + "298": "False positive" + } +} diff --git a/storage_thumbnail/README.rst b/storage_thumbnail/README.rst new file mode 100644 index 0000000000..f005b687c5 --- /dev/null +++ b/storage_thumbnail/README.rst @@ -0,0 +1,85 @@ +================= +Storage Thumbnail +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:7d819b9736618f58fefefb6b5f00415e401b70d5d855c86e5ded82606a0d770a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github + :target: https://github.com/OCA/storage/tree/18.0/storage_thumbnail + :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-storage_thumbnail + :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| + +External image thumbnail management depending on Storage File module. + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Akretion + +Contributors +------------ + +- Sebastien Beau +- Raphaël Reverdy +- Denis Roussel +- Vo Hong Thien + +Other credits +------------- + +The migration of this module from 15.0 to 18.0 was financially supported +by Camptocamp + +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. + +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/storage_thumbnail/__init__.py b/storage_thumbnail/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/storage_thumbnail/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/storage_thumbnail/__manifest__.py b/storage_thumbnail/__manifest__.py new file mode 100644 index 0000000000..14570c6693 --- /dev/null +++ b/storage_thumbnail/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Storage Thumbnail", + "summary": "Abstract module that add the possibility to have thumbnail", + "version": "18.0.1.0.0", + "category": "Storage", + "website": "https://github.com/OCA/storage", + "author": " Akretion, Odoo Community Association (OCA)", + "license": "LGPL-3", + "development_status": "Production/Stable", + "installable": True, + "depends": ["storage_file"], + "data": [ + "data/ir_parameter.xml", + "views/storage_thumbnail_view.xml", + "security/ir.model.access.csv", + ], +} diff --git a/storage_thumbnail/data/ir_parameter.xml b/storage_thumbnail/data/ir_parameter.xml new file mode 100644 index 0000000000..f35b7b0976 --- /dev/null +++ b/storage_thumbnail/data/ir_parameter.xml @@ -0,0 +1,7 @@ + + + + storage.thumbnail.backend_id + + + diff --git a/storage_thumbnail/i18n/storage_thumbnail.pot b/storage_thumbnail/i18n/storage_thumbnail.pot new file mode 100644 index 0000000000..64e40e6647 --- /dev/null +++ b/storage_thumbnail/i18n/storage_thumbnail.pot @@ -0,0 +1,241 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_thumbnail +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.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: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__active +msgid "Active" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__checksum +msgid "Checksum/SHA1" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__company_id +msgid "Company" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__create_uid +msgid "Created by" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__create_date +msgid "Created on" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__data +msgid "Data" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,help:storage_thumbnail.field_storage_thumbnail__data +msgid "Datas" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__display_name +msgid "Display Name" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__extension +msgid "Extension" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__file_id +msgid "File" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__file_size +msgid "File Size" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_file__file_type +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__file_type +msgid "File Type" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__filename +msgid "Filename without extension" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,help:storage_thumbnail.field_storage_thumbnail__internal_url +msgid "HTTP URL to load the file directly from storage." +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,help:storage_thumbnail.field_storage_thumbnail__url +msgid "HTTP accessible path to the file" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__human_file_size +msgid "Human File Size" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__id +msgid "ID" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__internal_url +msgid "Internal Url" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail____last_update +msgid "Last Modified on" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__write_date +msgid "Last Updated on" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_thumbnail_mixin__image_medium_url +msgid "Medium thumb URL" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__mimetype +msgid "Mime Type" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__name +msgid "Name" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__relative_path +msgid "Relative Path" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,help:storage_thumbnail.field_storage_thumbnail__relative_path +msgid "Relative location for backend" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__res_id +msgid "Res" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__res_model +msgid "Res Model" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__slug +msgid "Slug" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,help:storage_thumbnail.field_storage_thumbnail__slug +msgid "Slug-ified name with ID for URL" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_thumbnail_mixin__image_small_url +msgid "Small thumb URL" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,help:storage_thumbnail.field_storage_thumbnail__url_key +msgid "Specific URL key for generating the url of the image" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__backend_id +msgid "Storage" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model,name:storage_thumbnail.model_storage_file +msgid "Storage File" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model,name:storage_thumbnail.model_storage_thumbnail +msgid "Storage Thumbnail" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_thumbnail_mixin__thumb_medium_id +msgid "Thumb Medium" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_thumbnail_mixin__thumb_small_id +msgid "Thumb Small" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields.selection,name:storage_thumbnail.selection__storage_file__file_type__thumbnail +#: model_terms:ir.ui.view,arch_db:storage_thumbnail.view_thumbnail_form +msgid "Thumbnail" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model,name:storage_thumbnail.model_thumbnail_mixin +msgid "Thumbnail Mixin add the thumbnail capability" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_thumbnail_mixin__thumbnail_ids +msgid "Thumbnails" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__to_delete +msgid "To Delete" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__url +msgid "Url" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__url_key +msgid "Url Key" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__size_x +msgid "X size" +msgstr "" + +#. module: storage_thumbnail +#: model:ir.model.fields,field_description:storage_thumbnail.field_storage_thumbnail__size_y +msgid "Y size" +msgstr "" diff --git a/storage_thumbnail/models/__init__.py b/storage_thumbnail/models/__init__.py new file mode 100644 index 0000000000..914c5542e3 --- /dev/null +++ b/storage_thumbnail/models/__init__.py @@ -0,0 +1,3 @@ +from . import storage_thumbnail +from . import thumbnail_mixin +from . import storage_file diff --git a/storage_thumbnail/models/storage_file.py b/storage_thumbnail/models/storage_file.py new file mode 100644 index 0000000000..9a12f000fb --- /dev/null +++ b/storage_thumbnail/models/storage_file.py @@ -0,0 +1,13 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class StorageFile(models.Model): + _inherit = "storage.file" + + file_type = fields.Selection( + selection_add=[("thumbnail", "Thumbnail")], ondelete={"thumbnail": "set null"} + ) diff --git a/storage_thumbnail/models/storage_thumbnail.py b/storage_thumbnail/models/storage_thumbnail.py new file mode 100644 index 0000000000..1f3370a9b5 --- /dev/null +++ b/storage_thumbnail/models/storage_thumbnail.py @@ -0,0 +1,86 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +import logging + +import requests + +from odoo import api, fields, models +from odoo.tools.image import ImageProcess + +_logger = logging.getLogger(__name__) + + +class StorageThumbnail(models.Model): + _name = "storage.thumbnail" + _description = "Storage Thumbnail" + _inherits = {"storage.file": "file_id"} + _default_file_type = "thumbnail" + + size_x = fields.Integer("X size") + size_y = fields.Integer("Y size") + url_key = fields.Char(help="Specific URL key for generating the url of the image") + file_id = fields.Many2one("storage.file", "File", required=True, ondelete="cascade") + res_model = fields.Char(readonly=False, index=True) + res_id = fields.Integer(readonly=False, index=True) + + def _prepare_thumbnail(self, image, size_x, size_y, url_key): + image_resize_format = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("storage.image.resize.format") + ) + if image_resize_format: + extension = image_resize_format + else: + extension = image.extension + return { + "data": self._resize(image, size_x, size_y, extension), + "res_model": image._name, + "res_id": image.id, + "name": f"{url_key or image.filename}_{size_x}_{size_y}{extension}", + "size_x": size_x, + "size_y": size_y, + "url_key": url_key, + } + + def _resize(self, image, size_x, size_y, fmt): + image_resize_server = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("storage.image.resize.server") + ) + if image_resize_server and image.backend_id.served_by != "odoo": + values = {"url": image.url, "width": size_x, "height": size_y, "fmt": fmt} + url = image_resize_server.format(**values) + return base64.encodebytes(requests.get(url, timeout=30).content) + + image_process = ImageProcess(base64.b64decode(image.data)) + image_resize = image_process.resize(max_width=size_x, max_height=size_y) + return base64.b64encode(image_resize.image_quality()) + + def _get_default_backend_id(self): + """Choose the correct backend. + + By default : it's the one configured as ir.config_parameter + Overload this method if you need something more powerfull + """ + return self.env["storage.backend"]._get_backend_id_from_param( + self.env, "storage.thumbnail.backend_id" + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + vals["file_type"] = self._default_file_type + if "backend_id" not in vals: + vals["backend_id"] = self._get_default_backend_id() + return super().create(vals_list) + + def unlink(self): + files = self.mapped("file_id") + result = super().unlink() + files.unlink() + return result diff --git a/storage_thumbnail/models/thumbnail_mixin.py b/storage_thumbnail/models/thumbnail_mixin.py new file mode 100644 index 0000000000..0e18b6dcef --- /dev/null +++ b/storage_thumbnail/models/thumbnail_mixin.py @@ -0,0 +1,132 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import logging + +from odoo import api, fields, models, tools + +_logger = logging.getLogger(__name__) + +try: + from slugify import slugify +except ImportError: + _logger.debug("Cannot `import slugify`.") + + +class ThumbnailMixing(models.AbstractModel): + _name = "thumbnail.mixin" + _description = "Thumbnail Mixin add the thumbnail capability" + + thumbnail_ids = fields.One2many( + comodel_name="storage.thumbnail", + string="Thumbnails", + inverse_name="res_id", + domain=lambda self: [("res_model", "=", self._name)], + ) + thumb_medium_id = fields.Many2one( + comodel_name="storage.thumbnail", + compute="_compute_main_thumbs", + store=True, + readonly=False, + compute_sudo=True, + ) + thumb_small_id = fields.Many2one( + comodel_name="storage.thumbnail", + compute="_compute_main_thumbs", + store=True, + readonly=False, + compute_sudo=True, + ) + image_medium_url = fields.Char( + string="Medium thumb URL", compute="_compute_thumb_urls", compute_sudo=True + ) + image_small_url = fields.Char( + string="Small thumb URL", compute="_compute_thumb_urls", compute_sudo=True + ) + + _image_scale_mapping = { + "medium": (128, 128), + "small": (64, 64), + } + + @api.depends("thumbnail_ids.size_x", "thumbnail_ids.size_y") + def _compute_main_thumbs(self): + for rec in self: + for scale in self._image_scale_mapping.keys(): + fname = f"thumb_{scale}_id" + rec[fname] = rec._get_thumb(scale_key=scale) + + @api.depends( + "thumb_medium_id", "thumb_small_id", "backend_id.backend_view_use_internal_url" + ) + def _compute_thumb_urls(self): + for backend, records in tools.groupby(self, lambda x: x.backend_id): + url_fname = "url" + if backend.backend_view_use_internal_url: + url_fname = "internal_url" + for rec in records: + rec.image_medium_url = rec.thumb_medium_id[url_fname] + rec.image_small_url = rec.thumb_small_id[url_fname] + + def _get_thumb(self, scale_key=None, scale=None): + """Retrievet the first thumb matching given scale.""" + assert scale_key or scale + scale = scale or self._image_scale_mapping[scale_key] + size_x, size_y = scale + for thumb in self.thumbnail_ids: + if thumb.size_x == size_x and thumb.size_y == size_y: + return thumb + + def _get_medium_thumbnail(self): + return self.get_or_create_thumbnail(*self._image_scale_mapping["medium"]) + + def _get_small_thumbnail(self): + return self.get_or_create_thumbnail(*self._image_scale_mapping["small"]) + + def _get_url_key(self, url_key): + if url_key: + url_key = slugify(url_key) + return url_key + + def get_existing_thumbnail(self, size_x, size_y, url_key=None): + self.ensure_one() + url_key = self._get_url_key(url_key) + thumbnail = self.env["storage.thumbnail"].browse() + for th in self.thumbnail_ids: + if th.size_x == size_x and th.size_y == size_y: + if url_key and url_key != th.url_key: + continue + thumbnail = th + break + return thumbnail + + def get_or_create_thumbnail(self, size_x, size_y, url_key=None): + url_key = self._get_url_key(url_key) + thumbnail = self.get_existing_thumbnail(size_x, size_y, url_key=url_key) + if not thumbnail and self.data: + vals = self.env["storage.thumbnail"]._prepare_thumbnail( + self, size_x, size_y, url_key + ) + thumbnail = self.thumbnail_ids.create(vals) + # invalidate field since a new record is created + # The actual model is a mixin, therefore the inverse into + # storage.thumbnail is not defined as a one2many to this mixin. + # As consequence, the ORM is not able to trigger the invalidation + # of thumbnail_ids on our mixin + self.invalidate_recordset(fnames=["thumbnail_ids"]) + return thumbnail + + def generate_odoo_thumbnail(self): + self_sudo = self.sudo() + self_sudo._get_small_thumbnail() + self_sudo._get_medium_thumbnail() + return True + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + + for record in records: + record.generate_odoo_thumbnail() + + return records diff --git a/storage_thumbnail/pyproject.toml b/storage_thumbnail/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/storage_thumbnail/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/storage_thumbnail/readme/CONTRIBUTORS.md b/storage_thumbnail/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..d9b6261c27 --- /dev/null +++ b/storage_thumbnail/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- Sebastien Beau \<\> +- Raphaël Reverdy \<\> +- Denis Roussel \<\> +- Vo Hong Thien \<\> diff --git a/storage_thumbnail/readme/CREDITS.md b/storage_thumbnail/readme/CREDITS.md new file mode 100644 index 0000000000..e5725aaf17 --- /dev/null +++ b/storage_thumbnail/readme/CREDITS.md @@ -0,0 +1 @@ +The migration of this module from 15.0 to 18.0 was financially supported by Camptocamp \ No newline at end of file diff --git a/storage_thumbnail/readme/DESCRIPTION.md b/storage_thumbnail/readme/DESCRIPTION.md new file mode 100644 index 0000000000..44035c092a --- /dev/null +++ b/storage_thumbnail/readme/DESCRIPTION.md @@ -0,0 +1 @@ +External image thumbnail management depending on Storage File module. diff --git a/storage_thumbnail/security/ir.model.access.csv b/storage_thumbnail/security/ir.model.access.csv new file mode 100644 index 0000000000..7396bfed3f --- /dev/null +++ b/storage_thumbnail/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_storage_thumbnail_edit,storage_thumbnail edit,model_storage_thumbnail,base.group_system,1,1,1,1 +access_storage_thumbnail_read,storage_thumbnail read,model_storage_thumbnail,base.group_user,1,0,0,0 diff --git a/storage_thumbnail/static/description/icon.png b/storage_thumbnail/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/storage_thumbnail/static/description/icon.png differ diff --git a/storage_thumbnail/static/description/index.html b/storage_thumbnail/static/description/index.html new file mode 100644 index 0000000000..b448f8d3fd --- /dev/null +++ b/storage_thumbnail/static/description/index.html @@ -0,0 +1,432 @@ + + + + + +Storage Thumbnail + + + +
+

Storage Thumbnail

+ + +

Production/Stable License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

External image thumbnail management depending on Storage File module.

+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The migration of this module from 15.0 to 18.0 was financially supported +by Camptocamp

+
+
+

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.

+

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/storage_thumbnail/tests/__init__.py b/storage_thumbnail/tests/__init__.py new file mode 100644 index 0000000000..23b4a0f5c4 --- /dev/null +++ b/storage_thumbnail/tests/__init__.py @@ -0,0 +1 @@ +from . import test_thumbnail diff --git a/storage_thumbnail/tests/models.py b/storage_thumbnail/tests/models.py new file mode 100644 index 0000000000..c1906e64da --- /dev/null +++ b/storage_thumbnail/tests/models.py @@ -0,0 +1,26 @@ +from odoo import api, fields, models + + +# Declare as Transient to avoid ACL missing warning +class ModelTest(models.TransientModel): + _name = "model.test" + _inherit = "thumbnail.mixin" + _inherits = {"storage.file": "file_id"} + _description = "Model test" + + alt_name = fields.Char(string="Alt Image name") + file_id = fields.Many2one("storage.file", "File", required=True, ondelete="cascade") + + @api.model + def _get_backend_id(self): + return int( + self.env["ir.config_parameter"].get_param("storage.thumbnail.backend_id") + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + vals["file_type"] = "thumbnail" + if "backend_id" not in vals: + vals.update({"backend_id": self._get_backend_id()}) + return super().create(vals_list) diff --git a/storage_thumbnail/tests/static/akretion-logo.png b/storage_thumbnail/tests/static/akretion-logo.png new file mode 100644 index 0000000000..12b824c034 Binary files /dev/null and b/storage_thumbnail/tests/static/akretion-logo.png differ diff --git a/storage_thumbnail/tests/test_thumbnail.py b/storage_thumbnail/tests/test_thumbnail.py new file mode 100644 index 0000000000..e3f1304c0a --- /dev/null +++ b/storage_thumbnail/tests/test_thumbnail.py @@ -0,0 +1,141 @@ +import base64 +import os + +from odoo_test_helper import FakeModelLoader + +from odoo.fields import first + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class TestStorageThumbnail(TransactionComponentCase): + @classmethod + def setUpClass(cls): + res = super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import ModelTest + + cls.loader.update_registry((ModelTest,)) + path = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(path, "static/akretion-logo.png"), "rb") as f: + data = f.read() + cls.filesize = len(data) + cls.filedata = base64.b64encode(data) + cls.filename = "akretion-logo.png" + return res + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + return super().tearDownClass() + + def _create_thumbnail(self): + # create thumbnail + vals = {"name": "TEST THUMB"} + return self.env["storage.thumbnail"].create(vals) + + def _create_image(self, resize=False, **kw): + if resize: + self.env["ir.config_parameter"].sudo().create( + {"key": "storage.image.resize.format", "value": ".webp"} + ) + vals = {"name": self.filename, "data": self.filedata} + vals.update(kw) + return self.env["model.test"].create(vals) + + def test_thumbnail(self): + thumb = self._create_thumbnail() + self.assertTrue(thumb.url) + file_id = thumb.file_id + self.assertTrue(file_id) + thumb.unlink() + self.assertTrue(file_id.to_delete) + + def test_image_get_or_create_thumbnail_no_duplicate(self): + # Ensure no duplicate is generated thanks to unique url_key + image = self._create_image() + self.assertTrue(image.url) + self.assertEqual(len(image.thumbnail_ids), 2) + thumb = image.thumb_small_id + thumb.url_key = "test" + existing_thumb = image.get_or_create_thumbnail( + thumb.size_x, thumb.size_y, url_key="TEST" + ) + self.assertEqual(thumb, existing_thumb) + + def test_model(self): + image = self._create_image() + self.assertTrue(image.url) + self.assertEqual(2, len(image.thumbnail_ids)) + self.assertEqual(".png", first(image.thumbnail_ids).extension) + + def test_model_resize(self): + image = self._create_image(resize=True) + self.assertIn("webp", first(image.thumbnail_ids).url) + self.assertEqual(".webp", first(image.thumbnail_ids).extension) + + def test_medium_small(self): + image = self._create_image() + self.assertEqual(image.thumb_medium_id.size_x, 128) + self.assertEqual(image.thumb_medium_id.size_y, 128) + self.assertEqual(image.thumb_small_id.size_x, 64) + self.assertEqual(image.thumb_small_id.size_y, 64) + + def test_urls(self): + image1 = self._create_image() + image2 = self._create_image(name="another.png") + images = image1 + image2 + # Make it server externally + self.assertFalse(image1.backend_id.backend_view_use_internal_url) + image1.backend_id.served_by = "external" + cdn = "https://somewhere.com" + image1.backend_id.base_url = cdn + + t1_med_file = image1.thumb_medium_id.file_id + t1_small_file = image1.thumb_small_id.file_id + t2_med_file = image2.thumb_medium_id.file_id + t2_small_file = image2.thumb_small_id.file_id + + # Internal URL use CDN by default + expected = [ + { + "url": f"{cdn}/akretion-logo-{image1.file_id.id}.png", + "internal_url": f"/storage.file/akretion-logo-{image1.file_id.id}.png", + "image_medium_url": f"{cdn}/akretion-logo_128_128-{t1_med_file.id}.png", + "image_small_url": f"{cdn}/akretion-logo_64_64-{t1_small_file.id}.png", + }, + { + "url": f"{cdn}/another-{image2.file_id.id}.png", + "internal_url": f"/storage.file/another-{image2.file_id.id}.png", + "image_medium_url": f"{cdn}/another_128_128-{t2_med_file.id}.png", + "image_small_url": f"{cdn}/another_64_64-{t2_small_file.id}.png", + }, + ] + self.assertRecordValues(images, expected) + # Unless we enforce it + image1.backend_id.backend_view_use_internal_url = True + images.invalidate_recordset() + expected = [ + { + "url": f"{cdn}/akretion-logo-{image1.file_id.id}.png", + "internal_url": f"/storage.file/akretion-logo-{image1.file_id.id}.png", + "image_medium_url": ( + f"/storage.file/akretion-logo_128_128-{t1_med_file.id}.png" + ), + "image_small_url": ( + f"/storage.file/akretion-logo_64_64-{t1_small_file.id}.png" + ), + }, + { + "url": f"{cdn}/another-{image2.file_id.id}.png", + "internal_url": f"/storage.file/another-{image2.file_id.id}.png", + "image_medium_url": ( + f"/storage.file/another_128_128-{t2_med_file.id}.png" + ), + "image_small_url": ( + f"/storage.file/another_64_64-{t2_small_file.id}.png" + ), + }, + ] + self.assertRecordValues(images, expected) diff --git a/storage_thumbnail/views/storage_thumbnail_view.xml b/storage_thumbnail/views/storage_thumbnail_view.xml new file mode 100644 index 0000000000..a111fdb07a --- /dev/null +++ b/storage_thumbnail/views/storage_thumbnail_view.xml @@ -0,0 +1,26 @@ + + + + storage.thumbnail + +
+ + + + + +
+
+
+ + storage.thumbnail + + + + + + + + + +
diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..8865457c96 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +odoo_test_helper + +odoo-addon-storage_file @ git+https://github.com/OCA/storage.git@refs/pull/434/head#subdirectory=storage_file