diff --git a/docker/scripts/migration_diagnostics.py b/docker/scripts/migration_diagnostics.py index d07e5a0..c79bd1a 100644 --- a/docker/scripts/migration_diagnostics.py +++ b/docker/scripts/migration_diagnostics.py @@ -44,16 +44,13 @@ def check(self, name: str, action: Callable[[], T], fallback: T, *, cursor: Any try: result = action() except Exception as error: + self.record(name, "failed", f"{type(error).__name__}: {error}") + self.record(name + ": remaining guards", "blocked", "Coverage beyond the reported error is unproved") if cursor is not None: cursor.execute("ROLLBACK TO SAVEPOINT migration_diagnostic_check") - self.record(name, "failed", f"{type(error).__name__}: {error}") - self.record( - name + ": remaining guards", "blocked", "Coverage beyond the reported error is unproved; changes were skipped" - ) return fallback - else: - self.record(name, "passed") - return result finally: if cursor is not None: cursor.execute("RELEASE SAVEPOINT migration_diagnostic_check") + self.record(name, "passed") + return result diff --git a/docker/scripts/nativeupgrade/sale/20.0.1.0/pre-company-lead-time.py b/docker/scripts/nativeupgrade/sale/20.0.1.0/pre-company-lead-time.py new file mode 100644 index 0000000..380e5b2 --- /dev/null +++ b/docker/scripts/nativeupgrade/sale/20.0.1.0/pre-company-lead-time.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import runpy +from pathlib import Path +from typing import Any + + +def preserve_lead_time(cr: Any) -> None: + cr.execute("LOCK TABLE product_template,ir_model_fields,ir_default,res_company IN SHARE ROW EXCLUSIVE MODE") + cr.execute( + "SELECT id,ttype,store,company_dependent FROM ir_model_fields WHERE model=%s AND name=%s", + ("product.template", "sale_delay"), + ) + metadata = cr.fetchall() + if len(metadata) != 1 or metadata[0][1:3] != ("integer", True): + raise RuntimeError("Sale lead time has missing or unsupported source metadata") + field_id, _type, _stored, company_dependent = metadata[0] + cr.execute( + "SELECT data_type,column_default FROM information_schema.columns WHERE table_schema='public' " + "AND table_name='product_template' AND column_name='sale_delay'" + ) + columns = cr.fetchall() + if len(columns) == 1 and columns[0][0] == "jsonb" and company_dependent: + return + if columns != [("integer", None)] or company_dependent: + raise RuntimeError("Sale lead time has an unsupported source column") + cr.execute("SELECT 1 FROM ir_default WHERE field_id=%s LIMIT 1", (field_id,)) + if cr.fetchall(): + raise RuntimeError("Sale lead time has source defaults requiring preservation") + cr.execute("SELECT id FROM res_company ORDER BY id") + companies = cr.fetchall() + if not companies: + raise RuntimeError("Sale lead time has no existing company") + values = " || ".join("jsonb_build_object(%s,sale_delay)" for _ in companies) + cr.execute( + "ALTER TABLE product_template ALTER COLUMN sale_delay TYPE jsonb " + f"USING CASE WHEN sale_delay IS NULL THEN NULL ELSE {values} END", + tuple(str(company_id) for (company_id,) in companies), + ) + cr.execute("UPDATE ir_model_fields SET company_dependent=TRUE WHERE id=%s", (field_id,)) + + +def migrate(cr: Any, version: str | None) -> None: + if not version or version.split(".", 1)[0] != "19": + return + root = Path(__file__).resolve().parents[3] + diagnostics = runpy.run_path(str(root / "migration_diagnostics.py"))["MigrationDiagnostics"]() + diagnostics.check("Sale company-dependent lead time preservation", lambda: preserve_lead_time(cr), None, cursor=cr) + preserve_action = runpy.run_path(str(root / "prepare_core_references_20.py"))["preserve_sale_accrual_action"] + diagnostics.check("Sale accrued revenue action preservation", lambda: preserve_action(cr), None, cursor=cr) diff --git a/docker/scripts/payment_20.py b/docker/scripts/payment_20.py index f04bfa1..25a635d 100644 --- a/docker/scripts/payment_20.py +++ b/docker/scripts/payment_20.py @@ -4,7 +4,7 @@ import hashlib import re from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from importlib import import_module from pathlib import Path from typing import Any, NoReturn @@ -23,6 +23,63 @@ class Retirement: methods: list[int] attachments: list[int] aliases: list[int] + preserved_methods: list[tuple[int, int]] = field(default_factory=list) + + +def _separate_native_provider_methods(snapshot: dict[str, Any]) -> tuple[dict[str, Any], list[tuple[int, int]]]: + bindings = { + "payment_custom.payment_method_wire_transfer": ("payment.payment_provider_transfer", "payment_custom", "wire_transfer"), + "delivery.payment_method_cash_on_delivery": ("delivery.payment_provider_cod", "delivery", "cash_on_delivery"), + } + preserved = [] + provider_ids = set() + for alias, (provider_alias, module, mode) in bindings.items(): + owners = [row for row in snapshot["method_aliases"] if row["module"] + "." + row["name"] == alias] + if not owners: + continue + if len(owners) != 1: + _fail("native method alias ambiguity") + owner = owners[0] + method_id = owner["res_id"] + methods = [row for row in snapshot["methods"] if row["id"] == method_id] + providers = [row for row in snapshot["provider_aliases"] if row["module"] + "." + row["name"] == provider_alias] + if len(methods) != 1 or len(providers) != 1 or owner["model"] != "payment.method" or owner["noupdate"] is not True: + _fail("native provider binding") + provider_id = providers[0]["res_id"] + records = [row for row in snapshot["providers"] if row["id"] == provider_id] + if len(records) != 1 or providers[0]["model"] != "payment.provider" or providers[0]["noupdate"] is not True: + _fail("native provider identity") + provider = records[0] + connector = snapshot["modules"].get(provider["module_id"], {}) + if ( + provider["code"] != "custom" + or provider.get("custom_mode") != mode + or methods[0]["code"] != mode + or connector.get("xmlid") != "base.module_" + module + or connector.get("state") not in {"installed", "to upgrade"} + or [edge for edge in snapshot["provider_links"] if edge[0] == provider_id or edge[1] == method_id] + != [(provider_id, method_id)] + or len([row for row in snapshot["method_aliases"] if row["res_id"] == method_id]) != 1 + or len([row for row in snapshot["provider_aliases"] if row["res_id"] == provider_id]) != 1 + ): + _fail("native provider ownership or connector") + preserved.append((method_id, provider_id)) + provider_ids.add(provider_id) + method_ids = {method_id for method_id, _provider_id in preserved} + retiring_ids = {row["id"] for row in snapshot["methods"]} - method_ids + if any(row["id"] in method_ids and row.get("primary_payment_method_id") in retiring_ids for row in snapshot["methods"]): + _fail("native method brand parent would be retired") + filtered = dict(snapshot) + for key in ("methods", "method_aliases", "attachments"): + field_name = "id" if key == "methods" else "res_id" + filtered[key] = [row for row in snapshot[key] if row[field_name] not in method_ids] + for key in ("providers", "provider_aliases"): + field_name = "id" if key == "providers" else "res_id" + filtered[key] = [row for row in snapshot[key] if row[field_name] not in provider_ids] + filtered["provider_links"] = [edge for edge in snapshot["provider_links"] if edge[0] not in provider_ids] + for key in ("countries", "currencies"): + filtered[key] = {identifier: values for identifier, values in snapshot[key].items() if identifier not in method_ids} + return filtered, preserved def _retirement_error(subject: str) -> RuntimeError: @@ -74,7 +131,10 @@ def _owned(rows: list[dict[str, Any]], aliases: list[dict[str, Any]], expected: _fail("duplicate row identity") result = {} for alias in aliases: - if set(alias) - AUDIT != {"id", "module", "name", "model", "res_id", "noupdate"}: + if set(alias) - AUDIT - {"studio"} != {"id", "module", "name", "model", "res_id", "noupdate"} or alias.get("studio") not in ( + None, + False, + ): _fail("alias material metadata") key = alias["module"] + "." + alias["name"] identity = alias["res_id"] @@ -114,6 +174,7 @@ def _admit_image_file(attachment: dict[str, Any], expected: dict[str, Any], file def admit(snapshot: dict[str, Any], witness: dict[str, Any], filestore: Path) -> Retirement: + snapshot, preserved = _separate_native_provider_methods(snapshot) if snapshot["relation_columns"] != { table: sorted(("payment_method_id", column)) for table, column, _model in RELATIONS.values() }: @@ -180,16 +241,33 @@ def admit(snapshot: dict[str, Any], witness: dict[str, Any], filestore: Path) -> "has_db_datas", } metadata = {key: value for key, value in row.items() if key not in AUDIT | dynamic} - if _semantic_values(metadata, witness["attachment_defaults"]) != witness["attachment_defaults"]: - _fail("attachment customization or unknown fields") expected_image = witness["methods"][methods[row["res_id"]]]["images"][field] + if expected_image is None: + _fail("image differs from recorded source absence") + context = expected_image.get("metadata", {}) + website_id = context.get("website_id") + if ( + set(context) - {"index_content", "website_id"} + or context.get("index_content") not in (None, "image") + or (website_id is not None and (type(website_id) is not int or website_id <= 0)) + ): + _fail("unsupported native image metadata") + expected_metadata = witness["attachment_defaults"] | context + if _semantic_values(metadata, expected_metadata) != expected_metadata: + _fail("attachment customization or unknown fields") _admit_image_file(row, expected_image, filestore) - if seen != {(identifier, field) for identifier in methods for field in IMAGE_FIELDS}: + if seen != { + (identifier, field) + for identifier, alias in methods.items() + for field in IMAGE_FIELDS + if witness["methods"][alias]["images"][field] is not None + }: _fail("missing stock image") return Retirement( sorted(methods), sorted(row["id"] for row in snapshot["attachments"]), sorted(row["id"] for row in snapshot["method_aliases"]), + preserved, ) @@ -273,7 +351,7 @@ def observe(cr: Any) -> dict[str, Any]: _fail("main company binding") columns = _rows( cr, - "SELECT table_name,array_agg(column_name ORDER BY column_name) FROM information_schema.columns " + "SELECT table_name,array_agg(column_name::text ORDER BY column_name) FROM information_schema.columns " "WHERE table_schema='public' AND table_name=ANY(%s) GROUP BY table_name", ([relation[0] for relation in RELATIONS.values()],), ) @@ -325,6 +403,12 @@ def retire(cr: Any, profile: dict[str, Any], version: str, filestore: Path, chec } internal_relations.add(("payment_method", "primary_payment_method_id", "payment_method")) check_references(cr, targets, internal_relations, indirect_rows=indirect_rows) + if plan.preserved_methods: + cr.execute("ALTER TABLE payment_method ADD COLUMN provider_id integer REFERENCES payment_provider(id) ON DELETE CASCADE") + for method_id, provider_id in plan.preserved_methods: + cr.execute("UPDATE payment_method SET provider_id=%s WHERE id=%s RETURNING id", (provider_id, method_id)) + if cr.fetchall() != [(method_id,)]: + _fail("native provider assignment changed") for table in ("ir_attachment", "ir_model_data", "payment_method"): _delete_exact(cr, table, targets[table]) return plan diff --git a/docker/scripts/prepare_core_references_20.py b/docker/scripts/prepare_core_references_20.py index 11a0a56..2f7c75c 100644 --- a/docker/scripts/prepare_core_references_20.py +++ b/docker/scripts/prepare_core_references_20.py @@ -28,6 +28,7 @@ HR_DEPARTMENT_DATA_FILE = Path("/odoo/addons/hr/views/hr_department_views.xml") HR_USERS_DATA_FILE = Path("/odoo/addons/hr/views/res_users.xml") HR_TYPE_DATA_FILE = Path("/odoo/addons/hr/data/hr_employee_type_data.xml") +SALE_ACCRUAL_DATA_FILE = Path("/odoo/addons/sale/wizard/account_accrued_orders_wizard_views.xml") HR_TYPE_FIELD_OWNERS = ("hr.job", "hr.version", "hr.employee") UNTRACKED_PUBLIC_VIEW_DEFAULTS = {"track": (None, False), "visibility": (None, "")} _logger = logging.getLogger(__name__) @@ -752,6 +753,78 @@ def _require_empty(row: dict[str, Any], ignored: set[str], defaults: dict[str, t raise RuntimeError(f"Customized or unsupported core transition state: {name}") +def preserve_sale_accrual_action(cr: Any) -> None: + name = "action_accrued_revenue_entry_sale_order_line" + source = _reference(cr, "sale", name) + if source is None or source.model == "ir.actions.server": + return + if source.model != "ir.actions.act_window" or source.noupdate: + raise RuntimeError("Unsupported Sale accrual action ownership") + native = _hr_native_fields(SALE_ACCRUAL_DATA_FILE, name, "ir.actions.server") + if ( + native.get("binding_model_id") is None + or native["binding_model_id"].attrib.get("ref") != "sale.model_sale_order_line" + or native.get("state") is None + or native["state"].text != "code" + or native.get("name") is None + or not native["name"].text + ): + raise RuntimeError("Unsupported native Sale accrual action") + binding = _require_source(cr, "sale", "model_sale_order_line", "ir.model") + group = _require_source(cr, "account", "group_account_user", "res.groups") + if _action_aliases(cr, source.record_id) != {("sale", name, source.model)}: + raise RuntimeError("Customized Sale accrual action aliases") + aliases = _rows(cr, "SELECT * FROM ir_model_data WHERE module=%s AND name=%s FOR UPDATE", ("sale", name)) + if len(aliases) != 1 or aliases[0].get("studio"): + raise RuntimeError("Customized Sale accrual action metadata") + actions = _rows(cr, "SELECT * FROM ir_act_window WHERE id=%s FOR UPDATE", (source.record_id,)) + if len(actions) != 1: + raise RuntimeError("Missing Sale accrual window action") + _require_empty( + actions[0], + {"id", "create_uid", "write_uid", "create_date", "write_date"}, + { + "binding_model_id": (binding.record_id,), + "type": (source.model,), + "binding_type": ("action",), + "binding_view_types": ("list,form",), + "name": ({"en_US": native["name"].text},), + "limit": (80,), + "context": ("{}",), + "res_model": ("account.accrued.orders.wizard",), + "target": ("new",), + "view_mode": ("form",), + "mobile_view_mode": ("kanban",), + "cache": (True,), + }, + ) + cr.execute("SELECT tableoid::regclass::text,id FROM ir_actions WHERE id=%s FOR UPDATE", (source.record_id,)) + if cr.fetchall() != [("ir_act_window", source.record_id)]: + raise RuntimeError("Ambiguous Sale accrual action storage") + cr.execute("SELECT gid FROM ir_act_window_group_rel WHERE act_id=%s FOR UPDATE", (source.record_id,)) + if cr.fetchall() != [(group.record_id,)]: + raise RuntimeError("Customized Sale accrual action groups") + alias_id = aliases[0]["id"] + _check_references( + cr, + {"ir_act_window": [source.record_id], "ir_actions": [source.record_id], "ir_model_data": [alias_id]}, + {("ir_act_window_group_rel", "act_id", "ir_act_window")}, + indirect_rows={("ir_model_data", "res_id", "ir_act_window"): [alias_id]}, + ) + for query, parameters, expected in ( + ( + "DELETE FROM ir_act_window_group_rel WHERE act_id=%s AND gid=%s RETURNING act_id", + (source.record_id, group.record_id), + source.record_id, + ), + ("DELETE FROM ir_model_data WHERE id=%s RETURNING id", (alias_id,), alias_id), + ("DELETE FROM ir_act_window WHERE id=%s RETURNING id", (source.record_id,), source.record_id), + ): + cr.execute(query, parameters) + if cr.fetchall() != [(expected,)]: + raise RuntimeError("Sale accrual action retirement changed unexpectedly") + + def _pinned(member: dict[str, Any], channel: dict[str, Any]) -> bool: unpin = member["unpin_dt"] return not unpin or any( @@ -846,6 +919,42 @@ def _check_encoded_relations(cr: Any, table: str, column: str, targets: dict[str raise RuntimeError(f"Referenced core transition row: {table}.{column}") +def _check_json_references( + cr: Any, + table: str, + column: str, + targets: list[str], + references: set[str], + languages: set[str] | None = None, +) -> None: + variables = json.dumps({"targets": targets}) + if languages is None: + _identified( + cr, + "SELECT 1 FROM %s WHERE jsonb_path_exists(%s::jsonb, '$.** ? (@ == $targets[*])', %s::jsonb) LIMIT 1", + (table, column), + (variables,), + ) + invalid = cr.fetchone() is not None + else: + _identified( + cr, + "SELECT DISTINCT %s::jsonb AS reference_value FROM %s " + "WHERE jsonb_path_exists(%s::jsonb, '$.** ? (@ == $targets[*])', %s::jsonb)", + (column, table, column), + (variables,), + ) + invalid = any( + not isinstance(value, dict) + or not set(value).issubset(languages) + or any(not isinstance(label, str) and label is not None for label in value.values()) + or any(label in references for label in value.values() if isinstance(label, str)) + for (value,) in cr.fetchall() + ) + if invalid: + raise RuntimeError(f"Unsupported core transition JSON reference: {table}.{column}") + + def _check_references( cr: Any, ids: dict[str, list[int]], @@ -858,7 +967,9 @@ def _check_references( if source not in ids or target not in ids or not set(rows).issubset(ids[source]): raise RuntimeError("Internal reference rows must belong to retiring source and target cohorts") - model_targets = {table.replace("_", "."): records for table, records in ids.items()} + utility = import_module("odoo.upgrade.util") + model_tables = {utility.model_of_table(cr, table): table for table in ids} + model_targets = {model: ids[table] for model, table in model_tables.items()} models = list(model_targets) cr.execute( "SELECT source_ns.nspname, source.relname, source_col.attname, target.relname, target_col.attname, " @@ -932,7 +1043,7 @@ def _check_references( if not column or (kind == "many2many" and not relation_table): raise RuntimeError("Unsupported core transition relation metadata") owner = relation_table if kind == "many2many" else None - relational_columns.setdefault(column, []).append((owner, ids[target.replace(".", "_")])) + relational_columns.setdefault(column, []).append((owner, model_targets[target])) cr.execute( "SELECT 1 FROM pg_class relation JOIN pg_namespace schema ON schema.oid = relation.relnamespace " "JOIN pg_attribute field ON field.attrelid = relation.oid WHERE schema.nspname = 'public' " @@ -952,8 +1063,20 @@ def _check_references( ) if cr.fetchone(): raise RuntimeError("Unsupported core transition field default") - references = [f"{model},{record}" for model in model_ids for record in ids[model.replace(".", "_")]] - json_targets = json.dumps({"targets": [*model_ids, *references]}) + cr.execute("SELECT model,name,translate FROM ir_model_fields WHERE store AND ttype IN ('char','text','html')") + translated_columns = { + (utility.table_of_model(cr, model), name) + for model, name, translation in cr.fetchall() + if translation not in (None, False, "", "false", "f") + } + languages = None + if translated_columns: + cr.execute("SELECT code FROM res_lang") + languages = {row[0] for row in cr.fetchall()} + if not languages: + raise RuntimeError("Missing language metadata for translated reference storage") + references = [f"{model},{record}" for model in model_ids for record in model_targets[model]] + json_targets = [*model_ids, *references] for table, fields in columns.items(): for column, data_type in fields: text_storage = data_type in {"text", "character varying", "character"} @@ -974,14 +1097,14 @@ def _check_references( if cr.fetchone(): raise RuntimeError(f"Referenced core transition row: {table}.{column}") if data_type in {"json", "jsonb"}: - _identified( + _check_json_references( cr, - "SELECT 1 FROM %s WHERE jsonb_path_exists(%s::jsonb, '$.** ? (@ == $targets[*])', %s::jsonb) LIMIT 1", - (table, column), - (json_targets,), + table, + column, + json_targets, + set(references), + languages if (table, column) in translated_columns else None, ) - if cr.fetchone(): - raise RuntimeError(f"Unsupported core transition JSON reference: {table}.{column}") if column not in indirect_columns: continue for model, model_id in model_ids.items(): @@ -992,12 +1115,12 @@ def _check_references( "WHERE value.key = ANY(%s) AND value.value = %s))" ) parameters = ( - [str(record) for record in ids[model.replace(".", "_")]] if text_storage else ids[model.replace(".", "_")], + [str(record) for record in model_targets[model]] if text_storage else model_targets[model], model, list(model_columns.get(table, ())), str(model_id), ) - allowed = indirect_rows.get((table, column, model.replace(".", "_"))) + allowed = indirect_rows.get((table, column, model_tables[model])) if allowed: query += " AND NOT (record.id = ANY(%s))" parameters += (allowed,) diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index 56b7af5..5515007 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -671,8 +671,26 @@ Notes a normal success result when reporting is enabled. The supported entry is the OPW saved19 diagnostic driver, which owns its explicit diagnostic status, complete observed list and blocked coverage. - A report pass describes a check, not committed migration effects. + A report pass follows successful savepoint cleanup and describes a check, + not committed migration effects. Native 20 also selects the packaged `docker/scripts/nativeupgrade` root. + Its Sale pre-stage converts the stored global integer `product.template.sale_delay` + atomically to JSONB values for all existing companies. The upstream bulk + converter commits, so it cannot preserve this diagnostic transaction boundary. + Missing or unexpected metadata, column shapes, source defaults or companies + refuse before conversion; diagnostics report the failure and skip changes. + Already converted JSONB fields keep their defaults unchanged. + The Sale stage also handles the native accrued-revenue action changing from + a window action to a server action. It retires only the unchanged stock modal, + its sole XML ID and exact account-user group edge, after checking the target + definition and all incoming references. Native20 recreates the same XML ID. + Edited actions, extra aliases or groups, paths and incoming references report + failure before effects. Reference checks use upstream model/table mappings + so irregular action tables protect both concrete and generic action links. + JSON guards distinguish reflected translated text from record-reference + payloads. A language-keyed label naming an action class is retained as text; + complete model/record references, unknown encodings and other JSON payloads + keep their existing refusal behavior. Its Mail pre-stage reuses the guarded stock-view conversion from [website227](https://github.com/cbusillo/odoo-tenant-cm-website/issues/227). Before any shared bridge effect, `mail_view_20.py` locks and compares the @@ -716,6 +734,20 @@ Notes bridge creates no default provider or replacement catalog. Final readback must prove the selected record fates, surviving provider settings and native constraints; a completed registry alone does not establish preservation. + Native wire-transfer and Cash on Delivery methods stay outside this retirement + cohort. Their exact aliases, installed or scheduled-for-upgrade connector and exclusive existing provider + link must agree with the native target bindings before carrying that same owner + into `provider_id`. Their records, images, aliases and provider settings remain. + A witness may explicitly record an absent source image as `null`; an unexpected + image or an unrecorded loss still refuses. Present original images keep their + public-source comparison and exact-byte checks. Recorded absence does not + establish that an unused catalog was pristine, and never bypasses material-value, + history, ownership or incoming-reference checks. + Each image may also record its original native `index_content` and `website_id` + context. Only the native image index marker or null and an explicit website + identity or null are supported; actual metadata must match exactly. Other + metadata stays bound to the attachment defaults. No website is selected by + position and no custom index text is admitted as stock. A source custodian can use `qualify_source_family(profile, source_manifest, manifest_sha256, automation_source)` to add the one stock Base Automation extension without changing the independent reference. The separate immutable diff --git a/tests/live_odoo/addons/devkit_live_checks/tests/test_live_workflows.py b/tests/live_odoo/addons/devkit_live_checks/tests/test_live_workflows.py index cfec455..463e818 100644 --- a/tests/live_odoo/addons/devkit_live_checks/tests/test_live_workflows.py +++ b/tests/live_odoo/addons/devkit_live_checks/tests/test_live_workflows.py @@ -6,6 +6,7 @@ import configparser import json import os +import runpy import subprocess import sys import tempfile @@ -67,6 +68,83 @@ def database_command(statement: sql.Composed) -> None: @tagged("post_install", "-at_install") class TestLiveWorkflows(TransactionCase): + def test_translated_action_labels_preserve_record_reference_guards(self) -> None: + check = runpy.run_path("/volumes/scripts/prepare_core_references_20.py")["_check_json_references"] + cursor = self.env.cr + cursor.execute("CREATE TEMP TABLE devkit_reference_payload (content jsonb) ON COMMIT DROP") + model = "ir.actions.act_window" + reference = f"{model},1051" + labels = {"en_US": model, "fr_FR": "Window action"} + + def store(value: Any) -> None: + cursor.execute("DELETE FROM devkit_reference_payload") + cursor.execute("INSERT INTO devkit_reference_payload VALUES (%s::jsonb)", (json.dumps(value),)) + + store(labels) + check(cursor, "devkit_reference_payload", "content", [model, reference], {reference}, {"en_US", "fr_FR"}) + cursor.execute("SELECT content FROM devkit_reference_payload") + self.assertEqual(cursor.fetchone(), (labels,)) + cursor.execute("INSERT INTO devkit_reference_payload VALUES (%s::jsonb)", (json.dumps({"en_US": reference}),)) + with self.assertRaises(RuntimeError): + check(cursor, "devkit_reference_payload", "content", [model, reference], {reference}, {"en_US", "fr_FR"}) + for value in ( + {"en_US": reference}, + {"en_US": {"model": model, "id": 1051}}, + {"unknown": model}, + [model, 1051], + ): + with self.subTest(value=value): + store(value) + with self.assertRaises(RuntimeError): + check(cursor, "devkit_reference_payload", "content", [model, reference], {reference}, {"en_US", "fr_FR"}) + store(labels) + with self.assertRaises(RuntimeError): + check(cursor, "devkit_reference_payload", "content", [model, reference], {reference}) + + def test_sale_lead_times_keep_company_values_and_the_diagnostic_transaction(self) -> None: + migrate = runpy.run_path("/volumes/scripts/nativeupgrade/sale/20.0.1.0/pre-company-lead-time.py")["migrate"] + with self.target(empty=True) as runner: + database_command(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(runner.local.db_name))) + with psycopg2.connect(host="database", user="odoo", dbname=runner.local.db_name) as connection: + with connection.cursor() as cursor: + cursor.execute("CREATE TABLE product_template (id integer PRIMARY KEY, sale_delay integer)") + cursor.execute( + "CREATE TABLE ir_model_fields (id integer PRIMARY KEY,model text,name text,ttype text," + "store boolean,company_dependent boolean)" + ) + cursor.execute("CREATE TABLE ir_default (field_id integer)") + cursor.execute("CREATE TABLE ir_model_data (module text,name text,model text,res_id integer,noupdate boolean)") + cursor.execute("CREATE TABLE res_company (id integer PRIMARY KEY)") + cursor.execute("INSERT INTO res_company VALUES (2),(7)") + cursor.execute("INSERT INTO product_template VALUES (1,0),(2,8),(3,NULL)") + cursor.execute("INSERT INTO ir_model_fields VALUES (9,'product.template','sale_delay','integer',TRUE,FALSE)") + connection.commit() + report = self.root / (runner.local.db_name + "-sale-report.jsonl") + with patch.dict( + os.environ, + { + "ODOO_MIGRATION_DIAGNOSTIC_REPORT": str(report), + "ODOO_COPY_PURPOSE": "migration", + "PLATFORM_INSTANCE": "rehearsal", + "OPENUPGRADE_TARGET_VERSION": "20.0", + }, + ): + cursor.execute("SAVEPOINT caller_boundary") + migrate(cursor, "19.0.1.0") + cursor.execute("RELEASE SAVEPOINT caller_boundary") + cursor.execute("SELECT id,sale_delay FROM product_template ORDER BY id") + self.assertEqual(cursor.fetchall(), [(1, {"2": 0, "7": 0}), (2, {"2": 8, "7": 8}), (3, None)]) + cursor.execute("SELECT company_dependent FROM ir_model_fields WHERE id=9") + self.assertEqual(cursor.fetchone(), (True,)) + events = [json.loads(line) for line in report.read_text().splitlines()] + self.assertTrue(events) + self.assertTrue(all(event["status"] == "passed" for event in events), events) + connection.rollback() + cursor.execute("SELECT id,sale_delay FROM product_template ORDER BY id") + self.assertEqual(cursor.fetchall(), [(1, 0), (2, 8), (3, None)]) + cursor.execute("SELECT company_dependent FROM ir_model_fields WHERE id=9") + self.assertEqual(cursor.fetchone(), (False,)) + def planned_payload(self, runner: workflows.OdooDataWorkflowRunner) -> dict[str, Any]: graph = runner.release_module_graph() image = "fixture/image@sha256:" + "a" * 64 diff --git a/tests/test_core_references_20.py b/tests/test_core_references_20.py index 45a3a53..74e2907 100644 --- a/tests/test_core_references_20.py +++ b/tests/test_core_references_20.py @@ -42,7 +42,7 @@ def fetchall(self) -> list[tuple]: return [ tuple( json.loads(value) - if column[0] == "arch_db" + if column[0] in {"arch_db", "reference_value"} else datetime.fromisoformat(value) if column[0] == "create_date" and value is not None else value @@ -578,7 +578,9 @@ def setUp(self) -> None: "custom_notifications TEXT, mute_until_dt TEXT, fetched_message_id INTEGER, seen_message_id INTEGER, " "new_message_separator INTEGER, unpin_dt TEXT, last_interest_dt TEXT, last_seen_dt TEXT, create_date TEXT);" "CREATE TABLE ir_model (id INTEGER PRIMARY KEY, model TEXT);" - "CREATE TABLE ir_model_fields (name TEXT, ttype TEXT, store BOOLEAN, company_dependent BOOLEAN, relation TEXT, model TEXT, relation_table TEXT, column2 TEXT, column1 TEXT);" + "CREATE TABLE ir_model_fields (name TEXT, ttype TEXT, store BOOLEAN, company_dependent BOOLEAN, relation TEXT, model TEXT, relation_table TEXT, column2 TEXT, column1 TEXT, translate TEXT);" + "CREATE TABLE res_lang (code TEXT PRIMARY KEY);" + "INSERT INTO res_lang VALUES ('en_US'),('fr_FR');" "CREATE TABLE mail_message (id INTEGER PRIMARY KEY, model TEXT, res_id INTEGER, body TEXT);" "CREATE TABLE deletion_triggers (name TEXT);" "CREATE TABLE materialized_references (name TEXT);" @@ -599,6 +601,12 @@ def setUp(self) -> None: self.connection.create_function("jsonb_path_exists", 3, self.json_reference) self.cursor = DiscussCursor(self.connection) patch_attr(bridge, "_identified", self.cursor.identified).start() + importer = bridge.import_module + utility = Mock( + model_of_table=lambda _cr, table: table.replace("_", "."), + table_of_model=lambda _cr, model: model.replace(".", "_"), + ) + patch_attr(bridge, "import_module", lambda name: utility if name == "odoo.upgrade.util" else importer(name)).start() @staticmethod def json_reference(value: str | None, _path: str, variables: str) -> bool: @@ -1138,5 +1146,128 @@ def test_allowance_cannot_admit_a_nonretiring_source_row(self) -> None: self.assertEqual(self.all_rows(), before) +class SaleAccrualActionTests(EmptyChatFixture): + def setUp(self) -> None: + super().setUp() + self.native = Path(self.directory.name) / "sale.xml" + self.native.write_text( + '' + 'Accrued Revenue Entry' + '' + 'code' + ) + patch_attr(bridge, "SALE_ACCRUAL_DATA_FILE", self.native).start() + self.connection.executescript( + "CREATE TABLE ir_actions (id INTEGER PRIMARY KEY);" + "CREATE TABLE ir_act_window (id INTEGER PRIMARY KEY,binding_model_id INTEGER,type TEXT,binding_type TEXT," + 'binding_view_types TEXT,name TEXT,"limit" INTEGER,context TEXT,res_model TEXT,target TEXT,view_mode TEXT,' + "mobile_view_mode TEXT,cache BOOLEAN,path TEXT,custom_value TEXT);" + "CREATE TABLE ir_act_window_group_rel (act_id INTEGER REFERENCES ir_act_window(id),gid INTEGER);" + 'INSERT INTO ir_model VALUES (901,"ir.actions.act_window"),(902,"ir.actions.actions"),(903,"ir.model.data");' + 'INSERT INTO ir_act_window VALUES (1051,626,"ir.actions.act_window","action","list,form",' + '\'{"en_US":"Accrued Revenue Entry"}\',80,"{}","account.accrued.orders.wizard","new","form","kanban",1,NULL,NULL);' + "INSERT INTO ir_act_window_group_rel VALUES (1051,59);" + "CREATE TABLE ir_model_fields_selection (id INTEGER PRIMARY KEY,name JSON);" + """INSERT INTO ir_model_fields_selection VALUES (30,'{"en_US":"ir.actions.act_window"}');""" + "INSERT INTO ir_model_fields(name,model,ttype,store,translate) " + "VALUES ('name','ir.model.fields.selection','char',1,'standard');" + ) + self.xmlid("sale", "action_accrued_revenue_entry_sale_order_line", "ir.actions.act_window", 1051, False) + self.xmlid("sale", "model_sale_order_line", "ir.model", 626, True) + self.xmlid("account", "group_account_user", "res.groups", 59, True) + execute = self.cursor.execute + rows = bridge._rows + importer = bridge.import_module + utility = Mock( + model_of_table=lambda _cr, table: {"ir_act_window": "ir.actions.act_window", "ir_actions": "ir.actions.actions"}.get( + table, table.replace("_", ".") + ), + table_of_model=lambda _cr, model: {"ir.actions.act_window": "ir_act_window", "ir.actions.actions": "ir_actions"}.get( + model, model.replace(".", "_") + ), + ) + patch_attr(bridge, "import_module", lambda name: utility if name == "odoo.upgrade.util" else importer(name)).start() + + def action_rows(cr: Any, query: str, parameters: tuple = ()) -> list[dict[str, Any]]: + result = rows(cr, query, parameters) + if "FROM ir_act_window WHERE" in query: + for row in result: + row["name"] = json.loads(row["name"]) + return result + + def inherited_actions(query: str, parameters: tuple = ()) -> Any: + if "tableoid::regclass" in query: + result = self.connection.execute("SELECT id FROM ir_act_window WHERE id=?", parameters).fetchall() + return self.cursor._catalog([("ir_act_window", row[0]) for row in result], 2) + return execute(query, parameters) + + patch_attr(bridge, "_rows", action_rows).start() + patch_attr(self.cursor, "execute", inherited_actions).start() + + def retire_action(self) -> None: + bridge.preserve_sale_accrual_action(self.cursor) + + def test_stock_unreferenced_modal_is_retired_and_other_rows_survive(self) -> None: + before = self.all_rows() + self.retire_action() + after = self.all_rows() + self.assertEqual(after["ir_act_window"], []) + self.assertEqual(after["ir_act_window_group_rel"], []) + self.assertEqual(len(before["ir_model_data"]) - len(after["ir_model_data"]), 1) + for table in before.keys() - {"ir_act_window", "ir_act_window_group_rel", "ir_model_data"}: + self.assertEqual(after[table], before[table]) + self.retire_action() + self.assertEqual(self.all_rows(), after) + + def test_customizations_and_references_refuse_before_any_effect(self) -> None: + self.connection.executescript( + "CREATE TABLE custom_action_link (window_id INTEGER REFERENCES ir_act_window(id)," + "action_id INTEGER REFERENCES ir_actions(id),reference TEXT,payload JSON);" + "CREATE TABLE ir_filters (id INTEGER PRIMARY KEY,action_id INTEGER);" + "INSERT INTO ir_model_fields(name,ttype,store,relation,model) " + "VALUES ('action_id','many2one',1,'ir.actions.actions','ir.filters');" + ) + for change in ( + "UPDATE ir_act_window SET path='custom'", + "UPDATE ir_act_window SET custom_value='retained'", + """UPDATE ir_act_window SET context='{"default_name":"Custom"}'""", + """UPDATE ir_act_window SET name='{"en_US":"Custom"}'""", + "UPDATE ir_act_window SET binding_model_id=627", + "UPDATE ir_act_window_group_rel SET gid=60", + "DELETE FROM ir_act_window_group_rel", + "UPDATE ir_model_data SET noupdate=1 WHERE model='ir.actions.act_window'", + "INSERT INTO ir_model_data(module,name,model,res_id) VALUES ('custom','action','ir.actions.act_window',1051)", + "INSERT INTO custom_action_link(window_id) VALUES (1051)", + "INSERT INTO custom_action_link(reference) VALUES ('ir.actions.actions,1051')", + "INSERT INTO custom_action_link(reference) VALUES ('ir.actions.act_window,1051')", + """INSERT INTO custom_action_link(payload) VALUES ('{"action":"ir.actions.act_window,1051"}')""", + """INSERT INTO custom_action_link(payload) VALUES ('{"model":"ir.actions.act_window","id":1051}')""", + """UPDATE ir_model_fields_selection SET name='{"en_US":"ir.actions.act_window,1051"}'""", + """INSERT INTO ir_model_fields_selection VALUES (31,'{"en_US":"ir.actions.act_window,1051"}')""", + """UPDATE ir_model_fields_selection SET name='{"en_US":{"model":"ir.actions.act_window","id":1051}}'""", + """UPDATE ir_model_fields_selection SET name='{"unknown":"ir.actions.act_window"}'""", + "UPDATE ir_model_fields SET translate=NULL WHERE model='ir.model.fields.selection'", + "INSERT INTO ir_filters VALUES (1,1051)", + "INSERT INTO mail_message VALUES (1,'ir.actions.act_window',1051,'Retain history')", + "INSERT INTO deletion_triggers VALUES ('retain_action')", + ): + with self.subTest(change=change): + self.connection.execute("SAVEPOINT action_change") + self.connection.execute(change) + before = self.all_rows() + with self.assertRaises(RuntimeError): + self.retire_action() + self.assertEqual(self.all_rows(), before) + self.connection.execute("ROLLBACK TO action_change") + self.connection.execute("RELEASE action_change") + + def test_changed_native_model_refuses_before_any_effect(self) -> None: + self.native.write_text(self.native.read_text().replace("ir.actions.server", "ir.actions.client")) + before = self.all_rows() + with self.assertRaises(RuntimeError): + self.retire_action() + self.assertEqual(self.all_rows(), before) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_migration_diagnostics.py b/tests/test_migration_diagnostics.py index 45d7f41..d2e2788 100644 --- a/tests/test_migration_diagnostics.py +++ b/tests/test_migration_diagnostics.py @@ -16,6 +16,46 @@ class MigrationDiagnosticsTests(unittest.TestCase): + def test_action_failure_is_reported_before_lost_savepoint_cleanup(self) -> None: + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "report.jsonl" + with patch.dict( + os.environ, + { + "ODOO_MIGRATION_DIAGNOSTIC_REPORT": str(report), + "ODOO_COPY_PURPOSE": "migration", + "PLATFORM_INSTANCE": "rehearsal", + "OPENUPGRADE_TARGET_VERSION": "20.0", + }, + ): + cursor = Mock() + cursor.execute.side_effect = [None, RuntimeError("savepoint disappeared"), RuntimeError("savepoint disappeared")] + with self.assertRaisesRegex(RuntimeError, "savepoint disappeared"): + Diagnostics().check("conversion", self.reject, None, cursor=cursor) + events = [json.loads(line) for line in report.read_text().splitlines()] if report.exists() else [] + self.assertTrue(any(event["status"] == "failed" and "preserve" in event["detail"] for event in events)) + self.assertTrue(any(event["status"] == "blocked" for event in events)) + self.assertFalse(any(event["status"] == "passed" for event in events)) + + def test_savepoint_cleanup_failure_never_records_a_pass(self) -> None: + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "report.jsonl" + with patch.dict( + os.environ, + { + "ODOO_MIGRATION_DIAGNOSTIC_REPORT": str(report), + "ODOO_COPY_PURPOSE": "migration", + "PLATFORM_INSTANCE": "rehearsal", + "OPENUPGRADE_TARGET_VERSION": "20.0", + }, + ): + cursor = Mock() + cursor.execute.side_effect = [None, RuntimeError("savepoint disappeared")] + with self.assertRaisesRegex(RuntimeError, "savepoint disappeared"): + Diagnostics().check("conversion", lambda: None, None, cursor=cursor) + events = [json.loads(line) for line in report.read_text().splitlines()] if report.exists() else [] + self.assertFalse(any(event["status"] == "passed" for event in events)) + def test_mail_and_payment_report_missing_reference_inputs_and_keep_ordinary_failures(self) -> None: root = Path(__file__).resolve().parents[1] / "docker/scripts/nativeupgrade" scripts = (root / "mail/20.0.1.19/pre-server-action-view.py", root / "payment/20.0.2.0/pre-global-methods.py") diff --git a/tests/test_payment_20.py b/tests/test_payment_20.py index bac0cfb..5e517ec 100644 --- a/tests/test_payment_20.py +++ b/tests/test_payment_20.py @@ -172,6 +172,130 @@ def test_capture_locks_before_collecting_the_same_qualified_snapshot(self) -> No self.assertTrue(all(statement.startswith(("SELECT ", "WITH ")) for statement in statements[1:])) self.assertEqual(snapshot, self.current) + def add_native_method(self, mode: str = "cash_on_delivery") -> None: + wire = mode == "wire_transfer" + self.current["methods"].append({"id": 301, "code": mode, "active": True}) + method = self.alias(401, 301, "payment_method_" + mode, "payment.method") + method["module"] = "payment_custom" if wire else "delivery" + self.current["method_aliases"].append(method) + self.current["providers"].append({"id": 302, "code": "custom", "custom_mode": mode, "state": "enabled", "module_id": 303}) + provider = self.alias(402, 302, "payment_provider_transfer" if wire else "payment_provider_cod", "payment.provider") + provider["module"] = "payment" if wire else "delivery" + self.current["provider_aliases"].append(provider) + self.current["modules"][303] = { + "xmlid": "base.module_payment_custom" if wire else "base.module_delivery", + "state": "installed", + } + self.current["provider_links"].append((302, 301)) + self.current["countries"][301] = [] + self.current["currencies"][301] = [] + self.current["attachments"].append({"id": 403, "res_id": 301, "res_field": "image"}) + + def test_native_method_and_enabled_provider_are_preserved_outside_stock_retirement(self) -> None: + self.add_native_method() + before = copy.deepcopy(self.current) + plan = self.admit() + self.assertEqual(plan.preserved_methods, [(301, 302)]) + self.assertNotIn(301, plan.methods) + self.assertNotIn(403, plan.attachments) + self.assertNotIn(401, plan.aliases) + self.assertEqual(self.current, before) + + def test_scheduled_native_connector_upgrade_preserves_the_existing_provider(self) -> None: + self.add_native_method() + self.current["modules"][303]["state"] = "to upgrade" + plan = self.admit() + self.assertEqual(plan.preserved_methods, [(301, 302)]) + self.assertNotIn(301, plan.methods) + + def test_preserved_native_method_cannot_lose_a_retiring_brand_parent(self) -> None: + before = copy.deepcopy(self.current) + for mode in ("cash_on_delivery", "wire_transfer"): + with self.subTest(mode=mode): + self.current = copy.deepcopy(before) + self.add_native_method(mode) + self.current["methods"][-1]["primary_payment_method_id"] = self.current["methods"][0]["id"] + with self.assertRaisesRegex(RuntimeError, "native method brand parent"): + self.admit() + + def test_wire_transfer_provider_handoff_preserves_the_payment_module_alias(self) -> None: + self.add_native_method("wire_transfer") + before = copy.deepcopy(self.current) + plan = self.admit() + self.assertEqual(plan.preserved_methods, [(301, 302)]) + self.assertNotIn(301, plan.methods) + self.assertEqual(self.current, before) + + def test_native_ownership_requires_the_exact_existing_connector_and_exclusive_link(self) -> None: + self.add_native_method() + before = copy.deepcopy(self.current) + for mutate in ( + lambda: self.current["provider_links"].append((41, 301)), + lambda: self.current["provider_links"].append((302, 101)), + lambda: self.current["provider_links"].remove((302, 301)), + lambda: self.current["modules"][303].update(state="uninstalled"), + lambda: self.current["modules"][303].update(state="to install"), + lambda: self.current["modules"][303].update(state="to remove"), + lambda: self.current["modules"][303].update(xmlid="base.module_other"), + lambda: self.current["providers"][-1].update(custom_mode="wire_transfer"), + lambda: self.current["method_aliases"][-1].update(noupdate=False), + ): + self.current = copy.deepcopy(before) + mutate() + with self.assertRaises(RuntimeError): + self.admit() + + def test_native_owner_assignment_waits_for_all_incoming_reference_checks(self) -> None: + self.add_native_method() + function = PAYMENT["retire"] + cursor = Mock() + cursor.fetchall.return_value = [(301,)] + checker = Mock(side_effect=RuntimeError("outside reference")) + with patch.dict(function.__globals__, {"capture": lambda _cursor: self.current, "_delete_exact": Mock()}): + with self.assertRaises(RuntimeError): + function(cursor, self.profile, self.version, self.filestore, checker) + cursor.execute.assert_not_called() + checker.side_effect = None + function(cursor, self.profile, self.version, self.filestore, checker) + self.assertEqual(cursor.execute.call_args.args[1], (302, 301)) + + def test_recorded_source_image_absence_refuses_an_added_image_and_an_unrecorded_loss(self) -> None: + alias = next(iter(self.witness["methods"])) + attachment = self.current["attachments"][0] + self.witness["methods"][alias]["images"][attachment["res_field"]] = None + with self.assertRaisesRegex(RuntimeError, "recorded source absence"): + self.admit() + self.current["attachments"].remove(attachment) + self.admit() + self.current["attachments"].pop() + with self.assertRaisesRegex(RuntimeError, "missing stock image"): + self.admit() + + def test_unset_studio_alias_marker_is_accepted_and_customized_alias_refuses(self) -> None: + for alias in self.current["method_aliases"] + self.current["provider_aliases"]: + alias["studio"] = None + self.admit() + self.current["method_aliases"][0]["studio"] = True + with self.assertRaisesRegex(RuntimeError, "alias material metadata"): + self.admit() + + def test_native_image_index_and_website_context_must_match_the_recorded_original(self) -> None: + for method in self.witness["methods"].values(): + for image in method["images"].values(): + image["metadata"] = {"index_content": "image", "website_id": 71} + for attachment in self.current["attachments"]: + attachment.update(index_content="image", website_id=71) + self.admit() + self.current["attachments"][0]["website_id"] = 72 + with self.assertRaisesRegex(RuntimeError, "attachment customization"): + self.admit() + self.current["attachments"][0]["website_id"] = 71 + for method in self.witness["methods"].values(): + for image in method["images"].values(): + image["metadata"]["index_content"] = "custom content" + with self.assertRaisesRegex(RuntimeError, "unsupported native image metadata"): + self.admit() + def test_whole_family_admits_with_semantic_nullable_boolean_and_real_original_file_hashes(self) -> None: plan = self.admit() self.assertEqual(set(plan.methods), {row["id"] for row in self.current["methods"]}) diff --git a/tests/test_sale_20.py b/tests/test_sale_20.py new file mode 100644 index 0000000..8a311e6 --- /dev/null +++ b/tests/test_sale_20.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +import runpy +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +SALE = runpy.run_path( + str(Path(__file__).resolve().parents[1] / "docker/scripts/nativeupgrade/sale/20.0.1.0/pre-company-lead-time.py") +) + + +class SaleLeadTimeTests(unittest.TestCase): + def setUp(self) -> None: + self.cr = Mock() + + def test_defaults_and_unknown_source_shapes_refuse_conversion(self) -> None: + for readbacks in ( + [[]], + [[(9, "float", True, False)]], + [[(9, "integer", False, False)]], + [[(9, "integer", True, False)], [("text", None)]], + [[(9, "integer", True, True)], [("integer", None)]], + [[(9, "integer", True, False)], [("integer", "0")]], + [[(9, "integer", True, False)], [("integer", None)], [(1,)]], + [[(9, "integer", True, False)], [("integer", None)], [], []], + ): + with self.subTest(readbacks=readbacks): + self.cr.fetchall.side_effect = readbacks + with self.assertRaises(RuntimeError): + SALE["preserve_lead_time"](self.cr) + + def test_already_converted_field_keeps_its_defaults(self) -> None: + self.cr.fetchall.side_effect = [[(9, "integer", True, True)], [("jsonb", None)]] + SALE["preserve_lead_time"](self.cr) + self.assertEqual(self.cr.fetchall.call_count, 2) + + def test_other_crossings_do_not_change_data(self) -> None: + with patch.dict(os.environ, {}, clear=True): + for version in (None, "18.0.1.0", "20.0.1.0"): + SALE["migrate"](self.cr, version) + self.cr.execute.assert_not_called()