Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docker/scripts/prepare_core_references_20.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
7 changes: 7 additions & 0 deletions docs/tooling/workspace-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions tests/test_website_attribute_action_20.py
Original file line number Diff line number Diff line change
@@ -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 = "<p>Organize product attributes.</p>"
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("<div>" + self.help + "</div>"))
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": "<p>Keep this help.</p>"}),)),
("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": "<p>Texte conservé.</p>"}),
),
)
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)
Loading