Задача: HTML-поле Odoo — опис товару, коментар, будь-яке rich-text поле — може отримати
картинку одним із двох способів, шкідливих для розміру бази: вбудовану base64 просто в
розмітку (<img src="data:image/png;base64,...">) або посиланням на зовнішній URL, який
завтра помре. Модуль kw_html_image2attachment перехоплює обидва випадки і виносить
картинку в ir.attachment, лишаючи в полі звичайне /web/image/<id>.
Де відбувається заміна
Ядро — метод process_images_in_html: дві регулярки знаходять зовнішні http(s)-картинки
й вбудовані base64, для кожної створюють ir.attachment і підміняють src:
# kw_html_image2attachment/models/mixin.py:61-82
def process_images_in_html(self, html_content, record):
...
external_image_urls = re.findall(
r'<img.*?src="(https?://.*?)"', html_content)
for url in external_image_urls:
local_url = self.download_and_save_image(url, record)
if local_url != url:
html_content = html_content.replace(
f'src="{url}"', f'src="{local_url}"')
base64_images = re.findall(
r'src="data:image/[^;]+;base64,([^"]+)"', html_content)
for image_data in base64_images:
local_url = self.save_base_64_image(image_data, record)
...
return html_content
Обидва регекспи зав'язані на подвійні лапки в src="...". У міксині це діє на сирому
vals[field] — одинарні лапки там пройдуть повз, і картинка лишиться base64. Пакетний
шлях process_line читає вже санітизоване fields.Html (sanitize=True) значення, де
лапки завжди подвійні — там це не проблема.
Два шляхи в конвертацію — і працює коректно лише один
Перший шлях — міксин kw.html_image2attachment.mixin: підключаєш до моделі, задаєш
_kw_html_image2attachment_fields, і create()/write() конвертують картинки самі. У
робочому коді KitWorks його зараз не використовує жоден модуль: єдиний колишній
споживач (каталог модулів на сайті) відмовився в квітні 2026, бо недоступна зовнішня
картинка піднімала ValueError і валила транзакцію синхронізації.
Другий — записи kw.html_image2attachment.task/task.line: обираєш модель і HTML-поле,
тул створює по рядку на кожен активний запис, а крон що 5 хвилин забирає по 10 рядків і
проганяє через process_line:
<!-- kw_html_image2attachment/data/cron.xml:13-22 -->
env.cr.execute("""
SELECT id FROM kw_html_image2attachment_task_line
WHERE state = 'new' ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED
""")
ids = [r[0] for r in env.cr.fetchall()]
if ids:
model.browse(ids).action_process()
FOR UPDATE SKIP LOCKED тут не про паралельні запуски самого крону — Odoo 18 і так не
дає двом воркерам виконувати один ir.cron одночасно (ir_cron.py, _acquire_one_job).
Він рятує від очікування на рядки, які в цю мить тримає інша транзакція — наприклад,
ручний action_process з інтерфейсу.
Ну ок, розглянемо, у якому порядку кожен зі шляхів пише нове значення поля й чистить старі вкладення — тут і ховається різниця.
Неправильно — так робить write() міксина:
# kw_html_image2attachment/models/mixin.py:169-182
def write(self, vals):
fields_to_process = set(vals.keys()) & set(
self._kw_html_image2attachment_fields)
if fields_to_process:
tool = self.env['kw.html_image2attachment.tool']
for record in self:
for field in fields_to_process:
vals[field] = tool.process_images_in_html(
vals[field], record)
tool.mark_attachments(record)
tool.clean_unused_images(record)
return super().write(vals)
process_images_in_html уже створив нове вкладення й підставив /web/image/<new_id> у
vals[field] — але це лише локальна змінна. mark_attachments(record) і
clean_unused_images(record) викликані без явного списку полів, тобто беруть усі
_kw_html_image2attachment_fields (обидва мають дефолт fields=None) і читають їх через
getattr(record, field) — а super().write() ще не викликаний, тож читається старе
значення з бази. Нове вкладення в цей старий текст не потрапляє:
# clean_unused_images, mixin.py:106-114
image_attachments = self.env['ir.attachment'].search([
('res_model', '=', record._name), ('res_id', '=', record.id),
('kw_is_html2attachment_image', '=', True),
('id', 'not in', list(map(int, used_image_ids))),
])
if image_attachments:
image_attachments.unlink()
Якщо в старому значенні БУДЬ-ЯКОГО поля _kw_html_image2attachment_fields цього
запису вже є хоч одне /web/image/<id> — свій же конвертований атач чи картинка зі
штатного редактора Odoo, — used_image_ids не порожній, і щойно створене вкладення
видаляється тут-таки, до super().write(). Типовий тригер — повторне редагування
вже конвертованого поля. Тести test_200/test_210 у test_html_image2attachment_mixin.py
це не ловлять: обидва стартують без жодної картинки в жодному полі, тож used_image_ids
порожній із самого початку.
Правильно — так само в тому самому модулі, у process_line() пакетного шляху:
# kw_html_image2attachment/models/line.py:93-97
tool.mark_attachments(record, [self.res_field])
processed_html = tool.process_images_in_html(html, record)
if processed_html != html:
record.write({self.res_field: processed_html})
tool.clean_unused_images(record, [self.res_field])
Тут record.write() спочатку зберігає нове значення, і лише потім clean_unused_images
читає його оновленим. Застереження: це працює тільки для моделі БЕЗ міксина — якщо
модель успадковує kw.html_image2attachment.mixin, цей самий виклик заходить у той
самий перевизначений write(), і пастка спрацьовує ще до зовнішньої чистки.
Важливо! У create() міксина цієї пастки немає, але не через свідомий захист:
# kw_html_image2attachment/models/mixin.py:150-167 (скорочено)
records = super().create(vals_list)
for record, vals in zip(records, vals_list):
for field in self._kw_html_image2attachment_fields:
...
processed_html = tool.process_images_in_html(vals[field], record)
if processed_html != vals[field]:
record.write({field: processed_html})
tool.mark_attachments(record)
tool.clean_unused_images(record)
record.write({field: processed_html}) викликає той самий write() — клас звертається
сам до себе. У цьому вкладеному виклику clean_unused_images читає ще не оновлений
getattr(record, field) — вхідний, уже санітизований текст, щойно збережений
super().create(). Поки в ньому немає жодного /web/image/, used_image_ids порожній,
і чистка виходить рано. Захист тримається на порожньому списку, не на порядку: друге
HTML-поле з картинкою, чи одне поле зі змішаним вмістом (/web/image/<id> поруч зі
свіжим base64), зламає цей шлях так само.
Я б у цьому міксині виправив write() за тим самим принципом, що вже працює в process_line(): спершу зберегти нове значення поля через super().write(vals), і лише потім читати його для mark_attachments/clean_unused_images — не навпаки.