From 92394d822cbe5dd531698127e20d81fb0e16b45d Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 18 Sep 2023 16:55:26 +0200 Subject: [PATCH] [ADD] fs_image_thumbnail: A drop-in replacement of storage_thumbnail --- fs_image_thumbnail/README.rst | 35 +++ fs_image_thumbnail/__init__.py | 1 + fs_image_thumbnail/__manifest__.py | 23 ++ fs_image_thumbnail/models/__init__.py | 3 + .../models/fs_image_thumbnail_mixin.py | 238 ++++++++++++++++++ fs_image_thumbnail/models/fs_thumbnail.py | 11 + fs_image_thumbnail/models/ir_attachment.py | 16 ++ fs_image_thumbnail/readme/CONTEXT.rst | 17 ++ fs_image_thumbnail/readme/CONTRIBUTORS.rst | 1 + fs_image_thumbnail/readme/CREDITS.rst | 3 + fs_image_thumbnail/readme/DESCRIPTION.rst | 4 + fs_image_thumbnail/readme/USAGE.rst | 57 +++++ fs_image_thumbnail/security/fs_thumbnail.xml | 16 ++ .../static/description/icon.png | Bin 0 -> 9455 bytes fs_image_thumbnail/tests/__init__.py | 1 + .../tests/test_fs_image_thumbnail.py | 81 ++++++ .../views/fs_image_thumbnail_mixin.xml | 84 +++++++ fs_image_thumbnail/views/fs_thumbnail.xml | 62 +++++ fs_image_thumbnail/views/ir_attachment.xml | 19 ++ .../odoo/addons/fs_image_thumbnail | 1 + setup/fs_image_thumbnail/setup.py | 6 + 21 files changed, 679 insertions(+) create mode 100644 fs_image_thumbnail/README.rst create mode 100644 fs_image_thumbnail/__init__.py create mode 100644 fs_image_thumbnail/__manifest__.py create mode 100644 fs_image_thumbnail/models/__init__.py create mode 100644 fs_image_thumbnail/models/fs_image_thumbnail_mixin.py create mode 100644 fs_image_thumbnail/models/fs_thumbnail.py create mode 100644 fs_image_thumbnail/models/ir_attachment.py create mode 100644 fs_image_thumbnail/readme/CONTEXT.rst create mode 100644 fs_image_thumbnail/readme/CONTRIBUTORS.rst create mode 100644 fs_image_thumbnail/readme/CREDITS.rst create mode 100644 fs_image_thumbnail/readme/DESCRIPTION.rst create mode 100644 fs_image_thumbnail/readme/USAGE.rst create mode 100644 fs_image_thumbnail/security/fs_thumbnail.xml create mode 100644 fs_image_thumbnail/static/description/icon.png create mode 100644 fs_image_thumbnail/tests/__init__.py create mode 100644 fs_image_thumbnail/tests/test_fs_image_thumbnail.py create mode 100644 fs_image_thumbnail/views/fs_image_thumbnail_mixin.xml create mode 100644 fs_image_thumbnail/views/fs_thumbnail.xml create mode 100644 fs_image_thumbnail/views/ir_attachment.xml create mode 120000 setup/fs_image_thumbnail/odoo/addons/fs_image_thumbnail create mode 100644 setup/fs_image_thumbnail/setup.py 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 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 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, +)