diff --git a/accounting_pdf_reports/models/account_move_line.py b/accounting_pdf_reports/models/account_move_line.py index c28d0689..1a5c51da 100644 --- a/accounting_pdf_reports/models/account_move_line.py +++ b/accounting_pdf_reports/models/account_move_line.py @@ -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 diff --git a/om_account_budget/models/account_budget.py b/om_account_budget/models/account_budget.py index 72068795..7a10ac69 100644 --- a/om_account_budget/models/account_budget.py +++ b/om_account_budget/models/account_budget.py @@ -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'] @@ -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 @@ -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 @@ -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( diff --git a/om_account_budget/tests/__init__.py b/om_account_budget/tests/__init__.py new file mode 100644 index 00000000..ca1a742e --- /dev/null +++ b/om_account_budget/tests/__init__.py @@ -0,0 +1 @@ +from . import test_account_budget diff --git a/om_account_budget/tests/test_account_budget.py b/om_account_budget/tests/test_account_budget.py new file mode 100644 index 00000000..a5dae6c7 --- /dev/null +++ b/om_account_budget/tests/test_account_budget.py @@ -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) diff --git a/om_account_followup/wizard/followup_print.py b/om_account_followup/wizard/followup_print.py index b76ba33d..079e9bd1 100644 --- a/om_account_followup/wizard/followup_print.py +++ b/om_account_followup/wizard/followup_print.py @@ -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 '