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
4 changes: 2 additions & 2 deletions account_operating_unit/models/account_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ def _default_operating_unit_id(self):
def _onchange_operating_unit(self):
if self.operating_unit_id and (
not self.journal_id
or self.journal_id.operating_unit_id != self.operating_unit_id
or self.journal_id.sudo().operating_unit_id != self.operating_unit_id
):
journal = self.env["account.journal"].search(
[("type", "=", self.journal_id.type)]
[("type", "=", self.journal_id.sudo().type)]
)
jf = journal.filtered(
lambda aj: aj.operating_unit_id == self.operating_unit_id
Expand Down
1 change: 1 addition & 0 deletions account_operating_unit/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from . import test_account_operating_unit
from . import test_invoice_operating_unit
from . import test_cross_ou_journal_entry
from . import test_onchange_operating_unit_access
from . import test_operating_unit_security
from . import test_payment_operating_unit
from . import test_account_reconcile
166 changes: 166 additions & 0 deletions account_operating_unit/tests/test_onchange_operating_unit_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# © 2026 BITVAX
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
"""Bug: account.move._onchange_operating_unit raises AccessError when
the journal currently set on the move belongs to an Operating Unit the
acting user cannot read.

This happens because the handler reads ``self.journal_id.type`` and
``self.journal_id.operating_unit_id`` without ``sudo()``. The
account_operating_unit security rule
``ir_rule_account_journal_allowed_operating_units`` denies the read,
and Odoo raises AccessError.

The fix is to use ``.sudo()`` for those two attribute reads. The
journal still gets selected respecting OU because the search done
later filters by operating_unit_id afterwards.
"""

from odoo.exceptions import AccessError
from odoo.models import Command
from odoo.tests import tagged

from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.operating_unit.tests.common import OperatingUnitCommon


@tagged("post_install", "-at_install")
class TestOnchangeOperatingUnitAccess(AccountTestInvoicingCommon, OperatingUnitCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()

# Move demo OUs into the test company
(cls.ou1 | cls.b2b | cls.b2c).sudo().write({"company_id": cls.company.id})

# The test admin user needs full OU access for setup
cls.env.user.sudo().write(
{
"groups_id": [
Command.link(
cls.env.ref("operating_unit.group_manager_operating_unit").id
),
],
"operating_unit_ids": [
Command.link(cls.ou1.id),
Command.link(cls.b2b.id),
],
"default_operating_unit_id": cls.ou1.id,
"company_ids": [Command.link(cls.company.id)],
"company_id": cls.company.id,
}
)

# user1 is restricted to B2B only — REMOVES the manager group
# so its computed operating_unit_ids is restricted by
# assigned_operating_unit_ids and the security rule actually
# bites.
cls.user1.write(
{
"groups_id": [
(
3,
cls.env.ref("operating_unit.group_manager_operating_unit").id,
),
Command.link(
cls.env.ref("operating_unit.group_multi_operating_unit").id
),
Command.link(cls.env.ref("account.group_account_invoice").id),
],
"assigned_operating_unit_ids": [(6, 0, [cls.b2b.id])],
"default_operating_unit_id": cls.b2b.id,
"company_id": cls.company.id,
"company_ids": [Command.link(cls.company.id)],
}
)

Journal = cls.env["account.journal"].sudo()
cls.purchase_journal_ou1 = Journal.create(
{
"name": "Vendor Bills OU1 (test_onchange)",
"code": "TONP1",
"type": "purchase",
"company_id": cls.company.id,
"operating_unit_id": cls.ou1.id,
}
)
cls.purchase_journal_b2b = Journal.create(
{
"name": "Vendor Bills B2B (test_onchange)",
"code": "TONPB",
"type": "purchase",
"company_id": cls.company.id,
"operating_unit_id": cls.b2b.id,
}
)

cls.expense_account = cls.env["account.account"].search(
[
("account_type", "=", "expense"),
("company_ids", "in", cls.company.ids),
],
limit=1,
)

def test_onchange_operating_unit_no_access_error(self):
"""user1 (B2B-only) calls _onchange_operating_unit on an
invoice that they CAN read (operating_unit_id = b2b) but
whose journal_id points to a journal user1 CANNOT read
(operating_unit_id = ou1). This is the production scenario:
a draft move that was previously linked to a journal the
current user lost access to. Without the sudo() fix the
handler raises AccessError reading journal.type.
"""
# Create the invoice as admin so we can place it in B2B
# (so user1 can read it) but force the journal to OU1
# (so user1 cannot read the journal). This bypasses the
# _check_journal_operating_unit constraint by using sudo()
# on the create.
invoice = (
self.env["account.move"]
.sudo()
.with_context(default_move_type="in_invoice")
.create(
{
"partner_id": self.partner1.id,
"operating_unit_id": self.b2b.id,
"journal_id": self.purchase_journal_b2b.id,
"invoice_line_ids": [
(
0,
0,
{
"name": "Line",
"quantity": 1,
"price_unit": 100.0,
"account_id": self.expense_account.id,
"tax_ids": [],
},
)
],
}
)
)
# Now sneak the OU1 journal onto the move via raw SQL to
# bypass _check_journal_operating_unit. This simulates a
# legacy / migrated move that ended up in this inconsistent
# state and the user is trying to fix it via the form.
self.env.cr.execute(
"UPDATE account_move SET journal_id = %s WHERE id = %s",
(self.purchase_journal_ou1.id, invoice.id),
)
invoice.invalidate_recordset(["journal_id"])

# Sanity: user1 can read the move (it's in B2B).
invoice.with_user(self.user1).read(["operating_unit_id"])
# Sanity: user1 cannot read the OU1 journal directly.
with self.assertRaises(AccessError):
self.purchase_journal_ou1.with_user(self.user1).type # noqa: B018

# The actual repro: trigger the onchange as user1.
invoice_as_user1 = invoice.with_user(self.user1)
try:
invoice_as_user1._onchange_operating_unit()
except AccessError as e:
self.fail(
f"user1 (B2B only) hit AccessError on " f"_onchange_operating_unit: {e}"
)
Loading