diff --git a/docker/scripts/prepare_core_references_20.py b/docker/scripts/prepare_core_references_20.py index 2f7c75c..bc6a6a3 100644 --- a/docker/scripts/prepare_core_references_20.py +++ b/docker/scripts/prepare_core_references_20.py @@ -29,6 +29,8 @@ 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") +WEBSITE_ATTRIBUTE_DATA_FILE = Path("/odoo/addons/website_sale/views/product_attribute_category_views.xml") +WEBSITE_ATTRIBUTE_ACTION = "product_attribute_category_action" HR_TYPE_FIELD_OWNERS = ("hr.job", "hr.version", "hr.employee") UNTRACKED_PUBLIC_VIEW_DEFAULTS = {"track": (None, False), "visibility": (None, "")} _logger = logging.getLogger(__name__) @@ -231,6 +233,50 @@ def _stock_valuation_action_plan(cr: Any) -> Reference | None: return source +def _website_attribute_action_plan(cr: Any) -> Reference | None: + cr.execute("SELECT state FROM ir_module_module WHERE name = 'website_sale_comparison'") + installed = cr.fetchone() + if installed is None or installed[0] not in {"installed", "to upgrade"}: + return None + name = WEBSITE_ATTRIBUTE_ACTION + source = _require_source(cr, "website_sale_comparison", name, "ir.actions.act_window") + target = _check_target(cr, name, source, module="website_sale") + if (target is not None and target.noupdate) or (source.noupdate and target is None): + raise RuntimeError("Protected Website attribute action ownership") + fields = _hr_native_fields(WEBSITE_ATTRIBUTE_DATA_FILE, name, source.model) + if set(fields) != {"name", "res_model", "path", "view_mode", "help"} or any( + field.attrib != ({"name": key, "type": "html"} if key == "help" else {"name": key}) for key, field in fields.items() + ): + raise RuntimeError("Unsupported native Website attribute action fields") + native = {key: (field.text or "").strip() for key, field in fields.items()} + if native["res_model"] != "product.attribute.category" or not all(native[key] for key in ("name", "path", "view_mode")): + raise RuntimeError("Unsupported native Website attribute action") + actions = _rows(cr, "SELECT * FROM ir_act_window WHERE id = %s FOR UPDATE", (source.record_id,)) + if len(actions) != 1: + raise RuntimeError("Missing Website attribute action") + action = actions[0] + 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 Website attribute action storage") + cr.execute("SELECT id FROM ir_act_window WHERE path = %s FOR UPDATE", (native["path"],)) + if cr.fetchall() != [(source.record_id,)]: + raise RuntimeError("Changed Website attribute action path") + if action["type"] != source.model or any(action[key] != native[key] for key in ("res_model", "path", "view_mode")): + raise RuntimeError("Customized Website attribute action fields") + native_help = "".join(ElementTree.tostring(child, encoding="unicode") for child in fields["help"]) + try: + if ( + not isinstance(action["name"], dict) + or action["name"].get("en_US") != native["name"] + or not isinstance(action["help"], dict) + or _action_help(action["help"]["en_US"]) != _action_help(native_help) + ): + raise ValueError("Changed action text") + except (KeyError, ValueError, TypeError, ElementTree.ParseError) as error: + raise RuntimeError("Customized Website attribute action text") from error + return source + + def _bind_moved_action(cr: Any, source: Reference, name: str, source_module: str, target_module: str) -> None: if _reference(cr, target_module, name) is None: _bind_target(cr, name, Reference(source.model, source.record_id, False), module=target_module) @@ -1753,6 +1799,7 @@ def website_plan() -> tuple[Reference | None, Reference | None]: lunches = diagnostics.check("Calendar lunches", lambda: _lunch_plan(cr), [], cursor=cr) policies = diagnostics.check("Policy identities", lambda: _policy_xmlid_plan(cr), [], cursor=cr) stock_valuation = diagnostics.check("Stock valuation action", lambda: _stock_valuation_action_plan(cr), None, cursor=cr) + website_attribute = diagnostics.check("Website attribute action", lambda: _website_attribute_action_plan(cr), None, cursor=cr) stock_views = diagnostics.check("Stock product views", lambda: _stock_product_view_plan(cr), {}, cursor=cr) calendar_views = diagnostics.check("Calendar preferences", lambda: _calendar_view_plan(cr), [], cursor=cr) hr_preferences = diagnostics.check("HR preferences", lambda: _hr_preferences_view_plan(cr), None, cursor=cr) @@ -1843,5 +1890,7 @@ def private_plan() -> Reference: _release_policy_xmlids(cr, policies) if stock_valuation is not None: _bind_moved_action(cr, stock_valuation, "action_report_stock_valuation", "stock_account", "account") + if website_attribute is not None: + _bind_moved_action(cr, website_attribute, WEBSITE_ATTRIBUTE_ACTION, "website_sale_comparison", "website_sale") for name, source in hr_moved.items(): _bind_moved_action(cr, source, name, "hr_org_chart", "hr") diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index 1be22e1..093c043 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -698,6 +698,13 @@ Notes Original business attachments remain strict. The saved19 driver recognizes new extra-image copies only when database ownership, source checksums and actual bytes match their own template or variant; rollback leaves no separate copy journal. + The attribute-category action moves from `website_sale_comparison` to + `website_sale` by binding the new XML ID to the retained window action and + protecting its old alias. Action IDs, incoming links, custom aliases, context + and translations remain intact. Preparation compares the fields native XML + will overwrite with the selected target definition; conflicting ownership, + storage, paths or edited native values report failure before binding. + Other action types keep their independently unique paths. 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 diff --git a/tests/test_website_attribute_action_20.py b/tests/test_website_attribute_action_20.py new file mode 100644 index 0000000..e47df67 --- /dev/null +++ b/tests/test_website_attribute_action_20.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch +from xml.etree import ElementTree + +from test_core_references_20 import CoreReferenceFixture, bridge +from test_hr_org_chart_action_20 import WindowActionCursor + + +class WebsiteAttributeActionTests(CoreReferenceFixture): + def setUp(self) -> None: + super().setUp() + self.cursor = WindowActionCursor(self.connection) + self.name = bridge.WEBSITE_ATTRIBUTE_ACTION + self.native = { + "name": "Attribute categories", + "res_model": "product.attribute.category", + "path": "attribute-categories", + "view_mode": "list", + } + self.help = "

Organize product attributes.

" + root = ElementTree.Element("odoo") + record = ElementTree.SubElement(root, "record", id=self.name, model="ir.actions.act_window") + for name, value in self.native.items(): + ElementTree.SubElement(record, "field", name=name).text = value + help_field = ElementTree.SubElement(record, "field", name="help", type="html") + help_field.extend(ElementTree.fromstring("
" + self.help + "
")) + self.target = Path(self.directory.name) / "category.xml" + ElementTree.ElementTree(root).write(self.target) + patch.dict(vars(bridge), {"WEBSITE_ATTRIBUTE_DATA_FILE": self.target}).start() + self.connection.executescript( + "INSERT INTO ir_module_module VALUES ('website_sale_comparison','installed','19.0.1.0');" + "INSERT INTO ir_module_module VALUES ('website_sale','installed','19.0.1.0');" + "CREATE TABLE ir_act_window (id INTEGER PRIMARY KEY, name TEXT, type TEXT, res_model TEXT, path TEXT UNIQUE, " + "view_mode TEXT, help TEXT, context TEXT, write_uid INTEGER);" + "CREATE TABLE other_actions (storage TEXT, id INTEGER, path TEXT);" + "CREATE VIEW ir_actions AS SELECT 'ir_act_window' AS storage,id,path FROM ir_act_window UNION ALL SELECT * FROM other_actions;" + "CREATE TABLE action_links (action INTEGER REFERENCES ir_act_window(id), xmlid TEXT, url TEXT);" + ) + self.action = self.connection.execute( + "INSERT INTO ir_act_window(name,type,res_model,path,view_mode,help,context,write_uid) " + "VALUES (?,'ir.actions.act_window',?,?,?,?,?,59)", + ( + json.dumps({"en_US": self.native["name"]}), + self.native["res_model"], + self.native["path"], + self.native["view_mode"], + json.dumps({"en_US": self.help}), + "{'retained_business_context': True}", + ), + ).lastrowid + assert self.action is not None + self.xmlid("website_sale_comparison", self.name, "ir.actions.act_window", self.action, False) + self.xmlid("custom", "retained_category", "ir.actions.act_window", self.action, True) + self.connection.execute( + "INSERT INTO action_links VALUES (?,?,?)", + (self.action, f"website_sale_comparison.{self.name}", "/odoo/" + self.native["path"]), + ) + + def snapshot(self) -> dict[str, list[tuple]]: + return super().snapshot() | { + table: self.connection.execute("SELECT * FROM " + table + " ORDER BY rowid").fetchall() + for table in ("ir_act_window", "other_actions", "action_links") + } + + def native_load(self) -> None: + target = bridge._reference(self.cursor, "website_sale", self.name) + if target is None: + self.connection.execute("INSERT INTO ir_act_window(path) VALUES (?)", (self.native["path"],)) + return + self.assertEqual(target.record_id, self.action) + self.assertFalse(target.noupdate) + + def test_native_loading_reuses_action_preserves_links_custom_alias_and_replays(self) -> None: + before = self.snapshot() + self.apply() + self.native_load() + prepared = self.snapshot() + self.assertEqual(prepared["ir_act_window"], before["ir_act_window"]) + self.assertEqual(prepared["action_links"], before["action_links"]) + source = bridge._reference(self.cursor, "website_sale_comparison", self.name) + self.assertEqual(source.record_id, self.action) + self.assertTrue(source.noupdate) + self.apply() + self.assertEqual(self.snapshot(), prepared) + + def test_conflicting_destination_changed_native_values_and_protected_source_keep_all_state(self) -> None: + changes = ( + ("UPDATE ir_act_window SET name=?", (json.dumps({"en_US": "My categories"}),)), + ("UPDATE ir_act_window SET help=?", (json.dumps({"en_US": "

Keep this help.

"}),)), + ("UPDATE ir_act_window SET res_model='custom.category'", ()), + ("UPDATE ir_act_window SET view_mode='form'", ()), + ("UPDATE ir_act_window SET path='retained-path'", ()), + ("UPDATE ir_model_data SET noupdate=1 WHERE module='website_sale_comparison'", ()), + ("UPDATE ir_model_data SET model='ir.actions.server' WHERE module='website_sale_comparison'", ()), + ("UPDATE ir_model_data SET res_id=-1 WHERE module='website_sale_comparison'", ()), + ( + "INSERT INTO ir_model_data(module,name,model,res_id,noupdate) VALUES ('website_sale',?,'ir.actions.act_window',999,0)", + (self.name,), + ), + ( + "INSERT INTO ir_model_data(module,name,model,res_id,noupdate) VALUES ('website_sale',?,'ir.actions.server',?,0)", + (self.name, self.action), + ), + ( + "INSERT INTO ir_model_data(module,name,model,res_id,noupdate) VALUES ('website_sale',?,'ir.actions.act_window',?,1)", + (self.name, self.action), + ), + ) + for query, values in changes: + with self.subTest(query=query): + self.connection.execute("SAVEPOINT modified") + self.connection.execute(query, values) + before = self.snapshot() + with self.assertRaises(RuntimeError): + self.apply() + self.assertEqual(self.snapshot(), before) + self.connection.execute("ROLLBACK TO modified") + self.connection.execute("RELEASE modified") + + def test_same_path_in_another_action_type_is_not_a_window_collision(self) -> None: + self.connection.execute("INSERT INTO other_actions VALUES ('ir_act_server',999,?)", (self.native["path"],)) + self.apply() + self.native_load() + + def test_native_definition_change_is_reported_before_effects(self) -> None: + document = ElementTree.parse(self.target) + record = document.find("record") + assert record is not None + ElementTree.SubElement(record, "field", name="domain").text = "[]" + document.write(self.target) + before = self.snapshot() + with self.assertRaises(RuntimeError): + self.apply() + self.assertEqual(self.snapshot(), before) + + def test_existing_translations_and_uninstalled_source_are_preserved(self) -> None: + self.connection.execute( + "UPDATE ir_act_window SET name=?,help=?", + ( + json.dumps({"en_US": self.native["name"], "fr_FR": "Mes catégories"}), + json.dumps({"en_US": self.help, "fr_FR": "

Texte conservé.

"}), + ), + ) + before = self.snapshot()["ir_act_window"] + self.connection.execute("UPDATE ir_module_module SET state='uninstalled' WHERE name='website_sale_comparison'") + self.apply() + self.assertIsNone(bridge._reference(self.cursor, "website_sale", self.name)) + self.connection.execute("UPDATE ir_module_module SET state='installed' WHERE name='website_sale_comparison'") + self.apply() + self.native_load() + self.assertEqual(self.snapshot()["ir_act_window"], before)