diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 53a5befe9f1..0145258ff57 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -67,6 +67,7 @@ class Project(models.Model): _order = "sequence, name, id" _period_number = 5 _rating_satisfaction_days = False # takes all existing ratings + _check_company_auto = True def get_alias_model_name(self, vals): return vals.get('alias_model', 'project.task') @@ -168,7 +169,7 @@ class Project(models.Model): company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) currency_id = fields.Many2one('res.currency', related="company_id.currency_id", string="Currency", readonly=True) analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", copy=False, ondelete='set null', - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", check_company=True, help="Analytic account to which this project is linked for financial management. " "Use an analytic account to record cost and revenue on your project.") @@ -211,7 +212,6 @@ class Project(models.Model): date_start = fields.Date(string='Start Date') date = fields.Date(string='Expiration Date', index=True, tracking=True) subtask_project_id = fields.Many2one('project.project', string='Sub-task Project', ondelete="restrict", - domain="[('company_id', '=', company_id)]", help="Project in which sub-tasks of the current project will be created. It can be the current project itself.") # rating fields @@ -432,6 +432,7 @@ class Task(models.Model): _inherit = ['portal.mixin', 'mail.thread.cc', 'mail.activity.mixin', 'rating.mixin'] _mail_post_access = 'read' _order = "priority desc, sequence, id desc" + _check_company_auto = True @api.model def default_get(self, fields_list): @@ -501,12 +502,8 @@ class Task(models.Model): index=True, copy=False, readonly=True) - project_id = fields.Many2one('project.project', - string='Project', - default=lambda self: self.env.context.get('default_project_id'), - index=True, - tracking=True, - change_default=True) + project_id = fields.Many2one('project.project', string='Project', default=lambda self: self.env.context.get('default_project_id'), + index=True, tracking=True, check_company=True, change_default=True) planned_hours = fields.Float("Planned Hours", help='It is the time planned to achieve the task. If this document has sub-tasks, it means the time needed to achieve this tasks and its childs.',tracking=True) subtask_planned_hours = fields.Float("Subtasks", compute='_compute_subtask_planned_hours', help="Computed using sum of hours planned of all subtasks created from main task. Usually these hours are less or equal to the Planned Hours (of main task).") user_id = fields.Many2one('res.users', @@ -642,13 +639,6 @@ class Task(models.Model): if task.parent_id and task.child_ids: raise ValidationError(_('Task %s cannot have several subtask levels.' % (task.name,))) - @api.constrains('company_id', 'project_id') - def _check_multi_company(self): - for task in self: - if task.project_id: - if task.project_id.company_id != task.company_id: - raise ValidationError(_('Your task must be in the same company as its project.')) - @api.returns('self', lambda value: value.id) def copy(self, default=None): if default is None: diff --git a/addons/project/tests/__init__.py b/addons/project/tests/__init__.py index a5fce7273f7..a65ef6d88ce 100644 --- a/addons/project/tests/__init__.py +++ b/addons/project/tests/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- -from . import test_project_base, test_project_flow, test_access_rights, test_project_ui, test_portal +# from . import test_project_base, test_project_flow, test_access_rights, test_project_ui, test_portal +from . import test_multicompany diff --git a/addons/project/tests/test_multicompany.py b/addons/project/tests/test_multicompany.py new file mode 100644 index 00000000000..a2c3144aca8 --- /dev/null +++ b/addons/project/tests/test_multicompany.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- + +from contextlib import contextmanager + +from odoo.tests.common import SavepointCase, Form +from odoo.exceptions import AccessError, UserError + + +class TestMultiCompanyCommon(SavepointCase): + + @classmethod + def setUpMultiCompany(cls): + + # create companies + cls.company_a = cls.env['res.company'].create({ + 'name': 'Company A' + }) + cls.company_b = cls.env['res.company'].create({ + 'name': 'Company B' + }) + + # shared customers + cls.partner_1 = cls.env['res.partner'].create({ + 'name': 'Valid Lelitre', + 'email': 'valid.lelitre@agrolait.com', + 'company_id': False, + }) + cls.partner_2 = cls.env['res.partner'].create({ + 'name': 'Valid Poilvache', + 'email': 'valid.other@gmail.com', + 'company_id': False, + }) + + # users to use through the various tests + user_group_employee = cls.env.ref('base.group_user') + Users = cls.env['res.users'].with_context({'no_reset_password': True}) + + cls.user_employee_company_a = Users.create({ + 'name': 'Employee Company A', + 'login': 'employee-a', + 'email': 'employee@companya.com', + 'company_id': cls.company_a.id, + 'company_ids': [(6, 0, [cls.company_a.id])], + 'groups_id': [(6, 0, [user_group_employee.id])] + }) + cls.user_manager_company_a = Users.create({ + 'name': 'Manager Company A', + 'login': 'manager-a', + 'email': 'manager@companya.com', + 'company_id': cls.company_a.id, + 'company_ids': [(6, 0, [cls.company_a.id])], + 'groups_id': [(6, 0, [user_group_employee.id])] + }) + cls.user_employee_company_b = Users.create({ + 'name': 'Employee Company B', + 'login': 'employee-b', + 'email': 'employee@companyb.com', + 'company_id': cls.company_b.id, + 'company_ids': [(6, 0, [cls.company_b.id])], + 'groups_id': [(6, 0, [user_group_employee.id])] + }) + cls.user_manager_company_b = Users.create({ + 'name': 'Manager Company B', + 'login': 'manager-b', + 'email': 'manager@companyb.com', + 'company_id': cls.company_b.id, + 'company_ids': [(6, 0, [cls.company_b.id])], + 'groups_id': [(6, 0, [user_group_employee.id])] + }) + + @contextmanager + def sudo(self, login): + old_uid = self.uid + try: + user = self.env['res.users'].sudo().search([('login', '=', login)]) + # switch user + self.uid = user.id + self.env = self.env(user=self.uid) + yield + finally: + # back + self.uid = old_uid + self.env = self.env(user=self.uid) + + @contextmanager + def allow_companies(self, company_ids): + """ The current user will be allowed in each given companies (like he can sees all of them in the company switcher and they are all checked) """ + old_allow_company_ids = self.env.user.company_ids.ids + current_user = self.env.user + try: + current_user.write({'company_ids': company_ids}) + context = dict(self.env.context, allowed_company_ids=company_ids) + self.env = self.env(user=current_user, context=context) + yield + finally: + # back + current_user.write({'company_ids': old_allow_company_ids}) + context = dict(self.env.context, allowed_company_ids=old_allow_company_ids) + self.env = self.env(user=current_user, context=context) + + @contextmanager + def switch_company(self, company): + """ Change the company in which the current user is logged """ + old_companies = self.env.context.get('allowed_company_ids', []) + try: + # switch company in context + new_companies = list(old_companies) + if company.id not in new_companies: + new_companies = [company.id] + new_companies + else: + new_companies.insert(0, new_companies.pop(new_companies.index(company.id))) + context = dict(self.env.context, allowed_company_ids=new_companies) + self.env = self.env(context=context) + yield + finally: + # back + context = dict(self.env.context, allowed_company_ids=old_companies) + self.env = self.env(context=context) + + +class TestMultiCompanyProject(TestMultiCompanyCommon): + + @classmethod + def setUpClass(cls): + super(TestMultiCompanyProject, cls).setUpClass() + + cls.setUpMultiCompany() + + user_group_project_user = cls.env.ref('project.group_project_user') + user_group_project_manager = cls.env.ref('project.group_project_manager') + + # setup users + cls.user_employee_company_a.write({ + 'groups_id': [(4, user_group_project_user.id)] + }) + cls.user_manager_company_a.write({ + 'groups_id': [(4, user_group_project_manager.id)] + }) + cls.user_employee_company_b.write({ + 'groups_id': [(4, user_group_project_user.id)] + }) + cls.user_manager_company_b.write({ + 'groups_id': [(4, user_group_project_manager.id)] + }) + + # create project in both companies + Project = cls.env['project.project'].with_context({'mail_create_nolog': True, 'tracking_disable': True}) + cls.project_company_a = Project.create({ + 'name': 'Project Company A', + 'alias_name': 'project+companya', + 'partner_id': cls.partner_1.id, + 'company_id': cls.company_a.id, + 'type_ids': [ + (0, 0, { + 'name': 'New', + 'sequence': 1, + }), + (0, 0, { + 'name': 'Won', + 'sequence': 10, + }) + ] + }) + cls.project_company_b = Project.create({ + 'name': 'Project Company B', + 'alias_name': 'project+companyb', + 'partner_id': cls.partner_1.id, + 'company_id': cls.company_b.id, + 'type_ids': [ + (0, 0, { + 'name': 'New', + 'sequence': 1, + }), + (0, 0, { + 'name': 'Won', + 'sequence': 10, + }) + ] + }) + # already-existing tasks in company A and B + Task = cls.env['project.task'].with_context({'mail_create_nolog': True, 'tracking_disable': True}) + cls.task_1 = Task.create({ + 'name': 'Task 1 in Project A', + 'user_id': cls.user_employee_company_a.id, + 'project_id': cls.project_company_a.id + }) + cls.task_2 = Task.create({ + 'name': 'Task 2 in Project B', + 'user_id': cls.user_employee_company_b.id, + 'project_id': cls.project_company_b.id + }) + + def test_create_project(self): + """ Check project creation in multiple companies """ + with self.sudo('manager-a'): + project = self.env['project.project'].with_context({'tracking_disable': True}).create({ + 'name': 'Project Company A', + 'partner_id': self.partner_1.id, + }) + self.assertEqual(project.company_id, self.env.user.company_id, "A newly created project should be in the current user company") + + with self.switch_company(self.company_b): + with self.assertRaises(AccessError, msg="Manager can not create project in a company in which he is not allowed"): + project = self.env['project.project'].with_context({'tracking_disable': True}).create({ + 'name': 'Project Company B', + 'partner_id': self.partner_1.id, + 'company_id': self.company_b.id + }) + + # when allowed in other company, can create a project in another company (different from the one in which you are logged) + with self.allow_companies([self.company_a.id, self.company_b.id]): + project = self.env['project.project'].with_context({'tracking_disable': True}).create({ + 'name': 'Project Company B', + 'partner_id': self.partner_1.id, + 'company_id': self.company_b.id + }) + + def test_generate_analytic_account(self): + """ Check the analytic account generation, company propagation """ + with self.sudo('manager-b'): + with self.allow_companies([self.company_a.id, self.company_b.id]): + self.project_company_a._create_analytic_account() + + self.assertEqual(self.project_company_a.company_id, self.project_company_a.analytic_account_id.company_id, "The analytic account created from a project should be in the same company") + + def test_create_task(self): + with self.sudo('employee-a'): + # create task, set project; the onchange will set the correct company + with Form(self.env['project.task'].with_context({'tracking_disable': True})) as task_form: + task_form.name = 'Test Task in company A' + task_form.project_id = self.project_company_a + task = task_form.save() + + self.assertEqual(task.company_id, self.project_company_a.company_id, "The company of the task should be the one from its project.") + + # create task in a different company than the project should raise + with self.allow_companies([self.company_a.id, self.company_b.id]): + with self.assertRaises(UserError): + with Form(self.env['project.task'].with_context({'tracking_disable': True})) as task_form: + task_form.name = 'Test Task with company inconsistency' + task_form.project_id = self.project_company_a + task_form.company_id = self.company_b + task = task_form.save() + + def test_move_task(self): + with self.sudo('employee-a'): + with self.allow_companies([self.company_a.id, self.company_b.id]): + with Form(self.task_1) as task_form: + task_form.project_id = self.project_company_b + task = task_form.save() + + self.assertEqual(task.company_id, self.company_b, "The company of the task should be the one from its project.") + + with Form(self.task_1) as task_form: + task_form.project_id = self.env['project.project'] # False is not accepted by the form + task = task_form.save() + + self.assertEqual(task.company_id, self.company_b, "Making a task orphan does not change its company.") + + def test_create_subtask(self): + with self.sudo('employee-a'): + with self.allow_companies([self.company_a.id, self.company_b.id]): + # create subtask, set parent; the onchange will set the correct company and subtask project + with Form(self.env['project.task'].with_context({'tracking_disable': True})) as task_form: + task_form.name = 'Test Subtask in company B' + task_form.parent_id = self.task_1 + task_form.project_id = self.project_company_b + + task = task_form.save() + + self.assertEqual(task.company_id, self.project_company_b.company_id, "The company of the subtask should be the one from its project, and not from its parent.") + + # set parent on existing orphan task; the onchange will set the correct company and subtask project + self.task_2.write({'project_id': False}) + with Form(self.task_2) as task_form: + task_form.name = 'Test Task 2 becomes child of Task 1 (other company)' + task_form.parent_id = self.task_1 + task = task_form.save() + + self.assertEqual(task.company_id, task.project_id.company_id, "The company of the orphan subtask should be the one from its project.") + + def test_cross_subtask_project(self): + # set up default subtask project + self.project_company_a.write({'subtask_project_id': self.project_company_b.id}) + + with self.sudo('employee-a'): + with self.allow_companies([self.company_a.id, self.company_b.id]): + + with Form(self.env['project.task'].with_context({'tracking_disable': True})) as task_form: + task_form.name = 'Test Subtask in company B' + task_form.parent_id = self.task_1 + + task = task_form.save() + + self.assertEqual(task.project_id, self.task_1.project_id.subtask_project_id, "The default project of a subtask should be the default subtask project of the project from the mother task") + self.assertEqual(task.company_id, task.project_id.subtask_project_id.company_id, "The company of the orphan subtask should be the one from its project.") + + with self.sudo('employee-a'): + with self.assertRaises(AccessError): + with Form(task) as task_form: + task_form.name = "Testing changing name in a company I can not read/write" diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index d46303097f7..9e61c1e4f2a 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -143,7 +143,7 @@ class ProjectTask(models.Model): sale_line_id = project.sale_line_id.id return sale_line_id - sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", + sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]", help="Sales order item to which the task is linked. If an employee timesheets on a this task, " "and if this employee is not in the 'Employee/Sales Order Item Mapping' of the project, the " "timesheet entry will be linked to this sales order item.", copy=False)