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
5 changes: 4 additions & 1 deletion accounting_pdf_reports/models/account_move_line.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import ast
from odoo.osv import expression
import warnings
with warnings.catch_warnings():
warnings.simplefilter('ignore', DeprecationWarning)
from odoo.osv import expression
from odoo import api, models, fields


Expand Down
30 changes: 12 additions & 18 deletions om_account_budget/models/account_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,8 @@ def _compute_practical_amount(self):
if acc_ids:
domain += [('general_account_id', 'in', acc_ids)]

where_query = analytic_line_obj._where_calc(domain)
analytic_line_obj._apply_ir_rules(where_query, 'read')
from_string, from_params = where_query.from_clause
where_string, where_params = where_query.where_clause
from_clause, where_clause, where_clause_params = from_string, where_string, from_params + where_params

select = "SELECT SUM(amount) from " + from_clause + " where " + where_clause
result = analytic_line_obj._read_group(domain, aggregates=['amount:sum'])
line.practical_amount = result[0][0] if result and result[0] else 0.0

else:
aml_obj = self.env['account.move.line']
Expand All @@ -187,16 +182,13 @@ def _compute_practical_amount(self):
('date', '>=', date_from),
('date', '<=', date_to)
]
where_query = aml_obj._where_calc(domain)
aml_obj._apply_ir_rules(where_query, 'read')
from_string, from_params = where_query.from_clause
where_string, where_params = where_query.where_clause
from_clause, where_clause, where_clause_params = from_string, where_string, from_params + where_params

select = "SELECT sum(credit)-sum(debit) from " + from_clause + " where " + where_clause

self.env.cr.execute(select, where_clause_params)
line.practical_amount = self.env.cr.fetchone()[0] or 0.0

result = aml_obj._read_group(domain, aggregates=['credit:sum', 'debit:sum'])
if result and result[0]:
credit_sum, debit_sum = result[0]
line.practical_amount = (credit_sum or 0.0) - (debit_sum or 0.0)
else:
line.practical_amount = 0.0

def _compute_theoritical_amount(self):
# beware: 'today' variable is mocked in the python tests and thus, its implementation matter
Expand All @@ -207,6 +199,8 @@ def _compute_theoritical_amount(self):
theo_amt = 0.00
else:
theo_amt = line.planned_amount
elif not line.date_from or not line.date_to:
theo_amt = 0.00
else:
line_timedelta = line.date_to - line.date_from
elapsed_timedelta = today - line.date_from
Expand All @@ -228,7 +222,7 @@ def _compute_percentage(self):
else:
line.percentage = 0.00

@api.constrains('general_budget_id', 'analytic_account_id')
@api.constrains('general_budget_id', 'analytic_account_id', 'crossovered_budget_id')
def _must_have_analytical_or_budgetary_or_both(self):
if not self.analytic_account_id and not self.general_budget_id:
raise ValidationError(
Expand Down
1 change: 1 addition & 0 deletions om_account_budget/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_account_budget
279 changes: 279 additions & 0 deletions om_account_budget/tests/test_account_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError, AccessError

class TestAccountBudget(TransactionCase):

@classmethod
def setUpClass(cls):
super(TestAccountBudget, cls).setUpClass()
cls.account_model = cls.env['account.account']
cls.budget_post_model = cls.env['account.budget.post']
cls.budget_model = cls.env['crossovered.budget']

# Create a test account
cls.test_account = cls.account_model.create({
'name': 'Test Account',
'code': '123456',
'account_type': 'expense',
})

def test_01_budget_post_creation(self):
"""Test the creation and validation of a budgetary position."""

# Scenario 1.1: Create without accounts should fail
with self.assertRaises(ValidationError):
self.budget_post_model.create({
'name': 'Empty Budget Post',
'account_ids': [(5, 0, 0)]
})

# Scenario 1.2: Create with accounts should succeed
budget_post = self.budget_post_model.create({
'name': 'Valid Budget Post',
'account_ids': [(4, self.test_account.id)]
})
self.assertTrue(budget_post.id)
self.assertEqual(len(budget_post.account_ids), 1)

# Scenario 1.3: Remove accounts from existing should fail
with self.assertRaises(ValidationError):
budget_post.write({'account_ids': [(5, 0, 0)]})

def test_02_budget_state_transitions(self):
"""Test the workflow and state transitions of a budget."""
budget = self.budget_model.create({
'name': 'Test Budget',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
# Scenario 2.1: Created budget is in draft state
self.assertEqual(budget.state, 'draft')

# Scenario 2.2: Test workflow transitions
budget.action_budget_confirm()
self.assertEqual(budget.state, 'confirm')

budget.action_budget_validate()
self.assertEqual(budget.state, 'validate')

budget.action_budget_done()
self.assertEqual(budget.state, 'done')

# Scenario 2.3: Test cancellation and reset to draft
budget.action_budget_cancel()
self.assertEqual(budget.state, 'cancel')

budget.action_budget_draft()
self.assertEqual(budget.state, 'draft')

def test_03_budget_lines_constraints(self):
"""Test constraints on budget lines (missing targets, dates outside budget)."""
budget = self.budget_model.create({
'name': 'Test Budget 2',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
budget_line_model = self.env['crossovered.budget.lines']

# Scenario 3.1: Line without budget position and analytic account should fail
with self.assertRaises(ValidationError):
budget_line_model.create({
'crossovered_budget_id': budget.id,
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'planned_amount': 1000,
})

# Create valid budget post
budget_post = self.budget_post_model.create({
'name': 'Valid Post for Lines',
'account_ids': [(4, self.test_account.id)]
})

# Scenario 3.2: Dates outside budget period
with self.assertRaises(ValidationError):
budget_line_model.create({
'crossovered_budget_id': budget.id,
'general_budget_id': budget_post.id,
'date_from': '2025-12-01', # Before budget start
'date_to': '2026-12-31',
'planned_amount': 1000,
})

with self.assertRaises(ValidationError):
budget_line_model.create({
'crossovered_budget_id': budget.id,
'general_budget_id': budget_post.id,
'date_from': '2026-01-01',
'date_to': '2027-01-31', # After budget end
'planned_amount': 1000,
})

# Scenario 3.3: Valid dates and targets should succeed
line = budget_line_model.create({
'crossovered_budget_id': budget.id,
'general_budget_id': budget_post.id,
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'planned_amount': 1000,
})
self.assertTrue(line.id)

def test_04_theoretical_amount(self):
"""Test the computation of theoretical amount based on elapsed days."""
from unittest.mock import patch
from odoo import fields

budget = self.budget_model.create({
'name': 'Theo Budget',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
budget_post = self.budget_post_model.create({
'name': 'Theo Post',
'account_ids': [(4, self.test_account.id)]
})

line = self.env['crossovered.budget.lines'].create({
'crossovered_budget_id': budget.id,
'general_budget_id': budget_post.id,
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'planned_amount': 3650, # 10/day for non-leap year 365 days
})

# Scenario 4.1: Today is before date_from
with patch('odoo.addons.om_account_budget.models.account_budget.fields.Date.today', return_value=fields.Date.to_date('2025-12-31')):
line._compute_theoritical_amount()
self.assertEqual(line.theoritical_amount, 0.0)

# Scenario 4.2: Today is in the middle
# 2026-01-31 is 30 days elapsed since 2026-01-01 (01-01 to 01-31 is 30 days diff)
with patch('odoo.addons.om_account_budget.models.account_budget.fields.Date.today', return_value=fields.Date.to_date('2026-01-31')):
line._compute_theoritical_amount()
self.assertAlmostEqual(line.theoritical_amount, 300.82, places=2) # 30 / 364 * 3650

# Scenario 4.3: Today is after date_to
with patch('odoo.addons.om_account_budget.models.account_budget.fields.Date.today', return_value=fields.Date.to_date('2027-01-01')):
line._compute_theoritical_amount()
self.assertEqual(line.theoritical_amount, 3650.0)

# Scenario 4.4: Paid date set
line.paid_date = '2026-06-30'
# Today is before paid_date -> 0
with patch('odoo.addons.om_account_budget.models.account_budget.fields.Date.today', return_value=fields.Date.to_date('2026-05-01')):
line._compute_theoritical_amount()
self.assertEqual(line.theoritical_amount, 0.0)

# Today is after paid_date -> full planned_amount
with patch('odoo.addons.om_account_budget.models.account_budget.fields.Date.today', return_value=fields.Date.to_date('2026-07-01')):
line._compute_theoritical_amount()
self.assertEqual(line.theoritical_amount, 3650.0)

def test_05_practical_amount(self):
"""Test the computation of practical amount from account move lines."""
budget = self.budget_model.create({
'name': 'Practical Budget',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
budget_post = self.budget_post_model.create({
'name': 'Practical Post',
'account_ids': [(4, self.test_account.id)]
})

line = self.env['crossovered.budget.lines'].create({
'crossovered_budget_id': budget.id,
'general_budget_id': budget_post.id,
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'planned_amount': 1000,
})

# Create account move within the dates
move = self.env['account.move'].create({
'move_type': 'entry',
'date': '2026-06-15',
'line_ids': [
(0, 0, {
'account_id': self.test_account.id,
'debit': 500.0,
'credit': 0.0,
'name': 'Test Expense',
}),
(0, 0, {
'account_id': self.env.company.account_journal_suspense_account_id.id or self.env['account.account'].search([], limit=1).id,
'debit': 0.0,
'credit': 500.0,
'name': 'Test Credit',
}),
]
})
move.action_post()

line._compute_practical_amount()
# practical_amount is credit - debit. Since we debited 500 on test_account, it's -500
self.assertEqual(line.practical_amount, -500.0)

line._compute_theoritical_amount()
line._compute_percentage()
# if theoretical != 0, percentage is practical / theoretical
# We don't assert the exact percentage because it depends on today's date during test run,
# but we can test if it doesn't crash.
self.assertIsNotNone(line.percentage)

def test_06_security_access(self):
"""Test that regular users cannot create budgets."""
# Create a regular user
user = self.env['res.users'].create({
'name': 'Regular User',
'login': 'regular_user_budget',
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])]
})

# User without accounting rights should not be able to create a budget
with self.assertRaises(AccessError):
self.budget_model.with_user(user).create({
'name': 'Unauthorized Budget',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})

# Add accounting rights
user.write({'group_ids': [(4, self.env.ref('account.group_account_user').id)]})

# Should now be able to create a budget
budget = self.budget_model.with_user(user).create({
'name': 'Authorized Budget',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
self.assertTrue(budget.id)

def test_07_multicompany_access(self):
"""Test multi-company security rules on budgets."""
company_a = self.env['res.company'].create({'name': 'Company A'})
company_b = self.env['res.company'].create({'name': 'Company B'})

budget_a = self.budget_model.create({
'name': 'Budget A',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': company_a.id,
})

# Create a user in Company B
user_b = self.env['res.users'].create({
'name': 'User B',
'login': 'user_b_budget',
'company_id': company_b.id,
'company_ids': [(6, 0, [company_b.id])],
'group_ids': [(6, 0, [
self.env.ref('base.group_user').id,
self.env.ref('account.group_account_user').id
])]
})

# User B should not be able to see Budget A
budgets_visible = self.budget_model.with_user(user_b).search([('id', '=', budget_a.id)])
self.assertFalse(budgets_visible)
2 changes: 1 addition & 1 deletion om_account_followup/wizard/followup_print.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def _get_followup(self):
related='followup_id.company_id')
email_conf = fields.Boolean('Send Email Confirmation')
email_subject = fields.Char('Email Subject', size=64,
default=_('Invoices Reminder'))
default=lambda self: _('Invoices Reminder'))
partner_lang = fields.Boolean(
'Send Email in Partner Language', default=True,
help='Do not change message text, if you want to send email in '
Expand Down