# HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1740160349 -3600 # Fri Feb 21 18:52:29 2025 +0100 # Branch 18.0 # Node ID ac6f9091291b083a954e43851c04ba164ddedae4 # Parent 85bcb83808e879520a789197b9a0a39195fa7af1 # EXP-Topic data-uri ✨ Field converter on a binary field use data uri diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -1,6 +1,11 @@ Changelog ========= +18.0.6.1.0 +---------- + +Field converter on a binary field use data uri. + 18.0.6.0.0 ---------- diff --git a/__init__.py b/__init__.py --- a/__init__.py +++ b/__init__.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2025 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as diff --git a/__manifest__.py b/__manifest__.py --- a/__manifest__.py +++ b/__manifest__.py @@ -21,7 +21,7 @@ "name": "Converter", "license": "AGPL-3", "summary": "Convert odoo records to/from plain data structures.", - "version": "18.0.6.0.0", + "version": "18.0.6.1.0", "category": "Hidden", "author": "XCG Consulting", "website": "https://orbeet.io/", diff --git a/field.py b/field.py --- a/field.py +++ b/field.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2025 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,18 +17,27 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - +import base64 import datetime from collections.abc import Callable from typing import Any, Literal +from urllib.parse import urlsplit +from urllib.request import urlopen import pytz -from odoo import api, models # type: ignore[import-untyped] +from odoo import api, fields, models # type: ignore[import-untyped] -from .base import PHASE_POSTCREATE, Context, Converter, Newinstance, Skip, SkipType +from .base import ( + PHASE_POSTCREATE, + Context, + Newinstance, + PostHookConverter, + Skip, + SkipType, +) -class Field(Converter): +class Field(PostHookConverter): """Converter linked to a single field""" def __init__( @@ -111,6 +120,20 @@ if isinstance(value, datetime.date): if not self._message_formatter: value = value.isoformat() + field = instance._fields[self.field_name] + if isinstance(field, fields.Binary): + if field.attachment: + domain = [ + ("res_model", "=", instance._name), + ("res_field", "=", self.field_name), + ("res_id", "in", instance.ids), + ] + # sudo needed, no right to read those attachments + attachment = instance.env["ir.attachment"].sudo().search(domain) + mimetype = attachment.mimetype.encode("UTF-8") + else: + mimetype = b"application/octet-stream" + value = b"data:" + mimetype + b";base64," + value if self._message_formatter: value = self._message_formatter(value, False) return value @@ -135,11 +158,51 @@ value = self.odoo_to_message(instance) if isinstance(value, SkipType) or value == message_value: return {} + field = instance._fields[self.field_name] + if isinstance(field, fields.Binary): + if field.attachment: + return {} + else: + url = urlsplit(message_value) + if url.scheme != "data": + # urlsplit raises ValueError too + raise ValueError("Not a Data URI (%s scheme)", url.scheme) + with urlopen(message_value) as response: + message_value = base64.b64encode(response.read()) + if self._odoo_formatter: message_value = self._odoo_formatter(message_value) return {self.field_name: message_value} + def post_hook(self, instance: models.BaseModel, message_data): + field = instance._fields[self.field_name] + if isinstance(field, fields.Binary): + if field.attachment: + # create ir.attachment directly + url = urlsplit(message_data) + # sudo needed, no right to read those attachments + if url.scheme != "data": + # urlsplit raises ValueError too + raise ValueError("Not a Data URI (%s scheme)", url.scheme) + domain = [ + ("res_model", "=", instance._name), + ("res_field", "=", self.field_name), + ("res_id", "in", instance.ids), + ] + # sudo needed, no right to read those attachments + attachment = instance.env["ir.attachment"].sudo().search(domain) + with urlopen(message_data) as response: + datas = base64.b64encode(response.read()) + if self._odoo_formatter: + datas = self._odoo_formatter(datas) + # TODO does not update the data. Need to use _set_attachment_data + # directly maybe? + attachment.write( + {"datas": datas, "mimetype": url.path.split(";", 1)[0]} + ) + instance.invalidate_recordset([self.field_name]) + class TranslatedSelection(Field): """Converter that uses a translation value of a selection field rather diff --git a/tests/test_field.py b/tests/test_field.py --- a/tests/test_field.py +++ b/tests/test_field.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2021 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2021, 2025 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -110,3 +110,84 @@ ) ), ) + + def test_binary_data_uri(self): + converter = Field("avatar_1920") + partner = self.env.ref("base.partner_admin") + old_image = partner.avatar_1920 + value = converter.odoo_to_message(partner) + # admin partner avatar is stored with application/octet-stream + self.assertTrue(value.startswith(b"data:application/octet-stream;base64,")) + # white 1920x1920 image + data_uri = """data:image/png;base64, +iVBORw0KGgoAAAANSUhEUgAAB4AAAAeAAQAAAAAH2XdrAAAABGdBTUEAALGPC/xhBQAAACBjSFJN +AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAd2KE6QAAAAHdElN +RQfpAhURBCXSFMwjAAAIE0lEQVR42u3PAQ0AMAjAMPyb/mWQjE7BOu9Ysz0ADAwMDAwMfCfgesD1 +gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOu +B1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5w +PeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA +6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64H +XA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA9 +4HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDr +AdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdc +D7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3g +esD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB +1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wP +uB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6 +wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHX +A64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4 +HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA +9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcD +rgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7ge +cD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1 +gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOu +B1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5w +PeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA +6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64H +XA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA9 +4HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDr +AdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdc +D7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3g +esD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB +1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wP +uB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6 +wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHX +A64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4 +HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA +9YDrAdcDrgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcD +rgdcD7gecD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7ge +cD3gesD1gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1 +gOsB1wOuB1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrAdcDrgdcD7gecD3gesD1gOsB1wOu +B1wPuB5wPeB6wPWA6wHXA64HXA+4HnA94HrA9YDrfdjxYRCgcj/RAAAAJXRFWHRkYXRlOmNyZWF0 +ZQAyMDI1LTAyLTIxVDE3OjA0OjM3KzAwOjAw60rorAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNS0w +Mi0yMVQxNzowNDozNyswMDowMJoXUBAAAAAASUVORK5CYII=""" + values = converter.message_to_odoo(self.env, "update", data_uri, partner, True) + partner.write(values) + self.assertNotEqual(old_image, partner.avatar_1920) + value = converter.odoo_to_message(partner) + # not an attachment, mimetype is lost + self.assertTrue(value.startswith(b"data:application/octet-stream;base64,")) + converter = Field("image_1920") + main_company = self.env.ref("base.main_company").partner_id + values = converter.message_to_odoo(self.env, "update", data_uri, partner, True) + main_company.write(values) + value = converter.odoo_to_message(main_company) + self.assertTrue(value.startswith(b"data:image/png;base64,")) + # test on an attachment field + data_uri = ( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElE" + "QVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" + ) + menu = self.env["ir.ui.menu"].search([], limit=1) + old_image = menu.web_icon_data + converter = Field("web_icon_data") + values = converter.message_to_odoo(self.env, "update", data_uri, menu, True) + menu.write(values) + self.assertEqual(old_image, menu.web_icon_data) + converter.post_hook(menu, data_uri) + # TODO fix that, post_hook is not working + self.assertNotEqual(old_image, menu.web_icon_data) + value = converter.odoo_to_message(menu) + self.assertTrue(value.startswith(b"data:image/png;base64,")) + # TODO test with a image/svg and a non admin user + # TODO add more tests like text/plain