199 lines
6.9 KiB
Python
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,
|
|
)
|