From 8206b5b4cc92b6befd98ebec7f7ef8af588a9b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= <seb@odoo.com> Date: Thu, 1 Aug 2019 11:20:25 +0000 Subject: [PATCH] [IMP] fields: add image field PR: #34925 --- addons/product/models/product.py | 29 ++--- odoo/addons/base/models/image_mixin.py | 19 +--- odoo/addons/test_new_api/ir.model.access.csv | 1 + odoo/addons/test_new_api/models.py | 12 ++ .../test_new_api/tests/test_new_fields.py | 107 ++++++++++++++++++ odoo/fields.py | 29 ++++- 6 files changed, 165 insertions(+), 32 deletions(-) diff --git a/addons/product/models/product.py b/addons/product/models/product.py index cdb4a74a498c..250cc578c749 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -123,38 +123,31 @@ class ProductProduct(models.Model): # all image fields are base64 encoded and PIL-supported # all image_raw fields are technical and should not be displayed to the user - image_raw_original = fields.Binary("Raw Original Image") + image_raw_original = fields.Image("Raw Original Image") # resized fields stored (as attachment) for performance - image_raw_big = fields.Binary("Raw Big-sized Image", compute='_compute_images', store=True) - image_raw_large = fields.Binary("Raw Large-sized Image", compute='_compute_images', store=True) - image_raw_medium = fields.Binary("Raw Medium-sized Image", compute='_compute_images', store=True) - image_raw_small = fields.Binary("Raw Small-sized Image", compute='_compute_images', store=True) + image_raw_big = fields.Image("Raw Big-sized Image", related="image_raw_original", max_width=1024, max_height=1024, store=True) + image_raw_large = fields.Image("Raw Large-sized Image", related="image_raw_original", max_width=256, max_height=256, store=True) + image_raw_medium = fields.Image("Raw Medium-sized Image", related="image_raw_original", max_width=128, max_height=128, store=True) + image_raw_small = fields.Image("Raw Small-sized Image", related="image_raw_original", max_width=64, max_height=64, store=True) can_image_raw_be_zoomed = fields.Boolean("Can image raw be zoomed", compute='_compute_images', store=True) # Computed fields that are used to create a fallback to the template if # necessary, it's recommended to display those fields to the user. - image_original = fields.Binary("Original Image", compute='_compute_image_original', inverse='_set_image_original', help="Image in its original size, as it was uploaded.") - image_big = fields.Binary("Big-sized Image", compute='_compute_image_big', help="1024px * 1024px") - image_large = fields.Binary("Large-sized Image", compute='_compute_image_large', help="256px * 256px") - image_medium = fields.Binary("Medium-sized Image", compute='_compute_image_medium', help="128px * 128px") - image_small = fields.Binary("Small-sized Image", compute='_compute_image_small', help="64px * 64px") + image_original = fields.Image("Original Image", compute='_compute_image_original', inverse='_set_image_original', help="Image in its original size, as it was uploaded.") + image_big = fields.Image("Big-sized Image", compute='_compute_image_big', help="1024px * 1024px") + image_large = fields.Image("Large-sized Image", compute='_compute_image_large', help="256px * 256px") + image_medium = fields.Image("Medium-sized Image", compute='_compute_image_medium', help="128px * 128px") + image_small = fields.Image("Small-sized Image", compute='_compute_image_small', help="64px * 64px") can_image_be_zoomed = fields.Boolean("Can image be zoomed", compute='_compute_can_image_be_zoomed') - image = fields.Binary("Image", compute='_compute_image', inverse='_set_image') + image = fields.Image("Image", compute='_compute_image', inverse='_set_image') @api.depends('image_raw_original') def _compute_images(self): for record in self: image = record.image_raw_original - # for performance: avoid calling unnecessary methods when falsy - images = image and tools.image_get_resized_images(image, big_name=False) - record.image_raw_big = image and tools.image_get_resized_images(image, - large_name=False, medium_name=False, small_name=False)['image'] - record.image_raw_large = image and images['image_large'] - record.image_raw_medium = image and images['image_medium'] - record.image_raw_small = image and images['image_small'] record.can_image_raw_be_zoomed = image and tools.is_image_size_above(image) def _compute_image_original(self): diff --git a/odoo/addons/base/models/image_mixin.py b/odoo/addons/base/models/image_mixin.py index 855415e385fc..1941ca417741 100644 --- a/odoo/addons/base/models/image_mixin.py +++ b/odoo/addons/base/models/image_mixin.py @@ -10,29 +10,22 @@ class ImageMixin(models.AbstractModel): # all image fields are base64 encoded and PIL-supported - image_original = fields.Binary("Original Image", help="Image in its original size, as it was uploaded.") + image_original = fields.Image("Original Image", help="Image in its original size, as it was uploaded.") # resized fields stored (as attachment) for performance - image_big = fields.Binary("Big-sized Image", compute='_compute_images', store=True, help="1024px * 1024px") - image_large = fields.Binary("Large-sized Image", compute='_compute_images', store=True, help="256px * 256px") - image_medium = fields.Binary("Medium-sized Image", compute='_compute_images', store=True, help="128px * 128px") - image_small = fields.Binary("Small-sized Image", compute='_compute_images', store=True, help="64px * 64px") + image_big = fields.Image("Big-sized Image", related="image_original", max_width=1024, max_height=1024, store=True, help="1024px * 1024px") + image_large = fields.Image("Large-sized Image", related="image_original", max_width=256, max_height=256, store=True, help="256px * 256px") + image_medium = fields.Image("Medium-sized Image", related="image_original", max_width=128, max_height=128, store=True, help="128px * 128px") + image_small = fields.Image("Small-sized Image", related="image_original", max_width=64, max_height=64, store=True, help="64px * 64px") can_image_be_zoomed = fields.Boolean("Can image raw be zoomed", compute='_compute_images', store=True) - image = fields.Binary("Image", compute='_compute_image', inverse='_set_image') + image = fields.Image("Image", compute='_compute_image', inverse='_set_image') @api.depends('image_original') def _compute_images(self): for record in self: image = record.image_original - # for performance: avoid calling unnecessary methods when falsy - images = image and tools.image_get_resized_images(image, big_name=False) - record.image_big = image and tools.image_get_resized_images(image, - large_name=False, medium_name=False, small_name=False)['image'] - record.image_large = image and images['image_large'] - record.image_medium = image and images['image_medium'] - record.image_small = image and images['image_small'] record.can_image_be_zoomed = image and tools.is_image_size_above(image) @api.depends('image_big') diff --git a/odoo/addons/test_new_api/ir.model.access.csv b/odoo/addons/test_new_api/ir.model.access.csv index e071b1b25f14..3db5381a4b93 100644 --- a/odoo/addons/test_new_api/ir.model.access.csv +++ b/odoo/addons/test_new_api/ir.model.access.csv @@ -32,3 +32,4 @@ access_test_new_api_field_with_caps,access_test_new_api_field_with_caps,model_te access_test_new_api_req_m2o,access_test_new_api_req_m2o,model_test_new_api_req_m2o,,1,1,1,1 access_test_new_api_attachment,access_test_new_api_attachment,model_test_new_api_attachment,,1,1,1,1 access_test_new_api_attachment_host,access_test_new_api_attachment_host,model_test_new_api_attachment_host,,1,1,1,1 +access_test_new_api_model_image,access_test_new_api_model_image,model_test_new_api_model_image,,1,1,1,1 diff --git a/odoo/addons/test_new_api/models.py b/odoo/addons/test_new_api/models.py index 031b55aa3ac3..e045754bcc2c 100644 --- a/odoo/addons/test_new_api/models.py +++ b/odoo/addons/test_new_api/models.py @@ -480,6 +480,18 @@ class ComputeCascade(models.Model): record.baz = "<%s>" % (record.bar or "") +class ModelImage(models.Model): + _name = 'test_new_api.model_image' + _description = 'Test Image field' + + name = fields.Char(required=True) + + image = fields.Image() + image_512 = fields.Image("Image 512", related='image', max_width=512, max_height=512, store=True, readonly=False) + image_256 = fields.Image("Image 256", related='image', max_width=256, max_height=256, store=False, readonly=False) + image_128 = fields.Image("Image 128", max_width=128, max_height=128) + + class BinarySvg(models.Model): _name = 'test_new_api.binary_svg' _description = 'Test SVG upload' diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index d372f6f23ae6..d12072a58a3b 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -1,7 +1,10 @@ # # test cases for new-style fields # +import base64 from datetime import date, datetime, time +import io +from PIL import Image from odoo import fields from odoo.exceptions import AccessError, UserError @@ -1330,6 +1333,110 @@ class TestFields(common.TransactionCase): self.assertEqual(field.related, ('monetary_id', 'amount')) self.assertEqual(field.currency_field, 'base_currency_id') + def test_94_image(self): + f = io.BytesIO() + Image.new('RGB', (4000, 2000), '#4169E1').save(f, 'PNG') + f.seek(0) + image_w = base64.b64encode(f.read()) + + f = io.BytesIO() + Image.new('RGB', (2000, 4000), '#4169E1').save(f, 'PNG') + f.seek(0) + image_h = base64.b64encode(f.read()) + + record = self.env['test_new_api.model_image'].create({ + 'name': 'image', + 'image': image_w, + 'image_128': image_w, + }) + + # test create (no resize) + self.assertEqual(record.image, image_w) + # test create (resize, width limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_128))).size, (128, 64)) + # test create related store (resize, width limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (512, 256)) + # test create related no store (resize, width limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (256, 128)) + + record.write({ + 'image': image_h, + 'image_128': image_h, + }) + + # test write (no resize) + self.assertEqual(record.image, image_h) + # test write (resize, height limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_128))).size, (64, 128)) + # test write related store (resize, height limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (256, 512)) + # test write related no store (resize, height limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (128, 256)) + + record = self.env['test_new_api.model_image'].create({ + 'name': 'image', + 'image': image_h, + 'image_128': image_h, + }) + + # test create (no resize) + self.assertEqual(record.image, image_h) + # test create (resize, height limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_128))).size, (64, 128)) + # test create related store (resize, height limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (256, 512)) + # test create related no store (resize, height limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (128, 256)) + + record.write({ + 'image': image_w, + 'image_128': image_w, + }) + + # test write (no resize) + self.assertEqual(record.image, image_w) + # test write (resize, width limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_128))).size, (128, 64)) + # test write related store (resize, width limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (512, 256)) + # test write related store (resize, width limited) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (256, 128)) + + # test create inverse store + record = self.env['test_new_api.model_image'].create({ + 'name': 'image', + 'image_512': image_w, + }) + record.invalidate_cache(fnames=['image_512'], ids=record.ids) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (512, 256)) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image))).size, (4000, 2000)) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (256, 128)) + # test write inverse store + record.write({ + 'image_512': image_h, + }) + record.invalidate_cache(fnames=['image_512'], ids=record.ids) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (256, 512)) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image))).size, (2000, 4000)) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (128, 256)) + + # test create inverse no store + record = self.env['test_new_api.model_image'].create({ + 'name': 'image', + 'image_256': image_w, + }) + record.invalidate_cache(fnames=['image_256'], ids=record.ids) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (512, 256)) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image))).size, (4000, 2000)) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (256, 128)) + # test write inverse no store + record.write({ + 'image_256': image_h, + }) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (256, 512)) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image))).size, (2000, 4000)) + self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (128, 256)) + class TestX2many(common.TransactionCase): def test_definition_many2many(self): diff --git a/odoo/fields.py b/odoo/fields.py index 41779826d6e2..8d866204765e 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -23,7 +23,7 @@ except ImportError: import psycopg2 from .tools import float_repr, float_round, frozendict, html_sanitize, human_size, pg_varchar, \ - ustr, OrderedSet, pycompat, sql, date_utils, unique, IterableGenerator + ustr, OrderedSet, pycompat, sql, date_utils, unique, IterableGenerator, image_process from .tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT from .tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT from .tools.translate import html_translate, _ @@ -1906,6 +1906,33 @@ class Binary(Field): atts.unlink() +class Image(Binary): + _slots = { + 'max_width': 0, + 'max_height': 0, + } + + def create(self, record_values): + new_record_values = [] + for record, value in record_values: + new_record_values.append((record, self._image_process(value))) + super(Image, self).create(new_record_values) + + def write(self, records, value): + value = self._image_process(value) + super(Image, self).write(records, value) + + def _image_process(self, value): + if value and (self.max_width or self.max_height): + value = image_process(value, size=(self.max_width, self.max_height)) + return value + + def _compute_related(self, records): + super(Image, self)._compute_related(records) + for record in records: + record[self.name] = self._image_process(record[self.name]) + + class Selection(Field): """ :param selection: specifies the possible values for this field. -- GitLab