Skip to Content

kw_html_image2attachment: чому свіжа картинка може зникнути одразу після write()

Задача: 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 — не навпаки.

kw_html_image2attachment: чому свіжа картинка може зникнути одразу після write()
KitWorks, Volodymyr Karabanov 17 вересня 2026 р.
Поділитися цією публікацією
Теги
Архів