Files
account-tools/account_invoice_default_journal/migrations/18.0.1.0.0/post-migration.py
2026-10-20 21:02:05 +02:00

199 lines
6.9 KiB
Python

# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
"""
Carry 16.0 ``ir.property`` values over to the 18.0 ``jsonb`` storage.
Odoo 17.0 removed the ``ir.property`` model: the value of a
``company_dependent`` field now lives in a ``jsonb`` column on the model's own
table, keyed by company id, with the cross-record fallback stored in
``ir.default``. Nothing converts the values of a *custom* company-dependent
field automatically, so a 16.0 database upgraded to 18.0 would silently lose
every forced journal configured on a product.
This script reads the legacy ``ir_property`` rows of the two fields this module
owns and rewrites them in the 18.0 format. It is idempotent and a no-op on a
fresh 18.0 install, where the ``ir_property`` table no longer exists.
"""
import logging
_logger = logging.getLogger(__name__)
MODEL = "product.template"
TABLE = "product_template"
FIELDS = ("forced_journal_id", "forced_purchase_journal_id")
def _legacy_table_exists(cr):
"""
Tell whether the 16.0 ``ir_property`` table is still present.
:param cr: database cursor.
:return: ``True`` when the legacy table can be read, ``False`` on a fresh
18.0 install or once the table has been dropped.
"""
cr.execute("SELECT to_regclass('public.ir_property') IS NOT NULL")
return cr.fetchone()[0]
def _field_id(cr, field_name):
"""
Return the ``ir_model_fields`` id of a field of :data:`MODEL`.
Only the ``product.template`` definition is looked up on purpose: the
``product.product`` counterpart is a delegated related field and never
carries ``ir.property`` rows of its own.
:param cr: database cursor.
:param str field_name: name of the field to look up.
:return: the field id, or ``None`` when the field is unknown to the
database (module never installed before the upgrade).
"""
cr.execute(
"SELECT id FROM ir_model_fields WHERE model = %s AND name = %s",
(MODEL, field_name),
)
row = cr.fetchone()
return row[0] if row else None
def _migrate_per_record_values(cr, field_name, field_id):
"""
Move per-product legacy values into the ``jsonb`` column.
Existing keys in the target column win, which makes the operation
idempotent and safe to re-run after a partial upgrade. Properties pointing
at a journal that no longer exists are dropped, since the ``jsonb``
storage of a company-dependent many2one has no foreign key to protect it.
:param cr: database cursor.
:param str field_name: name of the company-dependent field to fill.
:param int field_id: ``ir_model_fields`` id of that field.
:return: number of products updated.
"""
cr.execute(
f"""
WITH legacy AS (
SELECT
split_part(ip.res_id, ',', 2)::integer AS product_tmpl_id,
ip.company_id AS company_id,
split_part(ip.value_reference, ',', 2)::integer AS journal_id
FROM ir_property ip
WHERE ip.fields_id = %s
AND ip.type = 'many2one'
AND ip.value_reference IS NOT NULL
AND ip.res_id IS NOT NULL
AND ip.company_id IS NOT NULL
AND split_part(ip.res_id, ',', 1) = %s
),
checked AS (
SELECT legacy.*
FROM legacy
JOIN account_journal aj ON aj.id = legacy.journal_id
JOIN {TABLE} pt ON pt.id = legacy.product_tmpl_id
JOIN res_company rc ON rc.id = legacy.company_id
),
grouped AS (
SELECT
product_tmpl_id,
jsonb_object_agg(company_id::text, journal_id) AS value
FROM checked
GROUP BY product_tmpl_id
)
UPDATE {TABLE} pt
SET {field_name} = grouped.value || COALESCE(pt.{field_name}, '{{}}'::jsonb)
FROM grouped
WHERE pt.id = grouped.product_tmpl_id
""",
(field_id, MODEL),
)
return cr.rowcount
def _migrate_fallback_values(cr, field_name, field_id):
"""
Move legacy cross-record defaults into ``ir_default``.
An ``ir_property`` row without ``res_id`` was a default applying to every
record of the model. In 18.0 that role belongs to ``ir.default``, which is
what :meth:`odoo.fields.Field.get_company_dependent_fallback` reads.
Rows are only inserted when no default exists yet for the same field and
company, keeping the operation idempotent.
:param cr: database cursor.
:param str field_name: name of the company-dependent field.
:param int field_id: ``ir_model_fields`` id of that field.
:return: number of defaults created.
"""
cr.execute(
"""
INSERT INTO ir_default (field_id, user_id, company_id, condition, json_value)
SELECT %s, NULL, ip.company_id, NULL,
split_part(ip.value_reference, ',', 2)
FROM ir_property ip
JOIN account_journal aj
ON aj.id = split_part(ip.value_reference, ',', 2)::integer
JOIN res_company rc ON rc.id = ip.company_id
WHERE ip.fields_id = %s
AND ip.type = 'many2one'
AND ip.value_reference IS NOT NULL
AND ip.res_id IS NULL
AND ip.company_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM ir_default d
WHERE d.field_id = %s
AND d.company_id = ip.company_id
AND d.user_id IS NULL
AND d.condition IS NULL
)
""",
(field_id, field_id, field_id),
)
if cr.rowcount:
_logger.info(
"%s: converted %s company fallback(s) from ir.property to ir.default",
field_name,
cr.rowcount,
)
return cr.rowcount
def migrate(cr, version):
"""
Convert the module's legacy ``ir.property`` rows to the 18.0 storage.
Runs after the module schema has been updated, so the ``jsonb`` columns
this script writes into already exist.
:param cr: database cursor.
:param str version: version the module is upgraded from. Falsy on a fresh
install, in which case there is nothing to convert.
"""
if not version:
return
if not _legacy_table_exists(cr):
_logger.info(
"ir_property table is gone, nothing to convert for %s",
", ".join(FIELDS),
)
return
for field_name in FIELDS:
field_id = _field_id(cr, field_name)
if not field_id:
_logger.warning(
"%s.%s not found in ir_model_fields, skipping its conversion",
MODEL,
field_name,
)
continue
updated = _migrate_per_record_values(cr, field_name, field_id)
_migrate_fallback_values(cr, field_name, field_id)
_logger.info(
"%s.%s: %s product(s) migrated from ir.property to jsonb storage",
MODEL,
field_name,
updated,
)