19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:56 +01:00
parent a2f74aefd8
commit 4a4d12c333
844 changed files with 212348 additions and 270090 deletions

View file

@ -4,16 +4,21 @@ from . import test_access_rights
from . import test_burndown_chart
from . import test_project_base
from . import test_project_config
from . import test_project_embedded_action_settings
from . import test_project_flow
from . import test_project_mail_features
from . import test_project_milestone
from . import test_project_profitability
from . import test_project_recurrence
from . import test_project_sharing
from . import test_project_sharing_portal_access
from . import test_project_sharing_ui
from . import test_project_stage_multicompany
from . import test_project_subtasks
from . import test_project_tags_filter
from . import test_project_task_type
from . import test_project_template
from . import test_project_template_ui
from . import test_project_ui
from . import test_project_update_access_rights
from . import test_project_update_flow
@ -21,8 +26,14 @@ from . import test_project_update_ui
from . import test_portal
from . import test_multicompany
from . import test_personal_stages
from . import test_res_config_settings
from . import test_task_dependencies
from . import test_task_follow
from . import test_task_templates
from . import test_task_templates_ui
from . import test_task_tracking
from . import test_project_report
from . import test_project_task_quick_create
from . import test_task_state
from . import test_project_task_mail_tracking_duration
from . import test_task_link_preview_name
from . import test_import_files

View file

@ -129,7 +129,8 @@ class TestCRUDVisibilityPortal(TestAccessRights):
self.project_pigs.message_subscribe(partner_ids=[self.env.user.partner_id.id])
self.task.flush_model()
self.task.invalidate_model()
self.task.with_user(self.env.user).name
with self.assertRaises(AccessError, msg=f"{self.env.user.name} should not be able to read the task"):
self.task.with_user(self.env.user).name
@users('Internal user')
def test_task_internal_read(self):
@ -207,8 +208,8 @@ class TestAllowedUsers(TestAccessRights):
self.project_pigs.message_unsubscribe(partner_ids=[self.user.partner_id.id])
self.assertIn(john.partner_id, self.task.message_partner_ids)
self.assertNotIn(john.partner_id, task.message_partner_ids)
# Unsubscribing to a project should not cause unsubscription of existing tasks in the project.
self.assertIn(self.user.partner_id, task.message_partner_ids)
# Unsubscribing to a project should unsubscribing of existing tasks in the project.
self.assertNotIn(self.user.partner_id, task.message_partner_ids)
self.assertNotIn(self.user.partner_id, self.task.message_partner_ids)
def test_visibility_changed(self):
@ -219,7 +220,7 @@ class TestAllowedUsers(TestAccessRights):
self.assertNotIn(self.portal.partner_id, self.task.message_partner_ids, "Portal user should have been removed from allowed users")
def test_write_task(self):
self.user.groups_id |= self.env.ref('project.group_project_user')
self.user.group_ids |= self.env.ref('project.group_project_user')
self.assertNotIn(self.user.partner_id, self.project_pigs.message_partner_ids)
self.task.message_subscribe(partner_ids=[self.user.partner_id.id])
self.project_pigs.invalidate_model()
@ -227,7 +228,7 @@ class TestAllowedUsers(TestAccessRights):
self.task.with_user(self.user).name = "I can edit a task!"
def test_no_write_project(self):
self.user.groups_id |= self.env.ref('project.group_project_user')
self.user.group_ids |= self.env.ref('project.group_project_user')
self.assertNotIn(self.user.partner_id, self.project_pigs.message_partner_ids)
with self.assertRaises(AccessError, msg="User should not be able to edit project"):
self.project_pigs.with_user(self.user).name = "I can't edit a task!"
@ -235,14 +236,14 @@ class TestAllowedUsers(TestAccessRights):
class TestProjectPortalCommon(TestProjectCommon):
def setUp(self):
super(TestProjectPortalCommon, self).setUp()
super().setUp()
self.user_noone = self.env['res.users'].with_context({'no_reset_password': True, 'mail_create_nosubscribe': True}).create({
'name': 'Noemie NoOne',
'login': 'noemie',
'email': 'n.n@example.com',
'signature': '--\nNoemie',
'notification_type': 'email',
'groups_id': [(6, 0, [])]})
'group_ids': [(6, 0, [])]})
self.task_3 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Test3', 'user_ids': self.user_portal, 'project_id': self.project_pigs.id})
@ -413,9 +414,34 @@ class TestAccessRightsPrivateTask(TestAccessRights):
with self.assertRaises(AccessError):
self.private_task.with_user(self.env.user).unlink()
def test_of_setting_root_user_on_private_task(self):
test_task = self.env['project.task'].create({
'name':'Test Private Task',
'user_ids': [Command.link(self.user_projectuser.id)]
})
self.assertNotEqual(test_task.user_ids, self.env.user, "Created private task should not have odoobot as asignee")
class TestAccessRightsInvitedUsers(TestAccessRights):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.project_pigs.privacy_visibility = 'invited_users'
cls.project_user = mail_new_test_user(cls.env, 'Project user', groups='project.group_project_user')
@users('admin')
def test_admin_access_invited_project(self):
self.assertFalse(self.project_pigs.collaborator_ids)
self.assertEqual(self.project_pigs.with_user(self.env.user).name, 'Pigs')
@users('Project user', 'Internal user', 'Portal user')
def test_other_users_access_invited_project(self):
with self.assertRaises(AccessError, msg="The user is not a follower of the project, he's not supposed to have access to the project."):
self.assertEqual(self.project_pigs.with_user(self.env.user).name, 'Pigs')
self.project_pigs.message_subscribe(partner_ids=[self.env.user.partner_id.id])
self.assertEqual(self.project_pigs.with_user(self.env.user).name, 'Pigs', "The user was set as a follower of the project, he's supposed to have access to the project.")
@users('admin')
def test_admin_access_invited_task(self):
self.assertEqual(self.task.with_user(self.env.user).name, 'Make the world a better place')
@users('Project user', 'Internal user', 'Portal user')
def test_other_users_access_invited_task(self):
with self.assertRaises(AccessError, msg="The user is not a follower of the project, he's not supposed to have access to the project."):
self.assertEqual(self.task.with_user(self.env.user).name, 'Make the world a better place')
self.task.message_subscribe(partner_ids=[self.env.user.partner_id.id])
self.assertEqual(self.task.with_user(self.env.user).name, 'Make the world a better place', "The user was set as a follower of the project, he's supposed to have access to the project.")

View file

@ -1,11 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from freezegun import freeze_time
from datetime import datetime
from odoo import Command
from odoo.osv.expression import AND, OR
from odoo.fields import Command, Domain
from odoo.tests.common import tagged, HttpCase
from .test_project_base import TestProjectCommon
@ -21,48 +19,39 @@ class TestBurndownChartCommon(TestProjectCommon):
super().setUpClass()
cls.current_year = datetime.now().year
create_date = datetime(cls.current_year - 1, 1, 1)
kanban_state_vals = {
"legend_blocked": 'Blocked',
"legend_done": 'Ready',
"legend_normal": 'In Progress'
}
Stage = cls.env['project.task.type']
cls.todo_stage = Stage.create({
'sequence': 1,
'name': 'TODO',
**kanban_state_vals,
})
cls.set_create_date('project_task_type', cls.todo_stage.id, create_date)
cls.in_progress_stage = Stage.create({
'sequence': 10,
'name': 'In Progress',
**kanban_state_vals,
})
cls.set_create_date('project_task_type', cls.in_progress_stage.id, create_date)
cls.testing_stage = Stage.create({
'sequence': 20,
'name': 'Testing',
**kanban_state_vals,
})
cls.set_create_date('project_task_type', cls.testing_stage.id, create_date)
cls.done_stage = Stage.create({
'sequence': 30,
'name': 'Done',
**kanban_state_vals,
})
cls.set_create_date('project_task_type', cls.done_stage.id, create_date)
cls.stages = cls.todo_stage + cls.in_progress_stage + cls.testing_stage + cls.done_stage
cls.project = cls.env['project.project'].create({
'name': 'Burndown Chart Test',
'privacy_visibility': 'employees',
'alias_name': 'project+burndown_chart',
'alias_name': 'project_burndown_chart',
'type_ids': [Command.link(stage_id) for stage_id in cls.stages.ids],
})
cls.set_create_date('project_project', cls.project.id, create_date)
cls.project.invalidate_model()
cls.milestone = cls.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Milestone',
'project_id': cls.project_pigs.id,
'project_id': cls.project.id,
})
cls.task_a = cls.env['project.task'].create({
'name': 'Task A',
@ -95,19 +84,19 @@ class TestBurndownChartCommon(TestProjectCommon):
cls.set_create_date('project_task', cls.task_e.id, create_date)
# Create a new task to check if a task without changing its stage is taken into account
task_f = cls.env['project.task'].create({
cls.task_f = cls.env['project.task'].create({
'name': 'Task F',
'priority': 0,
'project_id': cls.project.id,
'milestone_id': cls.milestone.id,
'stage_id': cls.todo_stage.id,
})
cls.set_create_date('project_task', task_f.id, datetime(cls.current_year - 1, 12, 20))
cls.set_create_date('project_task', cls.task_f.id, datetime(cls.current_year - 1, 12, 20))
cls.project_2 = cls.env['project.project'].create({
'name': 'Burndown Chart Test 2 mySearchTag',
'privacy_visibility': 'employees',
'alias_name': 'project+burndown_chart+2',
'alias_name': 'project_burndown_chart_2',
'type_ids': [Command.link(stage_id) for stage_id in cls.stages.ids],
})
cls.set_create_date('project_project', cls.project_2.id, create_date)
@ -126,6 +115,41 @@ class TestBurndownChartCommon(TestProjectCommon):
})
cls.set_create_date('project_task', cls.task_h.id, create_date)
cls.stage_1, cls.stage_2, cls.stage_3, cls.stage_4 = Stage.create([{
'sequence': 1,
'name': '1',
}, {
'sequence': 10,
'name': '2',
}, {
'sequence': 20,
'name': '3',
}, {
'sequence': 20,
'name': '4',
}])
cls.stages_bis = cls.stage_1 | cls.stage_2 | cls.stage_3 | cls.stage_4
cls.set_create_date('project_task_type', cls.stage_1.id, create_date)
cls.set_create_date('project_task_type', cls.stage_2.id, create_date)
cls.set_create_date('project_task_type', cls.stage_3.id, create_date)
cls.set_create_date('project_task_type', cls.stage_4.id, create_date)
cls.project_1 = cls.env['project.project'].create({
'name': 'Burndown Chart Test',
'privacy_visibility': 'employees',
'alias_name': 'project_burndown_chart_bis',
'type_ids': [Command.link(stage_id) for stage_id in cls.stages_bis.ids],
})
cls.set_create_date('project_project', cls.project_1.id, create_date)
cls.task_bis = cls.env['project.task'].create({
'name': 'Task',
'priority': 0,
'project_id': cls.project_1.id,
'stage_id': cls.stage_1.id,
})
cls.set_create_date('project_task', cls.task_bis.id, create_date)
cls.deleted_domain = Domain('project_id', '!=', False) & Domain('project_id', '=', cls.project_1.id)
# Precommit to have the records in db and allow to rollback at the end of test
cls.env.cr.flush()
@ -158,178 +182,254 @@ class TestBurndownChartCommon(TestProjectCommon):
cls.env.cr.flush()
with freeze_time('%s-08-01' % (cls.current_year - 1)):
cls.task_a.write({'stage_id': cls.done_stage.id})
cls.task_a.write({'stage_id': cls.done_stage.id, 'state': '1_done'})
cls.env.cr.flush()
with freeze_time('%s-09-10' % (cls.current_year - 1)):
cls.task_b.write({'stage_id': cls.done_stage.id})
cls.task_b.write({'stage_id': cls.done_stage.id, 'state': '1_done'})
cls.env.cr.flush()
with freeze_time('%s-10-05' % (cls.current_year - 1)):
cls.task_c.write({'stage_id': cls.done_stage.id})
cls.task_c.write({'stage_id': cls.done_stage.id, 'state': '1_done'})
cls.task_a.write({'state': '1_canceled'})
cls.env.cr.flush()
with freeze_time('%s-11-25' % (cls.current_year - 1)):
cls.task_d.write({'stage_id': cls.done_stage.id})
cls.task_d.write({'stage_id': cls.done_stage.id, 'state': '1_done'})
cls.task_b.write({'state': '1_canceled'})
cls.env.cr.flush()
with freeze_time('%s-12-12' % (cls.current_year - 1)):
cls.task_e.write({'stage_id': cls.done_stage.id})
cls.task_e.write({'stage_id': cls.done_stage.id, 'state': '1_done'})
cls.env.cr.flush()
with freeze_time('%s-12-24' % (cls.current_year - 1)):
cls.task_f.write({'state': '1_canceled'})
cls.env.cr.flush()
with freeze_time('%s-02-10' % (cls.current_year - 1)):
cls.task_bis.write({'stage_id': cls.stage_2.id})
cls.env.cr.flush()
with freeze_time('%s-03-10' % (cls.current_year - 1)):
(cls.task_bis).write({'stage_id': cls.stage_3.id})
cls.env.cr.flush()
with freeze_time('%s-04-10' % (cls.current_year - 1)):
(cls.task_bis).write({'stage_id': cls.stage_4.id})
cls.env.cr.flush()
class TestBurndownChart(TestBurndownChartCommon):
def map_read_group_result(self, read_group_result):
return {(res['date:month'], res['stage_id'][0]): res['__count'] for res in read_group_result if res['stage_id'][1]}
return {(res['date:month'][1], res['stage_id'][0]): int(res['__count']) for res in read_group_result if res['stage_id'][1]}
def map_read_group_is_closed_result(self, read_group_result):
return {(res['date:month'][1], res['is_closed']): int(res['__count']) for res in read_group_result}
def check_read_group_results(self, domain, expected_results_dict):
stages_dict = {stage.id: stage.name for stage in self.stages}
read_group_result = self.env['project.task.burndown.chart.report'].read_group(
domain, ['date', 'stage_id'], ['date:month', 'stage_id'], lazy=False)
read_group_result = self.env['project.task.burndown.chart.report'].formatted_read_group(
domain, ['date:month', 'stage_id'], ['__count'])
read_group_result_dict = self.map_read_group_result(read_group_result)
for (month, stage_id), __count in read_group_result_dict.items():
expected_count = expected_results_dict.get((month, stage_id), 100000)
self.assertEqual(
__count,
expected_count,
'In %s, the number of tasks should be equal to %s in %s stage.' % (month, expected_count, stages_dict.get(stage_id, 'Unknown'))
)
self.assertDictEqual(read_group_result_dict, expected_results_dict)
def check_read_group_is_closed_results(self, domain, expected_results_dict):
read_group_result = self.env['project.task.burndown.chart.report'].formatted_read_group(
domain, ['date:month', 'is_closed'], ['__count'])
read_group_result_dict = self.map_read_group_is_closed_result(read_group_result)
self.assertDictEqual(read_group_result_dict, expected_results_dict)
def test_burndown_chart(self):
burndown_chart_domain = [('display_project_id', '!=', False)]
burndown_chart_domain = [('project_id', '!=', False)]
project_domain = [('project_id', '=', self.project.id)]
# Check that we get the expected results for the complete data of `self.project`.
project_expected_dict = {
('January %s' % (self.current_year - 1), self.todo_stage.id): 5,
('January %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('January %s' % (self.current_year - 1), self.testing_stage.id): 0,
('January %s' % (self.current_year - 1), self.done_stage.id): 0,
('February %s' % (self.current_year - 1), self.todo_stage.id): 2,
('February %s' % (self.current_year - 1), self.in_progress_stage.id): 3,
('February %s' % (self.current_year - 1), self.testing_stage.id): 0,
('February %s' % (self.current_year - 1), self.done_stage.id): 0,
('March %s' % (self.current_year - 1), self.todo_stage.id): 0,
('March %s' % (self.current_year - 1), self.in_progress_stage.id): 5,
('March %s' % (self.current_year - 1), self.testing_stage.id): 0,
('March %s' % (self.current_year - 1), self.done_stage.id): 0,
('April %s' % (self.current_year - 1), self.todo_stage.id): 0,
('April %s' % (self.current_year - 1), self.in_progress_stage.id): 3,
('April %s' % (self.current_year - 1), self.testing_stage.id): 2,
('April %s' % (self.current_year - 1), self.done_stage.id): 0,
('May %s' % (self.current_year - 1), self.todo_stage.id): 0,
('May %s' % (self.current_year - 1), self.in_progress_stage.id): 2,
('May %s' % (self.current_year - 1), self.testing_stage.id): 3,
('May %s' % (self.current_year - 1), self.done_stage.id): 0,
('June %s' % (self.current_year - 1), self.todo_stage.id): 0,
('June %s' % (self.current_year - 1), self.in_progress_stage.id): 1,
('June %s' % (self.current_year - 1), self.testing_stage.id): 4,
('June %s' % (self.current_year - 1), self.done_stage.id): 0,
('July %s' % (self.current_year - 1), self.todo_stage.id): 0,
('July %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('July %s' % (self.current_year - 1), self.testing_stage.id): 5,
('July %s' % (self.current_year - 1), self.done_stage.id): 0,
('August %s' % (self.current_year - 1), self.todo_stage.id): 0,
('August %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('August %s' % (self.current_year - 1), self.testing_stage.id): 4,
('August %s' % (self.current_year - 1), self.done_stage.id): 1,
('September %s' % (self.current_year - 1), self.todo_stage.id): 0,
('September %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('September %s' % (self.current_year - 1), self.testing_stage.id): 3,
('September %s' % (self.current_year - 1), self.done_stage.id): 2,
('October %s' % (self.current_year - 1), self.todo_stage.id): 0,
('October %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('October %s' % (self.current_year - 1), self.testing_stage.id): 2,
('October %s' % (self.current_year - 1), self.done_stage.id): 3,
('November %s' % (self.current_year - 1), self.todo_stage.id): 0,
('November %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('November %s' % (self.current_year - 1), self.testing_stage.id): 1,
('November %s' % (self.current_year - 1), self.done_stage.id): 4,
('December %s' % (self.current_year - 1), self.todo_stage.id): 0,
('December %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('December %s' % (self.current_year - 1), self.done_stage.id): 5,
('December %s' % (self.current_year - 1), self.todo_stage.id): 1,
('January %s' % (self.current_year), self.todo_stage.id): 0,
('January %s' % (self.current_year), self.in_progress_stage.id): 0,
('January %s' % (self.current_year), self.done_stage.id): 5,
('January %s' % (self.current_year), self.todo_stage.id): 1,
('February %s' % (self.current_year), self.todo_stage.id): 0,
('February %s' % (self.current_year), self.in_progress_stage.id): 0,
('February %s' % (self.current_year), self.done_stage.id): 5,
('February %s' % (self.current_year), self.todo_stage.id): 1,
('March %s' % (self.current_year), self.todo_stage.id): 0,
('March %s' % (self.current_year), self.in_progress_stage.id): 0,
('March %s' % (self.current_year), self.done_stage.id): 5,
('March %s' % (self.current_year), self.todo_stage.id): 1,
('April %s' % (self.current_year), self.todo_stage.id): 0,
('April %s' % (self.current_year), self.in_progress_stage.id): 0,
('April %s' % (self.current_year), self.done_stage.id): 5,
('April %s' % (self.current_year), self.todo_stage.id): 1,
('May %s' % (self.current_year), self.todo_stage.id): 0,
('May %s' % (self.current_year), self.in_progress_stage.id): 0,
('May %s' % (self.current_year), self.done_stage.id): 5,
('May %s' % (self.current_year), self.todo_stage.id): 1,
('June %s' % (self.current_year), self.todo_stage.id): 0,
('June %s' % (self.current_year), self.in_progress_stage.id): 0,
('June %s' % (self.current_year), self.done_stage.id): 5,
('June %s' % (self.current_year), self.todo_stage.id): 1,
('July %s' % (self.current_year), self.todo_stage.id): 0,
('July %s' % (self.current_year), self.in_progress_stage.id): 0,
('July %s' % (self.current_year), self.done_stage.id): 5,
('July %s' % (self.current_year), self.todo_stage.id): 1,
('August %s' % (self.current_year), self.todo_stage.id): 0,
('August %s' % (self.current_year), self.in_progress_stage.id): 0,
('August %s' % (self.current_year), self.done_stage.id): 5,
('August %s' % (self.current_year), self.todo_stage.id): 1,
('September %s' % (self.current_year), self.todo_stage.id): 0,
('September %s' % (self.current_year), self.in_progress_stage.id): 0,
('September %s' % (self.current_year), self.done_stage.id): 5,
('September %s' % (self.current_year), self.todo_stage.id): 1,
('October %s' % (self.current_year), self.todo_stage.id): 0,
('October %s' % (self.current_year), self.in_progress_stage.id): 0,
('October %s' % (self.current_year), self.done_stage.id): 5,
('October %s' % (self.current_year), self.todo_stage.id): 1,
('November %s' % (self.current_year), self.todo_stage.id): 0,
('November %s' % (self.current_year), self.in_progress_stage.id): 0,
('November %s' % (self.current_year), self.done_stage.id): 5,
('November %s' % (self.current_year), self.todo_stage.id): 1,
('December %s' % (self.current_year), self.todo_stage.id): 0,
('December %s' % (self.current_year), self.in_progress_stage.id): 0,
('December %s' % (self.current_year), self.done_stage.id): 5,
('December %s' % (self.current_year), self.todo_stage.id): 1,
('December %s' % (self.current_year - 1), self.done_stage.id): 5,
}
self.check_read_group_results(AND([burndown_chart_domain, project_domain]), project_expected_dict)
project_expected_is_closed_dict = {
('January %s' % (self.current_year - 1), 'open'): 5,
('February %s' % (self.current_year - 1), 'open'): 5,
('March %s' % (self.current_year - 1), 'open'): 5,
('April %s' % (self.current_year - 1), 'open'): 5,
('May %s' % (self.current_year - 1), 'open'): 5,
('June %s' % (self.current_year - 1), 'open'): 5,
('July %s' % (self.current_year - 1), 'open'): 5,
('August %s' % (self.current_year - 1), 'open'): 4,
('August %s' % (self.current_year - 1), 'closed'): 1,
('September %s' % (self.current_year - 1), 'open'): 3,
('September %s' % (self.current_year - 1), 'closed'): 2,
('October %s' % (self.current_year - 1), 'open'): 2,
('October %s' % (self.current_year - 1), 'closed'): 3,
('November %s' % (self.current_year - 1), 'open'): 1,
('November %s' % (self.current_year - 1), 'closed'): 4,
('December %s' % (self.current_year - 1), 'closed'): 6,
}
months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October',
'November', 'December']
current_month = datetime.now().month
for i in range(current_month):
month_key = f"{months[i]} {self.current_year}"
project_expected_dict[(month_key, self.todo_stage.id)] = 1
project_expected_dict[(month_key, self.done_stage.id)] = 5
project_expected_is_closed_dict[(month_key, 'closed')] = 6
# Check that we get the expected results for the complete data of `self.project`.
self.check_read_group_results(Domain.AND([burndown_chart_domain, project_domain]), project_expected_dict)
self.check_read_group_is_closed_results(Domain.AND([burndown_chart_domain, project_domain]), project_expected_is_closed_dict)
# Check that we get the expected results for the complete data of `self.project` & `self.project_2` using an
# `ilike` in the domain.
all_projects_domain_with_ilike = OR([project_domain, [('project_id', 'ilike', 'mySearchTag')]])
all_projects_domain_with_ilike = Domain.OR([project_domain, [('project_id', 'ilike', 'mySearchTag')]])
project_expected_dict = {key: val if key[1] != self.todo_stage.id else val + 2 for key, val in project_expected_dict.items()}
self.check_read_group_results(AND([burndown_chart_domain, all_projects_domain_with_ilike]), project_expected_dict)
project_expected_is_closed_dict = {key: val if key[1] == 'closed' else val + 2 for key, val in project_expected_is_closed_dict.items()}
for i in range(2, 11):
month_key = f"{months[i]} {self.current_year - 1}"
project_expected_dict[(month_key, self.todo_stage.id)] = 2
project_expected_is_closed_dict[(f"{months[11]} {self.current_year - 1}", 'open')] = 2
for i in range(current_month):
project_expected_is_closed_dict[(f"{months[i]} {self.current_year}", 'open')] = 2
self.check_read_group_results(Domain.AND([burndown_chart_domain, all_projects_domain_with_ilike]), project_expected_dict)
self.check_read_group_is_closed_results(Domain.AND([burndown_chart_domain, all_projects_domain_with_ilike]), project_expected_is_closed_dict)
date_from, date_to = ('%s-01-01' % (self.current_year - 1), '%s-03-01' % (self.current_year - 1))
date_from_is_closed, date_to_is_closed = ('%s-10-01' % (self.current_year - 1), '%s-12-01' % (self.current_year - 1))
date_from, date_to = ('%s-01-01' % (self.current_year - 1), '%s-02-01' % (self.current_year - 1))
date_and_user_domain = [('date', '>=', date_from), ('date', '<', date_to), ('user_ids', 'ilike', 'ProjectUser')]
complex_domain_expected_dict = {
('January %s' % (self.current_year - 1), self.todo_stage.id): 3,
('February %s' % (self.current_year - 1), self.todo_stage.id): 1,
('February %s' % (self.current_year - 1), self.in_progress_stage.id): 2,
}
complex_domain = AND([burndown_chart_domain, all_projects_domain_with_ilike, date_and_user_domain])
complex_domain = Domain.AND([burndown_chart_domain, all_projects_domain_with_ilike, date_and_user_domain])
self.check_read_group_results(complex_domain, complex_domain_expected_dict)
date_and_user_domain = [('date', '>=', date_from_is_closed), ('date', '<', date_to_is_closed), ('user_ids', 'ilike', 'ProjectUser')]
complex_domain = Domain.AND([burndown_chart_domain, all_projects_domain_with_ilike, date_and_user_domain])
complex_domain_expected_dict = {
('October %s' % (self.current_year - 1), 'closed'): 2.0,
('October %s' % (self.current_year - 1), 'open'): 1.0,
('November %s' % (self.current_year - 1), 'closed'): 2.0,
('November %s' % (self.current_year - 1), 'open'): 1.0
}
self.check_read_group_is_closed_results(complex_domain, complex_domain_expected_dict)
date_and_user_domain = [('date', '>=', date_from), ('date', '<', date_to), ('user_ids', 'ilike', 'ProjectManager')]
milestone_domain = [('milestone_id', 'ilike', 'Test')]
complex_domain = AND([burndown_chart_domain, all_projects_domain_with_ilike, date_and_user_domain, milestone_domain])
complex_domain = Domain.AND([burndown_chart_domain, all_projects_domain_with_ilike, date_and_user_domain, milestone_domain])
complex_domain_expected_dict = {
('January %s' % (self.current_year - 1), self.todo_stage.id): 1,
('February %s' % (self.current_year - 1), self.todo_stage.id): 1,
}
self.check_read_group_results(complex_domain, complex_domain_expected_dict)
date_and_user_domain = [('date', '>=', date_from_is_closed), ('date', '<', date_to_is_closed), ('user_ids', 'ilike', 'ProjectManager')]
milestone_domain = [('milestone_id', 'ilike', 'Test')]
complex_domain = Domain.AND([burndown_chart_domain, all_projects_domain_with_ilike, date_and_user_domain, milestone_domain])
complex_domain_expected_dict = {
('October %s' % (self.current_year - 1), 'open'): 1.0,
('November %s' % (self.current_year - 1), 'closed'): 1.0
}
self.check_read_group_is_closed_results(complex_domain, complex_domain_expected_dict)
def burndown_chart_stage_delete_stage_1(self):
"""
Currently, this behavior is not working as expected. The key 'Jan year-1, stage_1.id' is not present as expected, but there's an extra unwanted key
'Jan year-1, stage_2.id' is present instead
"""
with freeze_time('%s-08-10' % (self.current_year - 1)):
self.stage_1.unlink()
self.env.cr.flush()
expected_dict = self.get_expected_dict()
del expected_dict[('January %s' % (self.current_year - 1), self.stage_1.id)]
self.check_read_group_results(self.deleted_domain, expected_dict)
def burndown_chart_stage_delete_stage_2(self):
"""
Currently, this behavior is not working as expected. The key 'Feb year-1, stage_2.id' is not present as expected, but there's an extra unwanted key
'Feb year-1, stage_3.id' is present instead
"""
with freeze_time('%s-08-10' % (self.current_year - 1)):
self.stage_2.unlink()
self.env.cr.flush()
expected_dict = self.get_expected_dict()
del expected_dict[('February %s' % (self.current_year - 1), self.stage_2.id)]
self.check_read_group_results(self.deleted_domain, expected_dict)
def test_burndown_chart_stage_deleted_3(self):
with freeze_time('%s-08-10' % (self.current_year - 1)):
self.stage_3.unlink()
self.env.cr.flush()
expected_dict = self.get_expected_dict()
del expected_dict[('March %s' % (self.current_year - 1), self.stage_3.id)]
self.check_read_group_results(self.deleted_domain, expected_dict)
def burndown_chart_all_stage_deleted(self):
"""
Currently, this behavior is not working as expected. An extra task is added for every month fetched by the query.
e.a. If the expected dict is :
{('April 2022', 390): 1, ('May 2022', 390): 1, ('June 2022', 390): 1, ('July 2022', 390): 1, ('August 2022', 390): 1, ('September 2022', 390): 1, etc : 1}
The fetched dict will be :
{('January 2022', 389): 1, ('February 2022', 389): 1, ('March 2022', 389): 1, ('April 2022', 390): 2, ('May 2022', 390): 2, ('June 2022', 390): 2, ('July 2022', 390): 2,
('August 2022', 390): 2, ('September 2022', 390): 2, etc :2 }
"""
with freeze_time('%s-08-10' % (self.current_year - 1)):
(self.stage_1 | self.stage_2 | self.stage_3).unlink()
self.env.cr.flush()
expected_dict = self.get_expected_dict()
del expected_dict[('January %s' % (self.current_year - 1), self.stage_1.id)]
del expected_dict[('February %s' % (self.current_year - 1), self.stage_2.id)]
del expected_dict[('March %s' % (self.current_year - 1), self.stage_3.id)]
self.check_read_group_results(self.deleted_domain, expected_dict)
def get_expected_dict(self):
expected_dict = {
('January %s' % (self.current_year - 1), self.stage_1.id): 1,
('February %s' % (self.current_year - 1), self.stage_2.id): 1,
('March %s' % (self.current_year - 1), self.stage_3.id): 1,
('April %s' % (self.current_year - 1), self.stage_4.id): 1,
('May %s' % (self.current_year - 1), self.stage_4.id): 1,
('June %s' % (self.current_year - 1), self.stage_4.id): 1,
('July %s' % (self.current_year - 1), self.stage_4.id): 1,
('August %s' % (self.current_year - 1), self.stage_4.id): 1,
('September %s' % (self.current_year - 1), self.stage_4.id): 1,
('October %s' % (self.current_year - 1), self.stage_4.id): 1,
('November %s' % (self.current_year - 1), self.stage_4.id): 1,
('December %s' % (self.current_year - 1), self.stage_4.id): 1,
}
months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October',
'November', 'December']
current_month = datetime.now().month
for i in range(current_month):
month_key = f"{months[i]} {self.current_year}"
expected_dict[(month_key, self.stage_4.id)] = 1
return expected_dict
@tagged('-at_install', 'post_install')
class TestBurndownChartTour(HttpCase, TestBurndownChartCommon):
def test_burndown_chart_tour(self):
# Test customizing personal stages as a project user
self.start_tour('/web', 'burndown_chart_tour', login="admin")
self.start_tour('/odoo', 'burndown_chart_tour', login="admin")

View file

@ -0,0 +1,61 @@
import unittest
from odoo.tests import TransactionCase, can_import, loaded_demo_data, tagged
from odoo.tools.misc import file_open
@tagged("post_install", "-at_install")
class TestImportFiles(TransactionCase):
@unittest.skipUnless(
can_import("xlrd.xlsx") or can_import("openpyxl"), "XLRD/XLSX not available",
)
def test_import_task_template_xls(self):
if not loaded_demo_data(self.env):
self.skipTest('Needs demo data to be able to import those files')
model = "project.task"
filename = "tasks_import_template.xlsx"
file_content = file_open(f"project/static/xls/{filename}", "rb").read()
import_wizard = self.env["base_import.import"].create(
{
"res_model": model,
"file": file_content,
"file_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
)
result = import_wizard.parse_preview(
{
"has_headers": True,
},
)
self.assertIsNone(result.get("error"))
field_names = ['/'.join(v) for v in result["matches"].values()]
results = import_wizard.execute_import(
field_names,
[r.lower() for r in result["headers"]],
{
"import_skip_records": [],
"import_set_empty_fields": [],
"fallback_values": {},
"name_create_enabled_fields": {"stage_id": True},
"encoding": "",
"separator": "",
"quoting": '"',
"date_format": "",
"datetime_format": "",
"float_thousand_separator": ",",
"float_decimal_separator": ".",
"advanced": True,
"has_headers": True,
"keep_matches": False,
"limit": 2000,
"skip": 0,
"tracking_disable": True,
},
)
self.assertFalse(
results["messages"],
"results should be empty on successful import of ",
)

View file

@ -1,9 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from contextlib import contextmanager
from datetime import datetime
from freezegun import freeze_time
from lxml import etree
from odoo.tests.common import TransactionCase, Form
from odoo import Command, fields
from odoo.tests import Form, TransactionCase
from odoo.exceptions import AccessError, UserError
class TestMultiCompanyCommon(TransactionCase):
@ -41,7 +45,7 @@ class TestMultiCompanyCommon(TransactionCase):
'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])]
'group_ids': [(6, 0, [user_group_employee.id])]
})
cls.user_manager_company_a = Users.create({
'name': 'Manager Company A',
@ -49,7 +53,7 @@ class TestMultiCompanyCommon(TransactionCase):
'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])]
'group_ids': [(6, 0, [user_group_employee.id])]
})
cls.user_employee_company_b = Users.create({
'name': 'Employee Company B',
@ -57,7 +61,7 @@ class TestMultiCompanyCommon(TransactionCase):
'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])]
'group_ids': [(6, 0, [user_group_employee.id])]
})
cls.user_manager_company_b = Users.create({
'name': 'Manager Company B',
@ -65,7 +69,7 @@ class TestMultiCompanyCommon(TransactionCase):
'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])]
'group_ids': [(6, 0, [user_group_employee.id])]
})
@contextmanager
@ -130,21 +134,21 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
# setup users
cls.user_employee_company_a.write({
'groups_id': [(4, user_group_project_user.id)]
'group_ids': [(4, user_group_project_user.id)]
})
cls.user_manager_company_a.write({
'groups_id': [(4, user_group_project_manager.id)]
'group_ids': [(4, user_group_project_manager.id)]
})
cls.user_employee_company_b.write({
'groups_id': [(4, user_group_project_user.id)]
'group_ids': [(4, user_group_project_user.id)]
})
cls.user_manager_company_b.write({
'groups_id': [(4, user_group_project_manager.id)]
'group_ids': [(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({
cls.Project = cls.env['project.project'].with_context({'mail_create_nolog': True, 'tracking_disable': True})
cls.project_company_a = cls.Project.create({
'name': 'Project Company A',
'alias_name': 'project+companya',
'partner_id': cls.partner_1.id,
@ -160,7 +164,7 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
})
]
})
cls.project_company_b = Project.create({
cls.project_company_b = cls.Project.create({
'name': 'Project Company B',
'alias_name': 'project+companyb',
'partner_id': cls.partner_1.id,
@ -196,7 +200,7 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
'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")
self.assertFalse(project.company_id, "A newly created project should have a company set to False by default")
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"):
@ -220,7 +224,112 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
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")
self.assertEqual(self.project_company_a.company_id, self.project_company_a.account_id.company_id, "The analytic account created from a project should be in the same company.")
project_no_company = self.Project.create({'name': 'Project no company'})
#ensures that all the existing plan have a company_id
project_no_company._create_analytic_account()
self.assertFalse(project_no_company.account_id.company_id, "The analytic account created from a project without company_id should have its company_id field set to False.")
project_no_company_2 = self.Project.create({'name': 'Project no company 2'})
project_no_company_2._create_analytic_account()
self.assertNotEqual(project_no_company_2.account_id, project_no_company.account_id, "The analytic account created should be different from the account created for the 1st project.")
self.assertEqual(project_no_company_2.account_id.plan_id, project_no_company.account_id.plan_id, "No new analytic should have been created.")
def test_analytic_account_company_consistency(self):
"""
This test ensures that the following invariant is kept:
If the company of an analytic account is set, all of its project must have the same company.
If the company of an analytic account is not set, its project can either have a company set, or none.
"""
project_no_company = self.Project.create({'name': 'Project no company'})
project_no_company._create_analytic_account()
account_no_company = project_no_company.account_id
self.project_company_a._create_analytic_account()
account_a = self.project_company_a.account_id
# Set the account of the project to a new account without company_id
self.project_company_a.account_id = account_no_company
self.assertEqual(self.project_company_a.account_id, account_no_company, "The new account should be set on the project.")
self.assertFalse(account_no_company.company_id, "The company of the account should not have been updated.")
self.project_company_a.account_id = account_a
# Set the account of the project to a new account with a company_id
project_no_company.account_id = account_a
self.assertEqual(project_no_company.company_id, self.company_a, "The company of the project should have been updated to the company of its new account.")
self.assertEqual(project_no_company.account_id, account_a, "The account of the project should have been updated.")
project_no_company.account_id = account_no_company
project_no_company.company_id = False
# Neither the project nor its account have a company_id
# set the company of the project
project_no_company.company_id = self.company_a
self.assertEqual(project_no_company.company_id, self.company_a, "The company of the project should have been updated.")
self.assertFalse(account_no_company.company_id, "The company of the account should not have been updated for the company of the project was False before its update.")
project_no_company.company_id = False
# set the company of the account
account_no_company.company_id = self.company_a
self.assertEqual(project_no_company.company_id, self.company_a, "The company of the project should have been updated to the company of its new account.")
self.assertEqual(account_no_company.company_id, self.company_a, "The company of the account should have been updated.")
# The project and its account have the same company (company A)
# set the company of the project to False
self.project_company_a.company_id = False
self.assertFalse(self.project_company_a.company_id, "The company of the project should have been updated.")
self.assertFalse(account_a.company_id, "The company of the account should be set to False, as it only has one project linked to it and the company of the project was set from company A to False")
account_a.company_id = self.company_a
# set the company of the project to company B
self.project_company_a.company_id = self.company_b
self.assertEqual(self.project_company_a.company_id, self.company_b, "The company of the project should have been updated.")
self.assertEqual(account_a.company_id, self.company_b, "The company of the account should have been updated, for its company was the same as the one of its project and the company of the project was set before the update.")
# set the company of the account to company A
account_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_a, "The company of the project should have been updated to the company of its account.")
self.assertEqual(account_a.company_id, self.company_a, "The company of the account should have been updated.")
# set the company of the account to False
account_a.company_id = False
self.assertEqual(self.project_company_a.company_id, self.company_a, "The company of the project should not have been updated for the company of its account has been set to False.")
self.assertFalse(account_a.company_id, "The company of the account should have been updated.")
# The project has a company_id set, but not its account
# set the company of the account to company B (!= project.company_id)
account_a.company_id = self.company_b
self.assertEqual(self.project_company_a.company_id, self.company_b, "The company of the project should have been updated to the company of its account even if the new company set on the account is a different one than the one the project.")
self.assertEqual(account_a.company_id, self.company_b, "The company of the account should have been updated.")
account_a.company_id = False
self.project_company_a.company_id = self.company_a
# set the company of the account to company A (== project.company_id)
account_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_a, "The company of the project should have been updated to the company of its account.")
self.assertEqual(account_a.company_id, self.company_a, "The company of the account should have been updated.")
account_a.company_id = False
# set the company of the project to company B
self.project_company_a.company_id = self.company_b
self.assertEqual(self.project_company_a.company_id, self.company_b, "The company of the project should have been updated.")
self.assertFalse(account_a.company_id, "The company of the account should not have been updated for it is was set to False.")
# set the company of the project to False
self.project_company_a.company_id = False
self.assertFalse(self.project_company_a.company_id, "The company of the project should have been updated.")
self.assertFalse(account_a.company_id, "The company of the account should not have been updated for it was set to False.")
# creates an AAL for the account_a
account_a.company_id = self.company_b
aal = self.env['account.analytic.line'].create({
'name': 'other revenues line',
'account_id': account_a.id,
'company_id': self.company_b.id,
'amount': 100,
})
with self.assertRaises(UserError):
self.project_company_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_b, "The account of the project contains AAL, its company can not be updated.")
aal.unlink()
project_no_company.account_id = account_a
self.assertEqual(project_no_company.company_id, account_a.company_id)
with self.assertRaises(UserError):
self.project_company_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_b, "The account of the project is linked to more than one project, its company can not be updated.")
def test_create_task(self):
with self.sudo('employee-a'):
@ -232,6 +341,64 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
self.assertEqual(task.company_id, self.project_company_a.company_id, "The company of the task should be the one from its project.")
def test_update_company_id(self):
""" this test ensures that:
- All the tasks of a project with a company set have the same company as their project. Updating the task set the task to a private state.
Updating the company the project update all the tasks even if the company of the project is set to False.
- The tasks of a project without company can have any company set. Updating a task does not update the company of its project. Updating the project update
all the tasks even if these tasks had a company set.
"""
project = self.Project.create({'name': 'Project'})
task = self.env['project.task'].create({
'name': 'task no company',
'project_id': project.id,
})
self.assertFalse(task.company_id, "Creating a task in a project without company set its company_id to False.")
with self.debug_mode():
task_form = Form(task)
task_form.company_id = self.company_a
task = task_form.save()
self.assertFalse(project.company_id, "Setting a new company on a task should not update the company of its project.")
self.assertEqual(task.company_id, self.company_a, "The company of the task should have been updated.")
project.company_id = self.company_b
self.assertEqual(project.company_id, self.company_b, "The company of the project should have been updated.")
self.assertEqual(task.company_id, self.company_b, "The company of the task should have been updated.")
with self.debug_mode():
task_form = Form(task)
task_form.company_id = self.company_a
task = task_form.save()
self.assertEqual(task.company_id, self.company_a, "The company of the task should have been updated.")
self.assertFalse(task.project_id, "The task should now be a private task.")
self.assertEqual(project.company_id, self.company_b, "the company of the project should not have been updated.")
task_1, task_2, task_3 = self.env['project.task'].create([{
'name': 'task 1',
'project_id': project.id,
}, {
'name': 'task 2',
'project_id': project.id,
}, {
'name': 'task 3',
'project_id': project.id,
}])
project.company_id = False
for task in project.task_ids:
self.assertFalse(task.company_id, "The tasks should not have a company_id set.")
task_1.company_id = self.company_a
task_2.company_id = self.company_b
self.assertEqual(task_1.company_id, self.company_a, "The company of task_1 should have been set to company A.")
self.assertEqual(task_2.company_id, self.company_b, "The company of task_2 should have been set to company B.")
self.assertFalse(task_3.company_id, "The company of the task_3 should not have been updated.")
self.assertFalse(project.company_id, "The company of the project should not have been updated.")
company_c = self.env['res.company'].create({'name': 'company C'})
project.company_id = company_c
for task in project.tasks:
self.assertEqual(task.company_id, company_c, "The company of the tasks should have been updated to company C.")
def test_move_task(self):
with self.sudo('employee-a'):
with self.allow_companies([self.company_a.id, self.company_b.id]):
@ -248,53 +415,58 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
self.assertEqual(task.company_id, self.company_a, "Moving a task should 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, 'default_parent_id': self.task_1.id, 'default_project_id': self.project_company_b.id})) as task_form:
task_form.name = 'Test Subtask in 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.")
# 1) Create a subtask and check that every field is correctly set on the subtask
with (
Form(self.task_1) as task_1_form,
task_1_form.child_ids.new() as subtask_line,
):
self.assertEqual(subtask_line.project_id, self.task_1.project_id, "The task's project should already be set on the subtask.")
subtask_line.name = 'Test Subtask'
subtask = self.task_1.child_ids[0]
self.assertFalse(subtask.display_in_project, "The subtask's field 'display in project' should be unchecked.")
self.assertEqual(subtask.company_id, self.task_1.company_id, "The company of the subtask should be the one from its project.")
# set parent on existing orphan task; the onchange will set the correct company and subtask project
self.task_2.write({'project_id': False})
# For `parent_id` to be visible in the view, you need
# 1. The debug mode
# 2. `allow_subtasks` to be true
# <field name="parent_id" attrs="{'invisible': [('allow_subtasks', '=', False)]}" groups="base.group_no_one"/>
# `allow_subtasks` is a related to `allow_subtasks` on the project
# as the point of the test is to test the behavior of the task `_compute_project_id` when there is no project,
# `allow_subtasks` is by default invisible, and you shouldn't therefore be able to change it.
# So, to make it visible, temporary modify the view to make it visible even when `allow_subtasks` is `False`.
view = self.env.ref('project.view_task_form2').sudo()
tree = etree.fromstring(view.arch)
for node in tree.xpath('//field[@name="parent_id"][@attrs]'):
node.attrib.pop('attrs')
view.arch = etree.tostring(tree)
with self.debug_mode():
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()
# 2) Change the project of the parent task and check that the subtask follows it
with Form(self.task_1) as task_1_form:
task_1_form.project_id = self.project_company_b
self.assertEqual(subtask.project_id, self.task_1.project_id, "The task's project should already be set on the subtask.")
self.assertFalse(subtask.display_in_project, "The subtask's field 'display in project' should be unchecked.")
self.assertEqual(subtask.company_id, self.project_company_b.company_id, "The company of the subtask should be the one from its project.")
task_1_form.project_id = self.project_company_a
# 3) Change the parent of the subtask and check that every field is correctly set on it
# For `parent_id` to be visible in the view, you need
# 1. The debug mode
# <field name="parent_id" groups="base.group_no_one"/>
view = self.env.ref('project.view_task_form2').sudo()
tree = etree.fromstring(view.arch)
for node in tree.xpath('//field[@name="parent_id"][@invisible]'):
node.attrib.pop('invisible')
view.arch = etree.tostring(tree)
with (
self.debug_mode(),
Form(subtask) as subtask_form
):
subtask_form.parent_id = self.task_2
self.assertEqual(subtask_form.project_id, self.task_2.project_id, "The task's project should already be set on the subtask.")
self.assertFalse(subtask.display_in_project, "The subtask's field 'display in project' should be unchecked.")
self.assertEqual(subtask.company_id, self.task_2.company_id, "The company of the subtask should be the one from its new project, set from its parent.")
# 4) Change the project of the subtask and check some fields
subtask.project_id = self.project_company_a
self.assertTrue(subtask.display_in_project, "The subtask's field 'display in project' should be checked.")
self.assertEqual(subtask.company_id, self.project_company_a.company_id, "The company of the subtask should be the one from its project, and not from its parent.")
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({'allow_subtasks': True})
# For `parent_id` to be visible in the view, you need
# 1. The debug mode
# 2. `allow_subtasks` to be true
# <field name="parent_id" attrs="{'invisible': [('allow_subtasks', '=', False)]}" groups="base.group_no_one"/>
# `allow_subtasks` is a related to `allow_subtasks` on the project
# as the point of the test is to test the behavior of the task `_compute_project_id` when there is no project,
# `allow_subtasks` is by default invisible, and you shouldn't therefore be able to change it.
# So, to make it visible, temporary modify the view to make it visible even when `allow_subtasks` is `False`.
# <field name="parent_id" groups="base.group_no_one"/>
view = self.env.ref('project.view_task_form2').sudo()
tree = etree.fromstring(view.arch)
for node in tree.xpath('//field[@name="parent_id"][@attrs]'):
node.attrib.pop('attrs')
for node in tree.xpath('//field[@name="parent_id"][@invisible]'):
node.attrib.pop('invisible')
view.arch = etree.tostring(tree)
with self.sudo('employee-a'):
@ -302,15 +474,43 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
with self.debug_mode():
with Form(self.env['project.task'].with_context({'tracking_disable': True})) as task_form:
task_form.name = 'Test Subtask in company B'
task_form.project_id = self.task_1.project_id
task_form.parent_id = self.task_1
task = task_form.save()
self.assertEqual(task.project_id, self.task_1.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.company_id, "The company of the orphan subtask should be the one from its project.")
self.assertEqual(self.task_1.child_ids.ids, [task.id])
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"
@freeze_time("2019-5-28 08:00:00")
def test_date_to_assign_project(self):
company_0, company_1 = self.env['res.company'].create([{
"name": "Test company 0",
},
{
"name": "Test company 1",
}])
self.env['resource.calendar.leaves'].create([{
'name': "Public Holiday for company 0",
'company_id': company_0.id,
'date_from': datetime(2019, 5, 27, 0, 0, 0),
'date_to': datetime(2019, 5, 29, 23, 0, 0),
'resource_id': False,
'time_type': "leave",
}])
project = self.env['project.project'].with_company(company_1).create({'name': 'Project for company 1'})
task = self.env['project.task'].with_company(company_1).create({
'name': 'Task for company 1',
'project_id': project.id,
'create_date': datetime(2019, 5, 28, 10, 0, 0),
'user_ids': False
})
with freeze_time("2019-05-28 14:00:00"):
task.user_ids = [Command.set([self.user_employee_company_a.id])]
task.date_assign = fields.Datetime.now()
self.assertEqual(task.working_hours_open, 3.0)
self.assertEqual(task.working_days_open, 0.375)

View file

@ -1,9 +1,12 @@
# -*- coding: utf-8 -*-
from odoo.tests import tagged, HttpCase
from odoo import Command
from odoo.exceptions import UserError
from odoo.tests import HttpCase, tagged, new_test_user
from .test_project_base import TestProjectCommon
@tagged('-at_install', 'post_install', 'personal_stages')
class TestPersonalStages(TestProjectCommon):
@ -56,43 +59,336 @@ class TestPersonalStages(TestProjectCommon):
'The search should only have returned task that are in the inbox personal stage.')
def test_personal_stage_read_group(self):
# Ensure user doesnt have any tasks before hand
(self.env['project.task'].sudo().search([("user_ids", "in", (self.task_1.user_ids + self.user_projectmanager).ids)]) - self.task_1).unlink()
self.task_1.user_ids += self.user_projectmanager
self.task_1.with_user(self.user_projectmanager).personal_stage_type_id = self.manager_stages[1]
#Makes sure the personal stage for project manager is saved in the database
self.env.flush_all()
read_group_user = self.env['project.task'].with_user(self.user_projectuser).read_group(
[('user_ids', '=', self.user_projectuser.id)], fields=['sequence:avg'], groupby=['personal_stage_type_ids'])
read_group_user = self.env['project.task'].with_context(read_group_expand=True).with_user(self.user_projectuser).formatted_read_group(
[('user_ids', '=', self.user_projectuser.id)], aggregates=['sequence:avg', '__count'], groupby=['personal_stage_type_ids'])
# Check that the result is at least a bit coherent
self.assertEqual(len(self.user_stages), len(read_group_user),
'read_group should return %d groups' % len(self.user_stages))
# User has only one task assigned the sum of all counts should be 1
total = 0
for group in read_group_user:
total += group['personal_stage_type_ids_count']
total += group['__count']
self.assertEqual(1, total,
'read_group should not have returned more tasks than the user is assigned to.')
read_group_manager = self.env['project.task'].with_user(self.user_projectmanager).read_group(
[('user_ids', '=', self.user_projectmanager.id)], fields=['sequence:avg'], groupby=['personal_stage_type_ids'])
read_group_manager = self.env['project.task'].with_context(read_group_expand=True).with_user(self.user_projectmanager).formatted_read_group(
[('user_ids', '=', self.user_projectmanager.id)], aggregates=['sequence:avg', '__count'], groupby=['personal_stage_type_ids'])
self.assertEqual(len(self.manager_stages), len(read_group_manager),
'read_group should return %d groups' % len(self.user_stages))
total = 0
total_stage_0 = 0
total_stage_1 = 0
for group in read_group_manager:
total += group['personal_stage_type_ids_count']
total += group['__count']
# Check that we have a task in both stages
if group['personal_stage_type_ids'][0] == self.manager_stages[0].id:
total_stage_0 += 1
elif group['personal_stage_type_ids'][0] == self.manager_stages[1].id:
total_stage_1 += 1
self.assertEqual(2, total,
self.assertEqual(1, total,
'read_group should not have returned more tasks than the user is assigned to.')
self.assertEqual(1, total_stage_0)
self.assertEqual(1, total_stage_1)
def test_delete_personal_stage(self):
"""
When deleting personal stages, the task of this stage are transfered to the one following it sequence-wise.
The deletion of stages can be done in batch.
"""
user_1, user_2, user_3 = self.env['res.users'].create([{
'login': 'user_1_stages',
'name': 'User 1 with personal stages',
}, {
'login': 'user_2_stages',
'name': 'User 2 with personal stages',
}, {
'login': 'user_3_stages',
'name': 'User 3 with personal stages',
}])
# Ensure user doesnt have any personal stages before hand and no onboarding tasks
self.env['project.task.type'].sudo().search([("user_id", "in", (user_1 + user_2 + user_3).ids)]).user_id = False
self.env['project.task'].sudo().search([("user_ids", "in", (user_1 + user_2 + user_3).ids)]).unlink()
self.assertEqual(self.env['project.task.type'].search_count([('user_id', '=', user_1.id)]), 0)
self.assertEqual(self.env['project.task.type'].search_count([('user_id', '=', user_2.id)]), 0)
self.assertEqual(self.env['project.task'].search_count([('user_ids', 'in', user_1.ids)]), 0)
self.assertEqual(self.env['project.task'].search_count([('user_ids', 'in', user_2.ids)]), 0)
# Create 5 personal stages for user 1
user_1_stages = self.env['project.task.type'].create([{
'user_id': user_1.id,
'name': f'User 1 - Stage {i}',
'sequence': 10 * i,
} for i in range(1, 6)])
# Create 3 personal stages for user 2
user_2_stages = self.env['project.task.type'].create([{
'user_id': user_2.id,
'name': f'User 2 - Stage {i}',
'sequence': 10 * i,
} for i in range(1, 4)])
# Create private tasks for user 1 and 2
private_tasks = self.env['project.task'].create([{
'user_ids': [Command.link(user_1.id), Command.link(user_2.id)],
'name': 'Task 1',
'project_id': False,
}, {
'user_ids': [Command.link(user_1.id), Command.link(user_2.id)],
'name': 'Task 2',
'project_id': False,
}, {
'user_ids': [Command.link(user_1.id)],
'name': 'Task 3',
'project_id': False,
}, {
'user_ids': [Command.link(user_1.id)],
'name': 'Task 4',
'project_id': False,
}])
# Put private tasks in personal stages for user 1
private_tasks[0].with_user(user_1.id).personal_stage_type_id = user_1_stages[2].id
private_tasks[1].with_user(user_1.id).personal_stage_type_id = user_1_stages[3].id
private_tasks[2].with_user(user_1.id).personal_stage_type_id = user_1_stages[4].id
private_tasks[3].with_user(user_1.id).personal_stage_type_id = user_1_stages[4].id
# Put private tasks in personal stages for user 2
private_tasks[0].with_user(user_2.id).personal_stage_type_id = user_2_stages[0].id
private_tasks[1].with_user(user_2.id).personal_stage_type_id = user_2_stages[1].id
# ------------------------------------
# ------- A. Initial situation ------
# ------------------------------------
#
# For user 1:
#
# +---------+---------+---------+---------+---------+
# | Stage 1 | Stage 2 | Stage 3 | Stage 4 | Stage 5 |
# +---------+---------+---------+---------+---------+
# | | | Task 1 | Task 2 | Task 3 |
# | | | | | Task 4 |
# +---------+---------+---------+---------+---------+
#
# For user 2:
#
# +---------+---------+---------+
# | Stage 1 | Stage 2 | Stage 3 |
# +---------+---------+---------+
# | Task 1 | Task 2 | |
# +---------+---------+---------+
self.assertEqual(self.env['project.task.type'].with_user(user_1.id).search_count([('project_ids', '=', False), ('user_id', '=', user_1.id)]), 5)
self.assertEqual(self.env['project.task'].with_user(user_1.id).search_count([('user_ids', 'in', user_1.ids)]), 4)
private_tasks.invalidate_recordset(['personal_stage_type_id'])
self.assertEqual(private_tasks[0].with_user(user_1.id).personal_stage_type_id.id, user_1_stages[2].id)
self.assertEqual(private_tasks[1].with_user(user_1.id).personal_stage_type_id.id, user_1_stages[3].id)
self.assertEqual(private_tasks[2].with_user(user_1.id).personal_stage_type_id.id, user_1_stages[4].id)
self.assertEqual(private_tasks[3].with_user(user_1.id).personal_stage_type_id.id, user_1_stages[4].id)
self.assertEqual(self.env['project.task.type'].with_user(user_2.id).search_count([('project_ids', '=', False), ('user_id', '=', user_2.id)]), 3)
self.assertEqual(self.env['project.task'].with_user(user_2.id).search_count([('user_ids', 'in', user_2.ids)]), 2)
private_tasks.invalidate_recordset(['personal_stage_type_id'])
self.assertEqual(private_tasks[0].with_user(user_2.id).personal_stage_type_id.id, user_2_stages[0].id)
self.assertEqual(private_tasks[1].with_user(user_2.id).personal_stage_type_id.id, user_2_stages[1].id)
# --------------------------------------------
# ---- B. Deleting an empty (own) stage -----
# --------------------------------------------
#
# Deleting stage 3 for user 2
# Expected result for user 2:
#
# +---------+---------+
# | Stage 1 | Stage 2 |
# +---------+---------+
# | Task 1 | Task 2 |
# +---------+---------+
user_2_stages[2].with_user(user_2.id).unlink()
self.assertEqual(self.env['project.task.type'].with_user(user_2.id).search_count([('project_ids', '=', False), ('user_id', '=', user_2.id)]), 2,
"A user should be able to unlink its own (empty) personal stage.")
# --------------------------------------------
# ---- C. Deleting a single (own) stage -----
# --------------------------------------------
#
# Deleting stage 3 for user 1, the task in this stage should move to stage 2
# Expected result for user 1:
#
# +---------+---------+---------+---------+
# | Stage 1 | Stage 2 | Stage 4 | Stage 5 |
# +---------+---------+---------+---------+
# | | Task 1 | Task 2 | Task 3 |
# | | | | Task 4 |
# +---------+---------+---------+---------+
private_tasks.invalidate_recordset(['personal_stage_type_id'])
user_1_stages[2].with_user(user_1.id).unlink()
self.assertEqual(self.env['project.task.type'].with_user(user_1.id).search_count([('project_ids', '=', False), ('user_id', '=', user_1.id)]), 4,
"A user should be able to unlink its own personal stage.")
self.assertEqual(self.env['project.task'].with_user(user_1.id).search_count([('user_ids', 'in', user_1.ids)]), 4,
"Tasks in a removed personal stage should not be unlinked.")
self.assertEqual(private_tasks[0].with_user(user_1.id).personal_stage_type_id.id, user_1_stages[1].id,
"Tasks in a removed personal stage should be moved to the stage following it sequence-wise")
# --------------------------------------------
# ---- D. Deleting (own) stage in batch ------
# --------------------------------------------
#
# Deleting stages 2 & 4 for user 1, the task in those stages should move to stage 1
# Expected result for user 1:
#
# +---------+---------+
# | Stage 1 | Stage 5 |
# +---------+---------+
# | Task 1 | Task 3 |
# | Task 2 | Task 4 |
# +---------+---------+
user_1_stages.filtered(lambda s: s.id in [user_1_stages[1].id, user_1_stages[3].id]).with_user(user_1.id).unlink()
self.assertEqual(self.env['project.task.type'].with_user(user_1.id).search_count([('project_ids', '=', False), ('user_id', '=', user_1.id)]), 2,
"A user should be able to unlink its own personal stage in batch.")
self.assertEqual(self.env['project.task'].with_user(user_1.id).search_count([('user_ids', 'in', user_1.ids)]), 4,
"Tasks in personal stages removed in batch should not be unlinked.")
for i in range(2):
self.assertEqual(private_tasks[i].with_user(user_1.id).personal_stage_type_id.id, user_1_stages[0].id,
"Tasks in a personal stage removed in batch should be moved to the stage following it sequence-wise")
# ------------------------------------------------------
# -- E. Deleting multi-user stages in batch (as sudo) --
# ------------------------------------------------------
#
# Deleting stages 1 user 1 and stage 2 for user 2
# Expected result for user 1:
#
# +---------+
# | Stage 5 |
# +---------+
# | Task 1 |
# | Task 2 |
# | Task 3 |
# | Task 4 |
# +---------+
#
# Expected result for user 2:
#
# +---------+
# | Stage 1 |
# +---------+
# | Task 1 |
# | Task 2 |
# +---------+
#
(user_1_stages[0] | user_2_stages[1]).sudo().unlink()
self.assertEqual(self.env['project.task.type'].with_user(user_1.id).search_count([('project_ids', '=', False), ('user_id', '=', user_1.id)]), 1,
"Superuser should be able to delete personal stages in batch.")
self.assertEqual(self.env['project.task.type'].with_user(user_2.id).search_count([('project_ids', '=', False), ('user_id', '=', user_2.id)]), 1,
"Superuser should be able to delete personal stages in batch.")
self.assertEqual(self.env['project.task'].with_user(user_1.id).search_count([('user_ids', 'in', user_1.ids)]), 4,
"Tasks in personal stages removed in batch by superuser should not be unlinked.")
for private_task in private_tasks:
self.assertEqual(private_task.with_user(user_1.id).personal_stage_type_id.id, user_1_stages[4].id,
"Tasks in a personal stage removed in batch should be moved to a stage with a higher sequence if no stage with lower sequence have been found")
private_tasks.invalidate_recordset(['personal_stage_type_id'])
self.assertEqual(private_tasks[0].with_user(user_2.id).personal_stage_type_id.id, user_2_stages[0].id,
"Tasks in a personal stage removed in batch by superuser should be moved to the stage following it sequence-wise")
self.assertEqual(private_tasks[1].with_user(user_2.id).personal_stage_type_id.id, user_2_stages[0].id,
"Tasks in a personal stage removed in batch by superuser should be moved to the stage following it sequence-wise")
# ------------------------------------------------------
# -- F. Deleting the last personal stage not allowed --
# ------------------------------------------------------
#
# Deleting stage 1 for user 2 should raise an error
# Expected result for user 2:
#
# +---------+
# | Stage 1 |
# +---------+
# | Task 1 |
# | Task 2 |
# +---------+
#
with self.assertRaises(UserError, msg="Deleting the last personal stage of a user should raise an error"):
user_2_stages[0].with_user(user_2.id).unlink()
self.assertEqual(self.env['project.task.type'].with_user(user_2.id).search_count([('project_ids', '=', False), ('user_id', '=', user_2.id)]), 1,
"Last personal stage of a user should not be deleted by unlink method")
private_tasks.invalidate_recordset(['personal_stage_type_id'])
self.assertEqual(private_tasks[0].with_user(user_2.id).personal_stage_type_id.id, user_2_stages[0].id,
"Last personal stage of a user should not be deleted by unlink method")
self.assertEqual(private_tasks[1].with_user(user_2.id).personal_stage_type_id.id, user_2_stages[0].id,
"Last personal stage of a user should not be deleted by unlink method")
# -------------------------------------------------------------------
# - G. Deleting the last personal stage not allowed (even if empty) -
# -------------------------------------------------------------------
empty_stage_user_3 = self.env['project.task.type'].create({
'user_id': user_3.id,
'name': 'User 3 - Empty stage',
'sequence': 10,
})
with self.assertRaises(UserError, msg="Deleting the last personal stage of a user should raise an error, even if the stage is empty"):
empty_stage_user_3.with_user(user_3.id).unlink()
# ---------------------------------------------------------
# - H. Mixed scenario: 1 normal stage and 2 personal ones -
# ---------------------------------------------------------
# Create one normal project stage with no task in it two other personal stages for both users that could be deleted
empty_stages = self.env['project.task.type'].create([{
'user_id': user_1.id,
'name': 'User 1 - Empty stage',
'sequence': 10,
}, {
'user_id': user_2.id,
'name': 'User 2 - Empty stage',
'sequence': 10,
}, {
'project_ids': self.project_pigs,
'name': 'Empty stage in project Pigs',
'sequence': 10,
}])
empty_stages.sudo().unlink()
self.assertFalse(self.env['project.task.type'].search_count([('id', 'in', empty_stages.ids)]),
"All stages, wether they are personal or not, should be able to be deleted in batch")
def test_new_personal_stages_created_for_new_users(self):
ProjectTaskType = self.env["project.task.type"]
internal_user = new_test_user(
self.env,
login="internal_user",
groups="base.group_user",
)
self.assertEqual(7, ProjectTaskType.search_count([("user_id", "=", internal_user.id)]), "Personal stages seems to have a wrong count")
portal_user = new_test_user(
self.env,
login="portal_user",
groups="base.group_portal",
)
self.assertEqual(0, ProjectTaskType.search_count([("user_id", "=", portal_user.id)]), "Portal users should never have personal stages when created")
public_user = new_test_user(
self.env,
login="public_user",
groups="base.group_public",
)
self.assertEqual(0, ProjectTaskType.search_count([("user_id", "=", public_user.id)]), "Public users should never have personal stages when created")
@tagged('-at_install', 'post_install')
class TestPersonalStageTour(HttpCase, TestProjectCommon):
def test_personal_stage_tour(self):
# Test customizing personal stages as a project user
self.start_tour('/web', 'personal_stage_tour', login="armandel")
self.start_tour('/odoo', 'personal_stage_tour', login="armandel")

View file

@ -4,10 +4,11 @@
from odoo import Command
from odoo.addons.project.tests.test_access_rights import TestProjectPortalCommon
from odoo.exceptions import AccessError
from odoo.tests import HttpCase
from odoo.tools import mute_logger
class TestPortalProject(TestProjectPortalCommon):
class TestPortalProject(TestProjectPortalCommon, HttpCase):
@mute_logger('odoo.addons.base.models.ir_model')
def test_portal_project_access_rights(self):
pigs = self.project_pigs
@ -45,11 +46,12 @@ class TestPortalProject(TestProjectPortalCommon):
self.project_pigs.privacy_visibility = 'portal'
self.assertFalse(self.project_pigs.access_token, 'The access token should not yet available since the project has not been shared yet.')
wizard = self.env['project.share.wizard'].create({
'access_mode': 'read',
'res_model': 'project.project',
'res_id': self.project_pigs.id,
'partner_ids': [
Command.link(self.partner_1.id),
'collaborator_ids': [
Command.create({
'partner_id': self.partner_1.id,
}),
]
})
wizard.action_send_mail()
@ -81,3 +83,74 @@ class TestPortalProject(TestProjectPortalCommon):
self.project_pigs.privacy_visibility = 'employees'
self.assertFalse(self.project_pigs.access_token, 'The access token should no longer be set since now the project is only available by internal users.')
self.assertFalse(all(self.project_pigs.tasks.mapped('access_token')), 'The access token should no longer be set in any tasks linked to the project since now the project is only available by internal users.')
def test_search_validates_results(self):
project_manager = self.env['res.users'].search([
('group_ids', 'in', [self.env.ref('project.group_project_manager').id])
],limit=1)
self.authenticate(project_manager.login, project_manager.login)
self.project_1 = self.env['project.project'].create({'name': 'Portal Search Project 1'})
self.project_2 = self.env['project.project'].create({'name': 'Portal Search Project 2'})
self.task_1 = self.env['project.task'].create({
'name': 'Test Task Name Match',
'project_id': self.project_1.id,
'user_ids': project_manager,
})
self.task_2 = self.env['project.task'].create({
'name': 'Another Task For Searching',
'project_id': self.project_2.id,
'user_ids': project_manager,
})
url = '/my/tasks'
response = self.url_open(url)
self.assertIn(self.task_1.name, response.text)
self.assertIn(self.task_2.name, response.text)
url = '/my/tasks?search_in=name&search=Test+Task+Name+Match'
response = self.url_open(url)
self.assertIn(self.task_1.name, response.text)
self.assertNotIn(self.task_2.name, response.text)
url = '/my/tasks?search_in=project_id&search=%s' % (self.project_1.name)
response = self.url_open(url)
self.assertIn(self.task_1.name, response.text)
self.assertNotIn(self.task_2.name, response.text)
def test_task_templates_visibility_portal(self):
"""
Verify that a portal user can see regular tasks but not task templates or their subtasks.
"""
self.authenticate(self.user_portal.login, self.user_portal.login)
portal_project = self.env['project.project'].create({'name': 'Portal Project'})
portal_project.message_subscribe(partner_ids=[self.user_portal.partner_id.id])
task, task_template = self.env['project.task'].create([
{
'name': 'Visible Task',
'project_id': portal_project.id,
},
{
'name': 'Invisible Template Task',
'project_id': portal_project.id,
'is_template': True,
'child_ids': [
Command.create({'name': 'Sub Task of Template Task 1'}),
Command.create({'name': 'Sub Task of Template Task 2'}),
]
}
])
# Check the portal my tasks page
my_tasks_response = self.url_open('/my/tasks')
self.assertIn(task.name, my_tasks_response.text)
self.assertNotIn(task_template.name, my_tasks_response.text)
self.assertNotIn(task_template.child_ids[0].name, my_tasks_response.text)
self.assertNotIn(task_template.child_ids[1].name, my_tasks_response.text)
# Check the tasks page for the specific project
project_tasks_response = self.url_open('/my/projects/%s' % (portal_project.id))
self.assertIn(task.name, project_tasks_response.text)
self.assertNotIn(task_template.name, project_tasks_response.text)
self.assertNotIn(task_template.child_ids[0].name, project_tasks_response.text)
self.assertNotIn(task_template.child_ids[1].name, project_tasks_response.text)

View file

@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
import json
from lxml import etree
from odoo import Command, fields
from odoo.tests import Form, users
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
@ -12,7 +11,9 @@ class TestProjectCommon(TransactionCase):
@classmethod
def setUpClass(cls):
super(TestProjectCommon, cls).setUpClass()
cls.env.company.resource_calendar_id.tz = "Europe/Brussels"
user_group_partner_manager = cls.env.ref('base.group_partner_manager')
user_group_employee = cls.env.ref('base.group_user')
user_group_project_user = cls.env.ref('project.group_project_user')
user_group_project_manager = cls.env.ref('project.group_project_manager')
@ -35,26 +36,26 @@ class TestProjectCommon(TransactionCase):
'email': 'b.t@example.com',
'signature': 'SignBert',
'notification_type': 'email',
'groups_id': [(6, 0, [cls.env.ref('base.group_public').id])]})
'group_ids': [(6, 0, [cls.env.ref('base.group_public').id])]})
cls.user_portal = Users.create({
'name': 'Chell Gladys',
'login': 'chell',
'email': 'chell@gladys.portal',
'signature': 'SignChell',
'notification_type': 'email',
'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])]})
'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])]})
cls.user_projectuser = Users.create({
'name': 'Armande ProjectUser',
'login': 'armandel',
'password': 'armandel',
'email': 'armande.projectuser@example.com',
'groups_id': [(6, 0, [user_group_employee.id, user_group_project_user.id])]
'group_ids': [(6, 0, [user_group_employee.id, user_group_project_user.id, user_group_partner_manager.id])]
})
cls.user_projectmanager = Users.create({
'name': 'Bastien ProjectManager',
'login': 'bastien',
'email': 'bastien.projectmanager@example.com',
'groups_id': [(6, 0, [user_group_employee.id, user_group_project_manager.id])]})
'group_ids': [(6, 0, [user_group_employee.id, user_group_project_manager.id, user_group_partner_manager.id])]})
# Test 'Pigs' project
cls.project_pigs = cls.env['project.project'].with_context({'mail_create_nolog': True}).create({
@ -89,15 +90,6 @@ class TestProjectCommon(TransactionCase):
})]
})
def format_and_process(self, template, to='groups@example.com, other@gmail.com', subject='Frogs',
extra='', email_from='Sylvie Lelitre <test.sylvie.lelitre@agrolait.com>',
cc='', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>',
model=None, target_model='project.task', target_field='name'):
self.assertFalse(self.env[target_model].search([(target_field, '=', subject)]))
mail = template.format(to=to, subject=subject, cc=cc, extra=extra, email_from=email_from, msg_id=msg_id)
self.env['mail.thread'].message_process(model, mail)
return self.env[target_model].search([(target_field, '=', subject)])
class TestProjectBase(TestProjectCommon):
@ -152,10 +144,400 @@ class TestProjectBase(TestProjectCommon):
user2 = self.user_projectuser.copy()
user1.notification_type = 'email'
user2.notification_type = 'inbox'
for user, filter_invisible_expected in ((user1, True), (user2, None)):
for user, filter_visible_expected in ((user1, False), (user2, True)):
Task = self.env['project.task'].with_user(user)
arch = Task.get_view(self.env.ref('project.view_task_search_form').id, 'search')['arch']
arch = Task.get_view(self.env.ref('project.view_task_search_form').id)['arch']
tree = etree.fromstring(arch)
node = tree.xpath('//filter[@name="message_needaction"]')[0]
modifiers = json.loads(node.get('modifiers') or '{}')
self.assertEqual(modifiers.get('invisible'), filter_invisible_expected)
self.assertEqual(bool(tree.xpath('//filter[@name="message_needaction"]')), filter_visible_expected)
@users('bastien')
def test_search_favorite_order(self):
""" Test the search method, ordering by favorite projects.
"""
self.project_goats.favorite_user_ids += self.user_projectmanager
self.env.cr.flush()
Project = self.env['project.project']
project_ids = [self.project_pigs.id, self.project_goats.id]
domain = [('id', 'in', project_ids)]
self.assertEqual(Project.search(domain, order='is_favorite desc')[0], self.project_goats)
self.assertEqual(Project.search(domain, order='is_favorite')[-1], self.project_goats)
self.assertTrue(self.project_pigs.id < self.project_goats.id)
self.assertEqual(Project.search(domain, order='id').ids, project_ids)
@users('bastien')
def test_edit_favorite(self):
project1, project2 = projects = self.env['project.project'].create([{
'name': 'Project Test1',
}, {
'name': 'Project Test2',
'is_favorite': True,
}])
self.assertFalse(project1.is_favorite)
self.assertTrue(project2.is_favorite)
project1.is_favorite = True
project2.is_favorite = False
projects.invalidate_recordset(['is_favorite']) # To force 'is_favorite' to recompute
self.assertTrue(project1.is_favorite)
self.assertFalse(project2.is_favorite)
@users('bastien')
def test_create_favorite_from_project_form(self):
Project = self.env['project.project']
form1 = Form(Project)
form1.name = 'Project Test1'
self.assertFalse(form1.is_favorite)
project1 = form1.save()
self.assertFalse(project1.is_favorite)
form2 = Form(Project)
form2.name = 'Project Test2'
form2.is_favorite = True
self.assertTrue(form2.is_favorite)
project2 = form2.save()
self.assertTrue(project2.is_favorite)
@users('bastien')
def test_edit_favorite_from_project_form(self):
project1, project2 = self.env['project.project'].create([{
'name': 'Project Test1',
}, {
'name': 'Project Test2',
'is_favorite': True,
}])
with Form(project1) as form:
form.is_favorite = True
self.assertTrue(project1.is_favorite)
with Form(project2) as form:
form.is_favorite = False
self.assertFalse(project2.is_favorite)
def test_change_project_or_partner_company(self):
""" Tests that it is impossible to change the company of a project
if the company of the partner is different and vice versa if the company of the project is set.
If the company of the project is not set, there are no restriction on its partner company-wise.
"""
company_1 = self.env.company
company_2 = self.env['res.company'].create({'name': 'Company 2'})
partner = self.env['res.partner'].create({
'name': 'Partner',
})
self.project_pigs.partner_id = partner
# Neither the partner nor the project have a company. Their companies can be updated.
self.assertFalse(partner.company_id)
self.assertFalse(self.project_pigs.company_id)
self.project_pigs.company_id = company_1
self.assertEqual(self.project_pigs.company_id, company_1, "The company of the project should have been updated.")
self.project_pigs.company_id = False
# if the partner company is set, the project's should also be set
partner.company_id = company_1
# If the partner has a company, the project must have the same
self.assertEqual(partner.company_id, self.project_pigs.company_id, "The company of the project should have been updated.")
# The partner has a company and the project has a company. The partner's can only be set to False, the project's can not be changed
with self.assertRaises(UserError):
# Cannot change the company of a project if both the project and its partner have a company
self.project_pigs.company_id = company_2
with self.assertRaises(UserError):
# Cannot change the company of a partner if both the project and its partner have a company
partner.company_id = company_2
partner.company_id = False
self.project_pigs.company_id = False
self.assertFalse(self.project_pigs.company_id, "The company of the project should have been set to False.")
self.project_pigs.company_id = company_1
self.project_goats.company_id = company_1
self.project_goats.partner_id = partner
with self.assertRaises(UserError):
# Cannot change the company of a partner that part of multiple projects with different companies
self.project_goats.partner_id.company_id = company_2
# The project has a company, but the partner has none. The partner can only be set to False/project.company but the project can have any new company.
with self.assertRaises(UserError):
# Cannot change the company of a partner if both the project and its partner have a company
partner.company_id = company_2
self.project_pigs.company_id = company_2
self.assertEqual(self.project_pigs.company_id, company_2, "The company of the project should have been updated.")
self.project_pigs.company_id = False
self.assertFalse(self.project_pigs.company_id, "The company of the project should have been set to False.")
self.project_pigs.company_id = company_1
partner.company_id = company_1
self.assertEqual(partner.company_id, company_1, "The company of the partner should have been updated.")
def test_add_customer_rating_project(self):
""" Tests that the rating_ids field contains a rating once created
"""
rate = self.env['rating.rating'].create({
'res_id': self.task_1.id,
'parent_res_id': self.project_pigs.id,
'res_model_id': self.env['ir.model']._get('project.task').id,
'parent_res_model_id': self.env['ir.model']._get('project.project').id,
})
rating = 5
self.task_1.rating_apply(rating, token=rate.access_token)
self.project_pigs.invalidate_recordset(['rating_ids'])
self.assertEqual(len(self.project_pigs.rating_ids), 1, "There should be 1 rating linked to the project")
def test_planned_dates_consistency_for_project(self):
""" This test ensures that a project can not have date start set,
if its date end is False and that it can not have a date end
set if its date start is False .
"""
self.assertFalse(self.project_goats.date_start)
self.assertFalse(self.project_goats.date)
self.project_goats.write({'date_start': '2021-09-27', 'date': '2021-09-28'})
self.assertEqual(fields.Date.to_string(self.project_goats.date_start), '2021-09-27', "The start date should be set.")
self.assertEqual(fields.Date.to_string(self.project_goats.date), '2021-09-28', "The expiration date should be set.")
self.project_goats.date_start = False
self.assertFalse(fields.Date.to_string(self.project_goats.date_start), "The start date should be unset.")
self.assertFalse(fields.Date.to_string(self.project_goats.date), "The expiration date should be unset as well.")
self.project_goats.write({'date_start': '2021-09-27', 'date': '2021-09-28'})
self.project_goats.date = False
self.assertFalse(fields.Date.to_string(self.project_goats.date), "The expiration date should be unset.")
self.assertFalse(fields.Date.to_string(self.project_goats.date_start), "The start date should be unset as well.")
self.project_goats.write({'date_start': '2021-09-27'})
self.assertFalse(fields.Date.to_string(self.project_goats.date_start), "The start date should be unset since expiration date if not set.")
self.assertFalse(fields.Date.to_string(self.project_goats.date), "The expiration date should stay be unset.")
self.project_goats.write({'date': '2021-09-28'})
self.assertFalse(fields.Date.to_string(self.project_goats.date), "The expiration date should be unset since the start date if not set.")
self.assertFalse(fields.Date.to_string(self.project_goats.date_start), "The start date should be unset.")
self.project_pigs.write({'date_start': '2021-09-23', 'date': '2021-09-24'})
# Case 1: one project has date range set and the other one has no date range set.
projects = self.project_goats + self.project_pigs
projects.write({'date_start': '2021-09-27', 'date': '2021-09-28'})
for p in projects:
self.assertEqual(fields.Date.to_string(p.date_start), '2021-09-27', f'The start date of {p.name} should be updated.')
self.assertEqual(fields.Date.to_string(p.date), '2021-09-28', f'The expiration date of {p.name} should be updated.')
self.project_goats.date_start = False
projects.write({'date_start': '2021-09-30'})
self.assertFalse(fields.Date.to_string(self.project_goats.date_start), 'The start date should not be updated')
self.assertFalse(fields.Date.to_string(self.project_goats.date), 'The expiration date should not be updated')
self.assertEqual(fields.Date.to_string(self.project_pigs.date_start), '2021-09-27', 'The start date should not be updated.')
self.assertEqual(fields.Date.to_string(self.project_pigs.date), '2021-09-28', 'The expiration date should not be updated.')
projects.write({'date_start': False})
for p in projects:
self.assertFalse(fields.Date.to_string(p.date_start), f'The start date of {p.name} should be set to False.')
self.assertFalse(fields.Date.to_string(p.date), f'The expiration date of {p.name} should be set to False.')
self.project_pigs.write({'date_start': '2021-09-27', 'date': '2021-09-28'})
projects.write({'date': False})
for p in projects:
self.assertFalse(fields.Date.to_string(p.date_start), f'The start date of {p.name} should be set to False.')
self.assertFalse(fields.Date.to_string(p.date), f'The expiration date of {p.name} should be set to False.')
# Case 2: both projects have no date range set
projects.write({'date_start': '2021-09-27'})
for p in projects:
self.assertFalse(fields.Date.to_string(p.date_start), f'The start date of {p.name} should not be updated.')
self.assertFalse(fields.Date.to_string(p.date), f'The expiration date of {p.name} should not be updated.')
projects.write({'date': '2021-09-28'})
for p in projects:
self.assertFalse(fields.Date.to_string(p.date_start), f'The start date of {p.name} should not be updated.')
self.assertFalse(fields.Date.to_string(p.date), f'The expiration date of {p.name} should not be updated.')
projects.write({'date_start': '2021-09-27', 'date': '2021-09-28'})
for p in projects:
self.assertEqual(fields.Date.to_string(p.date_start), '2021-09-27', f'The start date of {p.name} should be updated.')
self.assertEqual(fields.Date.to_string(p.date), '2021-09-28', f'The expiration date of {p.name} should be updated.')
# Case 3: both projects have a different date range set
self.project_pigs.write({'date_start': '2021-09-23', 'date': '2021-09-30'})
projects.write({'date_start': '2021-09-22'})
self.assertEqual(fields.Date.to_string(self.project_goats.date_start), '2021-09-22', 'The start date should be updated.')
self.assertEqual(fields.Date.to_string(self.project_goats.date), '2021-09-28', 'The expiration date should not be updated.')
self.assertEqual(fields.Date.to_string(self.project_pigs.date_start), '2021-09-22', 'The start date should be updated.')
self.assertEqual(fields.Date.to_string(self.project_pigs.date), '2021-09-30', 'The expiration date should not be updated.')
projects.write({'date': '2021-09-29'})
self.assertEqual(fields.Date.to_string(self.project_goats.date_start), '2021-09-22', 'The start date should not be updated.')
self.assertEqual(fields.Date.to_string(self.project_goats.date), '2021-09-29', 'The expiration date should be updated.')
self.assertEqual(fields.Date.to_string(self.project_pigs.date_start), '2021-09-22', 'The start date should not be updated.')
self.assertEqual(fields.Date.to_string(self.project_pigs.date), '2021-09-29', 'The expiration date should be updated.')
projects.write({'date_start': False})
for p in projects:
self.assertFalse(fields.Date.to_string(p.date_start), f'The start date of {p.name} should be set to False.')
self.assertFalse(fields.Date.to_string(p.date), f'The expiration date of {p.name} should be set to False.')
self.project_goats.write({'date_start': '2021-09-27', 'date': '2021-09-28'})
self.project_pigs.write({'date_start': '2021-09-23', 'date': '2021-09-30'})
projects.write({'date': False})
for p in projects:
self.assertFalse(fields.Date.to_string(p.date_start), f'The start date of {p.name} should be set to False.')
self.assertFalse(fields.Date.to_string(p.date), f'The expiration date of {p.name} should be set to False.')
self.project_goats.write({'date_start': '2021-09-27', 'date': '2021-09-28'})
self.project_pigs.write({'date_start': '2021-09-23', 'date': '2021-09-30'})
projects.write({'date_start': '2021-09-25', 'date': '2021-09-26'})
for p in projects:
self.assertEqual(fields.Date.to_string(p.date_start), '2021-09-25', f'The start date of {p.name} should be updated.')
self.assertEqual(fields.Date.to_string(p.date), '2021-09-26', f'The expiration date of {p.name} should be updated.')
def test_create_task_in_batch_with_email_cc(self):
user_a, user_b, user_c = self.env['res.users'].create([{
'name': 'user A',
'login': 'loginA',
'email': 'email@bisous1',
}, {
'name': 'user B',
'login': 'loginB',
'email': 'email@bisous2',
}, {
'name': 'user C',
'login': 'loginC',
'email': 'email@bisous3',
}])
partner = self.env['res.partner'].create({
'name': 'partner',
'email': 'email@bisous4',
})
task_1, task_2 = self.env['project.task'].with_context({'mail_create_nolog': True}).create([{
'name': 'task 1',
'project_id': self.project_pigs.id,
'email_cc': 'email@bisous1, email@bisous2, email@bisous4'
}, {
'name': 'task 2',
'project_id': self.project_pigs.id,
'email_cc': 'email@bisous3, email@bisous2, email@bisous4'
}])
self.assertTrue(user_a.partner_id in task_1.message_partner_ids)
self.assertTrue(user_b.partner_id in task_1.message_partner_ids)
self.assertFalse(user_c.partner_id in task_1.message_partner_ids)
self.assertFalse(partner in task_1.message_partner_ids)
self.assertFalse(user_a.partner_id in task_2.message_partner_ids)
self.assertTrue(user_b.partner_id in task_2.message_partner_ids)
self.assertTrue(user_c.partner_id in task_2.message_partner_ids)
self.assertFalse(partner in task_2.message_partner_ids)
def test_create_private_task_in_batch(self):
""" This test ensures that copying private task in batch can be done correctly."""
task_0, task_1 = self.env['project.task'].create([{
'name': f'task {i}',
'user_ids': self.env.user.ids,
'project_id': False,
} for i in range(2)]).copy()
self.assertEqual(task_0.name, 'task 0 (copy)')
self.assertEqual(task_1.name, 'task 1 (copy)')
def test_duplicate_project_with_tasks(self):
""" Test to check duplication of projects tasks active state. """
project = self.env['project.project'].create({
'name': 'Project',
})
task = self.env['project.task'].create({
'name': 'Task',
'project_id': project.id,
})
# Duplicate active project with active task
project_dup = project.copy()
self.assertTrue(project_dup.active, "Active project should remain active when duplicating an active project")
self.assertEqual(project_dup.task_count, 1, "Duplicated project should have as many tasks as orginial project")
self.assertTrue(project_dup.tasks.active, "Active task should remain active when duplicating an active project")
# Duplicate active project with archived task
task.active = False
project_dup = project.copy()
self.assertTrue(project_dup.active, "Active project should remain active when duplicating an active project")
self.assertFalse(project_dup.tasks.active, "Archived task should remain archived when duplicating an active project")
# Duplicate archived project with archived task
project.active = False
project_dup = project.copy()
self.assertTrue(project_dup.active, "The new project should be active by default")
self.assertTrue(project_dup.tasks.active, "Archived task should be active when duplicating an archived project")
def test_create_analytic_account_batch(self):
""" This test will check that the '_create_analytic_account' method assigns the accounts to the projects in the right order. """
projects = self.env["project.project"].create([{
"name": f"Project {x}",
} for x in range(10)])
projects._create_analytic_account()
self.assertEqual(projects.mapped("name"), projects.account_id.mapped("name"), "The analytic accounts names should match with the projects.")
def test_task_count(self):
project1, project2 = self.env['project.project'].create([
{'name': 'project1'},
{'name': 'project2'},
])
self.env['project.task'].with_context(default_project_id=project1.id).create([
{'name': 'task1'},
{'name': 'task2', 'state': '1_done'},
{'name': 'task3', 'child_ids': [
Command.create({'name': 'subtask1', 'project_id': project1.id}),
Command.create({'name': 'subtask2', 'project_id': project1.id, 'state': '1_canceled'}),
Command.create({'name': 'subtask3', 'project_id': project2.id}),
Command.create({'name': 'subtask4', 'project_id': project1.id, 'child_ids': [
Command.create({'name': 'subsubtask41', 'project_id': project2.id}),
Command.create({'name': 'subsubtask42', 'project_id': project1.id})
]}),
Command.create({'name': 'subtask5', 'state': '1_done', 'project_id': project1.id, 'child_ids': [
Command.create({'name': 'subsubtask51', 'project_id': project1.id, 'state': '1_done'}),
]}),
]}
])
self.assertEqual(project1.task_count, 9)
self.assertEqual(project1.open_task_count, 5)
self.assertEqual(project1.closed_task_count, 4)
self.assertEqual(project2.task_count, 2)
self.assertEqual(project2.open_task_count, 2)
self.assertEqual(project2.closed_task_count, 0)
def test_archived_duplicate_task(self):
""" Test to check duplication of an archived task.
The duplicate of an archived task should be active.
"""
project = self.env['project.project'].create({
'name': 'Project',
})
task = self.env['project.task'].create({
'name': 'Task',
'project_id': project.id,
})
copy_task1 = task.copy()
self.assertTrue(copy_task1.active, "Active task should be active when duplicating an active task")
task.active = False
copy_task2 = task.copy()
self.assertTrue(copy_task2.active, "Archived task should be active when duplicating an archived task")
def test_duplicate_doesnt_copy_date(self):
project = self.env['project.project'].create({
'name': 'Project',
'date_start': '2021-09-20',
'date': '2021-09-28',
})
task = self.env['project.task'].create({
'name': 'Task',
'project_id': project.id,
'date_deadline': '2021-09-26',
})
project_copy = project.copy()
self.assertFalse(project_copy.date_start, "The project's date fields shouldn't be copied on project duplication")
self.assertFalse(project_copy.date, "The project's date fields shouldn't be copied on project duplication")
self.assertFalse(project_copy.task_ids.date_deadline, "The task's date fields shouldn't be copied on project duplication")
self.assertFalse(task.copy().date_deadline, "The task's date fields shouldn't be copied on task duplication")
def test_archived_subtask_not_copied_during_parent_task_duplication(self):
"""Test that archived subtasks are not copied when duplicating a parent task."""
parent_task = self.env['project.task'].create({
'name': 'Parent Task',
'project_id': self.project_pigs.id,
'child_ids': [
Command.create({
'name': 'child task',
'project_id': self.project_pigs.id,
}),
],
})
parent_task.child_ids.active = False
self.assertFalse(parent_task.copy().child_ids, "Archived subtask should not be copied")

View file

@ -1,11 +1,5 @@
# -*- coding: utf-8 -*-
import logging
from .test_project_base import TestProjectCommon
_logger = logging.getLogger(__name__)
class TestProjectConfig(TestProjectCommon):
"""Test module configuration and its effects on projects."""
@ -13,49 +7,120 @@ class TestProjectConfig(TestProjectCommon):
@classmethod
def setUpClass(cls):
super(TestProjectConfig, cls).setUpClass()
cls.Project = cls.env["project.project"]
cls.Settings = cls.env["res.config.settings"]
cls.features = (
# Pairs of associated (config_flag, project_flag)
("group_subtask_project", "allow_subtasks"),
("group_project_recurring_tasks", "allow_recurring_tasks"),
("group_project_rating", "rating_active"),
)
cls.user_settings_project_manager = cls.env['res.users.settings']._find_or_create_for_user(cls.user_projectmanager)
cls.user_settings_project_user = cls.env['res.users.settings']._find_or_create_for_user(cls.user_projectuser)
# Start with a known value on feature flags to ensure validity of tests
cls._set_feature_status(is_enabled=False)
@classmethod
def _set_feature_status(cls, is_enabled):
"""Set enabled/disabled status of all optional features in the
project app config to is_enabled (boolean).
def test_project_stages_feature_enable_views(self):
"""Check that the Gantt, Calendar and Activities views are
enabled when the 'Project Stage' feature is enabled.
"""
features_config = cls.Settings.create(
{feature[0]: is_enabled for feature in cls.features})
features_config.execute()
self.Settings.create({"group_project_stages": True}).execute() # enabling feature
menu_ids = set([self.env.ref('project.menu_projects').id, self.env.ref('project.menu_projects_config').id])
menu_loaded = set(self.env['ir.ui.menu']._load_menus_blacklist())
self.assertTrue(menu_ids.issubset(menu_loaded), "The menu project and menu projects config should be loaded")
def test_existing_projects_enable_features(self):
"""Check that *existing* projects have features enabled when
the user enables them in the module configuration.
def test_copy_project_manager_embedded_config_as_default(self):
"""
self._set_feature_status(is_enabled=True)
for config_flag, project_flag in self.features:
self.assertTrue(
self.project_pigs[project_flag],
"Existing project failed to adopt activation of "
f"{config_flag}/{project_flag} feature")
Test that when a user gets the embedded actions configs of a projet, he gets the configs of
the project manager as default if he has no personal configuration yet for a particular action.
The configs he gets should also be copied in its user's settings.
"""
project = self.env['project.project'].create({
'name': 'Test Project',
'user_id': self.user_projectmanager.id,
})
project_action = self.env['ir.actions.act_window'].create({
'name': 'Test Project Action',
'res_model': 'project.project',
})
# Create the embedded action config for the project manager
project_manager_config = self.env['res.users.settings.embedded.action'].create({
'user_setting_id': self.user_settings_project_manager.id,
'action_id': project_action.id,
'res_model': 'project.project',
'res_id': project.id,
'embedded_actions_order': 'false,3,2,1',
'embedded_actions_visibility': '1,2,3',
'embedded_visibility': True,
})
def test_new_projects_enable_features(self):
"""Check that after the user enables features in the module
configuration, *newly created* projects have those features
enabled as well.
# The project user has no personal configuration yet
self.assertFalse(self.user_settings_project_user.embedded_actions_config_ids)
# He should get the project manager configuration as default
project_user_config = self.user_settings_project_user.with_context(
res_model='project.project',
res_id=project.id,
).get_embedded_actions_settings()
self.assertDictEqual(
project_user_config[f'{project_action.id}+{project.id}'],
{
'embedded_actions_order': [False, 3, 2, 1],
'embedded_actions_visibility': [1, 2, 3],
'embedded_visibility': True,
},
)
# Check that the config has indeed been copied for the project user
project_user_config = self.user_settings_project_user.embedded_actions_config_ids
self.assertEqual(len(project_user_config), 1, 'There should be one embedded action setting created.')
self.assertEqual(project_user_config.action_id, project_manager_config.action_id, 'The action should match the one set in the project manager config.')
self.assertEqual(project_user_config.res_id, project_manager_config.res_id, 'The res_id should match the one set in the project manager config.')
self.assertEqual(project_user_config.res_model, project_manager_config.res_model, 'The res_model should match the one set in the project manager config.')
self.assertEqual(project_user_config.embedded_actions_order, project_manager_config.embedded_actions_order, 'The embedded actions order should match the one set in the project manager config.')
self.assertEqual(project_user_config.embedded_actions_visibility, project_manager_config.embedded_actions_visibility, 'The embedded actions visibility should match the one set in the project manager config.')
self.assertEqual(project_user_config.embedded_visibility, project_manager_config.embedded_visibility, 'The embedded visibility should match the one set in the project manager config.')
def test_no_copy_project_manager_embedded_config_as_default(self):
"""
self._set_feature_status(is_enabled=True)
project_cows = self.Project.create({
"name": "Cows",
"partner_id": self.partner_1.id})
for config_flag, project_flag in self.features:
self.assertTrue(
project_cows[project_flag],
f"Newly created project failed to adopt activation of "
f"{config_flag}/{project_flag} feature")
Test that when a user gets the embedded actions configs of a projet, he does not get the configs of
the project manager as default if he already has a personal configuration for some actions.
"""
project = self.env['project.project'].create({
'name': 'Test Project',
'user_id': self.user_projectmanager.id,
})
project_action = self.env['ir.actions.act_window'].create({
'name': 'Test Project Action',
'res_model': 'project.project',
})
# Create the embedded action config for the project manager
self.env['res.users.settings.embedded.action'].create({
'user_setting_id': self.user_settings_project_manager.id,
'action_id': project_action.id,
'res_model': 'project.project',
'res_id': project.id,
'embedded_actions_order': 'false,3,2,1',
'embedded_actions_visibility': '1,2,3',
'embedded_visibility': True,
})
# The project user already has a personal configuration
user_config = self.env['res.users.settings.embedded.action'].create({
'user_setting_id': self.user_settings_project_user.id,
'action_id': project_action.id,
'res_model': 'project.project',
'res_id': project.id,
'embedded_actions_order': 'false,1,2,3',
'embedded_actions_visibility': '2,3',
'embedded_visibility': False,
})
self.assertEqual(len(self.user_settings_project_user.embedded_actions_config_ids), 1)
# He should get his personal configuration as default
project_user_config = self.user_settings_project_user.with_context(
res_model='project.project',
res_id=project.id,
).get_embedded_actions_settings()
self.assertDictEqual(
project_user_config[f'{project_action.id}+{project.id}'],
{
'embedded_actions_order': [False, 1, 2, 3],
'embedded_actions_visibility': [2, 3],
'embedded_visibility': False,
},
)
# Check that no new config has been created for the project user
project_user_config = self.user_settings_project_user.embedded_actions_config_ids
self.assertEqual(len(project_user_config), 1, 'There should still be only one embedded action setting created.')
self.assertEqual(project_user_config, user_config, 'The existing user config should be unchanged.')

View file

@ -0,0 +1,131 @@
from odoo.addons.project.tests.test_project_base import TestProjectCommon
class TestProjectEmbeddedActionSettings(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = cls.env['res.users'].create({
'name': 'Test User',
'login': 'test_user',
'password': 'test_password',
})
cls.user_settings = cls.env['res.users.settings']._find_or_create_for_user(cls.user)
cls.window_action = cls.env['ir.actions.act_window'].create({
'name': 'Test Action',
'res_model': 'project.project',
})
cls.embedded_action_1, cls.embedded_action_2 = cls.env['ir.embedded.actions'].create([
{
'name': 'Embedded Action 1',
'parent_res_model': 'project.project',
'parent_action_id': cls.window_action.id,
'action_id': cls.window_action.id,
},
{
'name': 'Embedded Action 2',
'parent_res_model': 'project.project',
'parent_action_id': cls.window_action.id,
'action_id': cls.window_action.id,
},
])
cls.embedded_action_settings = cls.env['res.users.settings.embedded.action'].create({
'user_setting_id': cls.user_settings.id,
'action_id': cls.window_action.id,
'res_model': 'project.project',
'res_id': cls.project_goats.id,
'embedded_actions_order': f'{cls.embedded_action_2.id},false,{cls.embedded_action_1.id}',
'embedded_actions_visibility': f'{cls.embedded_action_1.id},{cls.embedded_action_2.id}',
'embedded_visibility': True,
})
def test_copy_embedded_action_settings(self):
'''
Test that embedded action settings are copied correctly when a project is copied.
'''
copied_project = self.project_goats.copy()
# Check if the embedded action settings are copied
copied_settings = self.env['res.users.settings.embedded.action'].search([
('user_setting_id', '=', self.user_settings.id),
('action_id', '=', self.window_action.id),
('res_id', '=', copied_project.id),
])
self.assertEqual(len(copied_settings), 1, 'There should be one embedded action setting for the copied project.')
self.assertEqual(len(self.user_settings.embedded_actions_config_ids), 2, 'There should be two embedded action settings after copying the project.')
self.assertEqual(copied_settings.action_id, self.embedded_action_settings.action_id, 'The action_id should match the original.')
self.assertEqual(copied_settings.res_id, copied_project.id, 'The res_id should match the copied project id.')
self.assertEqual(copied_settings.res_model, self.embedded_action_settings.res_model, 'The res_model should match the original.')
self.assertEqual(copied_settings.embedded_actions_order, self.embedded_action_settings.embedded_actions_order, 'The embedded actions order should match the original.')
self.assertEqual(copied_settings.embedded_actions_visibility, self.embedded_action_settings.embedded_actions_visibility, 'The embedded actions visibility should match the original.')
self.assertEqual(copied_settings.embedded_visibility, self.embedded_action_settings.embedded_visibility, 'The embedded visibility should match the original.')
def test_copy_custom_embedded_action_settings(self):
'''
Test that the user-specific actions are not copied in the settings when a project is copied.
'''
# Create user-specific and shared embedded actions
user_specific_embedded_action, shared_embedded_action = self.env['ir.embedded.actions'].create([
{
'name': 'Custom User-Specific Action',
'parent_res_model': 'project.project',
'parent_action_id': self.window_action.id,
'action_id': self.window_action.id,
'parent_res_id': self.project_pigs.id,
'user_id': self.user.id, # User-specific action
},
{
'name': 'Custom Shared Action',
'parent_res_model': 'project.project',
'parent_action_id': self.window_action.id,
'action_id': self.window_action.id,
'parent_res_id': self.project_pigs.id,
'user_id': False, # Shared action
},
])
# Create settings containing those embedded actions
self.env['res.users.settings.embedded.action'].create({
'user_setting_id': self.user_settings.id,
'action_id': self.window_action.id,
'res_model': 'project.project',
'res_id': self.project_pigs.id,
'embedded_actions_order': f'{shared_embedded_action.id},{user_specific_embedded_action.id}',
'embedded_actions_visibility': f'{user_specific_embedded_action.id},{shared_embedded_action.id}',
'embedded_visibility': True,
})
# Copy the project
copied_project = self.project_pigs.copy()
# Get the copied shared embedded action
copied_shared_embedded_action = self.env['ir.embedded.actions'].search([
('parent_res_model', '=', 'project.project'),
('parent_res_id', '=', copied_project.id),
('user_id', '=', False) # Ensure it's a shared action
], limit=1)
# Check if the shared embedded action settings are correctly copied
copied_settings = self.env['res.users.settings.embedded.action'].search([
('user_setting_id', '=', self.user_settings.id),
('action_id', '=', self.window_action.id),
('res_id', '=', copied_project.id),
])
self.assertEqual(len(copied_settings), 1, 'There should be one embedded action setting for the copied project.')
self.assertEqual(len(self.user_settings.embedded_actions_config_ids), 3, 'There should be three embedded action settings after copying the project.')
self.assertEqual(copied_settings.action_id, self.embedded_action_settings.action_id, 'The action_id should match the original.')
self.assertEqual(copied_settings.res_id, copied_project.id, 'The res_id should match the copied project id.')
self.assertEqual(copied_settings.res_model, self.embedded_action_settings.res_model, 'The res_model should match the original.')
self.assertEqual(copied_settings.embedded_actions_order, str(copied_shared_embedded_action.id), 'Only the shared embedded action should be copied in the actions order.')
self.assertEqual(copied_settings.embedded_actions_visibility, str(copied_shared_embedded_action.id), 'Only the shared embedded action should be copied in the visible actions.')
self.assertEqual(copied_settings.embedded_visibility, self.embedded_action_settings.embedded_visibility, 'The embedded visibility should match the original.')
def test_unlink_project_removes_embedded_action_settings(self):
'''
Test that unlinking a project removes the embedded action settings associated with it.
'''
self.assertTrue(self.embedded_action_settings.exists(), 'The embedded action settings should exist before unlinking the project.')
self.project_goats.unlink()
# Check if the embedded action settings are removed
self.assertFalse(self.embedded_action_settings.exists(), 'The embedded action settings should be removed when the project is unlinked.')
remaining_settings = self.env['res.users.settings.embedded.action'].search([
('user_setting_id', '=', self.user_settings.id),
('action_id', '=', self.window_action.id),
('res_id', '=', self.project_goats.id),
])
self.assertEqual(len(remaining_settings), 0, 'There should be no more embedded action settings for the unlinked project.')

View file

@ -1,138 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from markupsafe import Markup
from .test_project_base import TestProjectCommon
from odoo import Command
from odoo.tools import mute_logger
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.mail.tests.common import MailCase
from odoo.exceptions import AccessError
EMAIL_TPL = """Return-Path: <whatever-2a840@postmaster.twitter.com>
X-Original-To: {to}
Delivered-To: {to}
To: {to}
cc: {cc}
Received: by mail1.odoo.com (Postfix, from userid 10002)
id 5DF9ABFB2A; Fri, 10 Aug 2012 16:16:39 +0200 (CEST)
Message-ID: {msg_id}
Date: Tue, 29 Nov 2011 12:43:21 +0530
From: {email_from}
MIME-Version: 1.0
Subject: {subject}
Content-Type: text/plain; charset=ISO-8859-1; format=flowed
Hello,
This email should create a new entry in your module. Please check that it
effectively works.
Thanks,
--
Raoul Boitempoils
Integrator at Agrolait"""
class TestProjectFlow(TestProjectCommon, MailCommon):
class TestProjectFlow(TestProjectCommon, MailCase):
def test_project_process_project_manager_duplicate(self):
pigs = self.project_pigs.with_user(self.user_projectmanager)
dogs = pigs.copy()
self.assertEqual(len(dogs.tasks), 2, 'project: duplicating a project must duplicate its tasks')
@mute_logger('odoo.addons.mail.models.mail_thread')
def test_task_process_without_stage(self):
# Do: incoming mail from an unknown partner on an alias creates a new task 'Frogs'
task = self.format_and_process(
EMAIL_TPL, to='project+pigs@mydomain.com, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
email_from='%s' % self.user_projectuser.email,
subject='Frogs', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>',
target_model='project.task')
# Test: one task created by mailgateway administrator
self.assertEqual(len(task), 1, 'project: message_process: a new project.task should have been created')
# Test: check partner in message followers
self.assertIn(self.partner_2, task.message_partner_ids, "Partner in message cc is not added as a task followers.")
# Test: messages
self.assertEqual(len(task.message_ids), 1,
'project: message_process: newly created task should have 1 message: email')
self.assertEqual(task.message_ids.subtype_id, self.env.ref('project.mt_task_new'),
'project: message_process: first message of new task should have Task Created subtype')
self.assertEqual(task.message_ids.author_id, self.user_projectuser.partner_id,
'project: message_process: second message should be the one from Agrolait (partner failed)')
self.assertEqual(task.message_ids.subject, 'Frogs',
'project: message_process: second message should be the one from Agrolait (subject failed)')
# Test: task content
self.assertEqual(task.name, 'Frogs', 'project_task: name should be the email subject')
self.assertEqual(task.project_id, self.project_pigs, 'project_task: incorrect project')
self.assertEqual(task.stage_id.sequence, False, "project_task: shouldn't have a stage, i.e. sequence=False")
@mute_logger('odoo.addons.mail.models.mail_thread')
def test_task_process_with_stages(self):
# Do: incoming mail from an unknown partner on an alias creates a new task 'Cats'
task = self.format_and_process(
EMAIL_TPL, to='project+goats@mydomain.com, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
email_from='%s' % self.user_projectuser.email,
subject='Cats', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>',
target_model='project.task')
# Test: one task created by mailgateway administrator
self.assertEqual(len(task), 1, 'project: message_process: a new project.task should have been created')
# Test: check partner in message followers
self.assertIn(self.partner_2, task.message_partner_ids, "Partner in message cc is not added as a task followers.")
# Test: messages
self.assertEqual(len(task.message_ids), 1,
'project: message_process: newly created task should have 1 messages: email')
self.assertEqual(task.message_ids.subtype_id, self.env.ref('project.mt_task_new'),
'project: message_process: first message of new task should have Task Created subtype')
self.assertEqual(task.message_ids.author_id, self.user_projectuser.partner_id,
'project: message_process: first message should be the one from Agrolait (partner failed)')
self.assertEqual(task.message_ids.subject, 'Cats',
'project: message_process: first message should be the one from Agrolait (subject failed)')
# Test: task content
self.assertEqual(task.name, 'Cats', 'project_task: name should be the email subject')
self.assertEqual(task.project_id, self.project_goats, 'project_task: incorrect project')
self.assertEqual(task.stage_id.sequence, 1, "project_task: should have a stage with sequence=1")
@mute_logger('odoo.addons.mail.models.mail_thread')
def test_task_from_email_alias(self):
# Do: incoming mail from a known partner email on an alias creates a new task 'Super Frog'
task = self.format_and_process(
EMAIL_TPL, to='project+goats@mydomain.com, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
email_from='%s' % self.user_portal.email,
subject='Super Frog', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>',
target_model='project.task')
# Test: one task created by mailgateway administrator
self.assertEqual(len(task), 1, 'project: message_process: a new project.task should have been created')
# Test: check partner in message followers
self.assertIn(self.partner_2, task.message_partner_ids, "Partner in message cc is not added as a task followers.")
# Test: check partner has not been assgined
self.assertFalse(task.user_ids, "Partner is not added as an assignees")
# Test: messages
self.assertEqual(len(task.message_ids), 1,
'project: message_process: newly created task should have 1 messages: email')
self.assertEqual(task.message_ids.subtype_id, self.env.ref('project.mt_task_new'),
'project: message_process: first message of new task should have Task Created subtype')
self.assertEqual(task.message_ids.author_id, self.user_portal.partner_id,
'project: message_process: first message should be the one from Agrolait (partner failed)')
self.assertEqual(task.message_ids.subject, 'Super Frog',
'project: message_process: first message should be the one from Agrolait (subject failed)')
# Test: task content
self.assertEqual(task.name, 'Super Frog', 'project_task: name should be the email subject')
self.assertEqual(task.project_id, self.project_goats, 'project_task: incorrect project')
self.assertEqual(task.stage_id.sequence, 1, "project_task: should have a stage with sequence=1")
self.assertEqual(
task.description,
Markup(
'<pre>Hello,\n\nThis email should create a new entry in your module. Please check that it\neffectively works.\n\nThanks,\n<span data-o-mail-quote="1">\n--\nRaoul Boitempoils\nIntegrator at Agrolait</span></pre>\n'
),
'The task description should be the email content.',
)
def test_subtask_process(self):
"""
Check subtask mecanism and change it from project.
@ -153,7 +31,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
'user_ids': self.user_projectuser,
'project_id': self.project_pigs.id,
'partner_id': self.partner_2.id,
'planned_hours': 12,
'allocated_hours': 12,
})
another_parent_task = Task.create({
@ -161,7 +39,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
'user_ids': self.user_projectuser,
'project_id': self.project_pigs.id,
'partner_id': self.partner_3.id,
'planned_hours': 0,
'allocated_hours': 0,
})
# remove the partner_id of the 'goats' project
@ -174,7 +52,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
# the child task 1 is linked to a project without partner_id (goats project)
child_task_1 = Task.with_context(default_project_id=self.project_goats.id, default_parent_id=parent_task.id).create({
'name': 'Task Child with project',
'planned_hours': 3,
'allocated_hours': 3,
})
# the child task 2 is linked to a project with a partner_id (pigs project)
@ -182,8 +60,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
'name': 'Task Child without project',
'parent_id': parent_task.id,
'project_id': self.project_pigs.id,
'display_project_id': self.project_pigs.id,
'planned_hours': 5,
'allocated_hours': 5,
})
self.assertEqual(
@ -199,7 +76,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
"Parent task should have 2 children")
self.assertEqual(
parent_task.subtask_planned_hours, 8,
parent_task.subtask_allocated_hours, 8,
"Planned hours of subtask should impact parent task")
# change the parent of a subtask without a project partner_id
@ -222,7 +99,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
# set a project with partner_id to a subtask without project partner_id
child_task_1.write({
'display_project_id': self.project_pigs.id
'project_id': self.project_pigs.id
})
self.assertNotEqual(
@ -236,7 +113,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
# set a project with partner_id to a subtask with a project partner_id
child_task_2.write({
'display_project_id': self.project_goats.id
'project_id': self.project_goats.id
})
self.assertEqual(
@ -359,11 +236,11 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
self.assertEqual(task.personal_stage_id.stage_id.name, stages[0].get('name'), "tasks assigned to the current user should be in the right default stage")
def test_send_rating_review(self):
project_settings = self.env["res.config.settings"].create({'group_project_rating': True})
project_settings.execute()
self.assertTrue(self.project_goats.rating_active, 'The customer ratings should be enabled in this project.')
won_stage = self.project_goats.type_ids[-1]
won_stage.write({
'rating_active': True,
'rating_status': 'stage',
})
rating_request_mail_template = self.env.ref('project.rating_project_request_email_template')
won_stage.write({'rating_template_id': rating_request_mail_template.id})
tasks = self.env['project.task'].with_context(mail_create_nolog=True, default_project_id=self.project_goats.id).create([
@ -427,42 +304,173 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
self.assertEqual(len(project_A.message_ids), init_nb_log + 2,
"should have 2 new messages: one for tracking, one for template")
def test_project_notify_get_recipients_groups(self):
projects = self.env['project.project'].create([
{
'name': 'public project',
'privacy_visibility': 'portal',
'partner_id': self.partner_1.id,
},
{
'name': 'internal project',
'privacy_visibility': 'employees',
'partner_id': self.partner_1.id,
},
{
'name': 'private project',
'privacy_visibility': 'followers',
'partner_id': self.partner_1.id,
},
])
for project in projects:
groups = project._notify_get_recipients_groups()
groups_per_key = {g[0]: g for g in groups}
for key, group in groups_per_key.items():
has_button_access = group[2]['has_button_access']
if key in ['portal', 'portal_customer']:
self.assertEqual(
has_button_access,
project.name == 'public project',
"Only the public project should have its name clickable in the email sent to the customer when an email is sent via a email template set in the project stage for instance."
)
elif key == 'user':
self.assertTrue(has_button_access)
def test_private_task_search_tag(self):
task = self.env['project.task'].create({
'name': 'Test Private Task',
})
# Tag name_search should not raise Error if project_id is False
task.tag_ids.with_context(project_id=task.project_id.id).name_search(
args=["!", ["id", "in", []]])
domain=["!", ["id", "in", []]])
def test_copy_project_with_default_name(self):
""" Test the new project after the duplication got the exepected name
Test Cases:
==========
1. Duplicate a project
2. Check the new project got the name of the project to copy plus `(copy)`
3. Duplicate a project with default name
4. Check the new project got the name defined in the default
"""
project = self.project_pigs.copy()
self.assertEqual(project.name, 'Pigs (copy)', "The name of the copied project should be 'Pigs (copy)'")
project = self.project_pigs.copy({'name': 'Pigs 2'})
self.assertEqual(project.name, 'Pigs 2', "The name of the copied project should be 'Pigs 2'")
def test_description_field_history_on_update(self):
"""Test updating 'description' field in project task and checking history content at revision id."""
task = self.env['project.task'].create({
'name': 'Test Task',
'description': 'Hello',
})
task.description = False
self.assertEqual(task.html_field_history_get_content_at_revision('description', 1), '<p>Hello</p>', "should recover previous text for description")
def test_copy_project_with_embedded_actions(self):
project_pigs_milestone_action = self.env['ir.actions.act_window'].create({
'name': 'Milestones',
'res_model': 'project.milestone',
'view_mode': 'kanban,list,form',
'domain': f"[('project_id', '=', {self.project_pigs.id})]",
})
task_action = self.env['ir.actions.act_window'].create({
'name': 'Tasks',
'res_model': 'project.task',
'view_mode': 'kanban,list,form',
'domain': "[('project_id', '=', active_id), ('display_in_project', '=', True)]",
'context': "{'default_project_id': active_id}",
})
task_embedded_action = self.env['ir.embedded.actions'].create({
'parent_res_model': 'project.project',
'parent_res_id': self.project_pigs.id,
'action_id': project_pigs_milestone_action.id,
'parent_action_id': task_action.id,
})
project_model = self.env['ir.model'].search([('model', '=', 'project.task')])
task_embedded_filter = self.env['ir.filters'].create({
'name': 'filter',
'embedded_action_id': task_embedded_action.id,
'embedded_parent_res_id': self.project_pigs.id,
'action_id': project_pigs_milestone_action.id,
'model_id': project_model.id,
})
new_project_pigs = self.project_pigs.copy()
embedded_action = self.env['ir.embedded.actions'].search([
('parent_res_model', '=', 'project.project'),
('parent_res_id', '=', new_project_pigs.id),
])
self.assertTrue(
embedded_action,
'The embedded action linked to project pigs should also be copied.',
)
self.assertEqual(
embedded_action.action_id,
task_embedded_action.action_id,
"The new embedded action should have the same action than the one copied.",
)
self.assertEqual(
embedded_action.parent_res_model,
task_embedded_action.parent_res_model,
)
self.assertEqual(
embedded_action.parent_action_id,
task_embedded_action.parent_action_id,
)
duplicated_task_embedded_filter = embedded_action.filter_ids
self.assertEqual(
len(duplicated_task_embedded_filter),
1,
"The filter linked to the original embedded action should also be copied."
)
self.assertEqual(duplicated_task_embedded_filter.name, f"{task_embedded_filter.name} (copy)")
self.assertEqual(duplicated_task_embedded_filter.embedded_action_id, embedded_action)
self.assertEqual(duplicated_task_embedded_filter.embedded_parent_res_id, new_project_pigs.id)
self.assertEqual(duplicated_task_embedded_filter.action_id, task_embedded_filter.action_id)
self.assertEqual(duplicated_task_embedded_filter.model_id, task_embedded_filter.model_id)
def test_do_not_copy_project_stage(self):
stage = self.env['project.project.stage'].create({'name': 'Custom stage'}) # Default sequence is 50
self.project_pigs.stage_id = stage.id
project_copy = self.project_pigs.with_context(default_stage_id=stage.id).copy()
self.assertNotEqual(project_copy.stage_id, self.project_pigs.stage_id, 'Copied project should have lowest sequence stage')
def test_project_task_copy_without_archive_user(self):
self.user_projectuser.action_archive()
task = self.task_1.copy()
self.assertFalse(task.user_ids)
task_b = self.task_2.copy({
'name': 'Task B',
'user_ids': [Command.set([self.user_projectuser.id, self.user_projectmanager.id])],
})
self.assertEqual(self.user_projectuser + self.user_projectmanager, task_b.user_ids)
def test_project_sub_task_copy_without_archive_user(self):
self.task_1.write({
'child_ids': [self.task_2.id]
})
self.user_projectmanager.action_archive()
task_1_copy = self.task_1.copy()
self.assertFalse(task_1_copy.child_ids.user_ids)
def test_task_email_context_with_subtitles(self):
task = self.env['project.task'].create({
'name': 'Task',
'user_ids': [Command.set([self.user_projectuser.id])],
'project_id': self.project_goats.id,
})
self.assertFalse(self.project_goats.message_follower_ids)
self.assertEqual(self.project_goats.privacy_visibility, 'followers')
self.project_goats.invalidate_recordset()
render_context = task.with_user(self.user_projectuser)._notify_by_email_prepare_rendering_context(task.message_ids, {})
self.assertListEqual(render_context['subtitles'], ['Task', 'Project: Goats, Stage: New'])
def test_project_multi_tasks_copy_with_archive_user(self):
"""
Step 1: Create new an active project user
Step 2: Create three tasks
- Task 1: No users assigned.
- Task 2: Assigned to two active users.
- Task 3: Assigned to one active and one soon-to-be-archived user.
Step 3: Archive one of the users
Step 4: Copy all tasks
Step 5: Validate expected user_ids on copied tasks
- Task1 had no users expect no users in the copied task.
- Task2 had 2 active users both should be preserved.
- Task3 had one active + one archived user only active (self.user_projectuser) should be preserved in the copy.
"""
user_projectuser = self.user_projectuser.copy()
tasks = self.env['project.task'].create([{
'name': 'Task1',
'project_id': self.project_goats.id,
}, {
'name': 'Task2',
'user_ids': [Command.set([self.user_projectuser.id, self.user_projectmanager.id])],
'project_id': self.project_goats.id,
}, {
'name': 'Task3',
'user_ids': [Command.set([self.user_projectuser.id, user_projectuser.id])],
'project_id': self.project_goats.id,
}])
user_projectuser.action_archive()
task1, task_2, task_3 = tasks.copy()
self.assertFalse(task1.user_ids)
self.assertEqual(self.user_projectuser + self.user_projectmanager, task_2.user_ids)
self.assertEqual(self.user_projectuser, task_3.user_ids)

View file

@ -0,0 +1,704 @@
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.project.tests.test_project_base import TestProjectCommon
from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE
from odoo.tests import tagged, users, new_test_user
from odoo.tools import formataddr, mute_logger
from odoo.fields import Command
@tagged('post_install', '-at_install', 'mail_flow', 'mail_tools')
class TestProjectMailFeatures(TestProjectCommon, MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
# set high threshold to be sure to not hit mail limit during tests for a model
cls.env['ir.config_parameter'].sudo().set_param('mail.gateway.loop.threshold', 50)
# be sure to test emails
cls.user_employee.notification_type = 'email'
cls.user_projectuser.notification_type = 'email'
cls.user_projectmanager.notification_type = 'inbox'
# simple template used in auto acknowledgement
cls.test_template = cls.env['mail.template'].create({
'auto_delete': True,
'body_html': '<p>Hello <t t-out="object.partner_id.name"/></p>',
'lang': '{{ object.partner_id.lang or object.user_ids[:1].lang or user.lang }}',
'model_id': cls.env['ir.model']._get_id('project.task'),
'name': 'Test Acknowledge',
'subject': 'Test Acknowledge {{ object.name }}',
'use_default_to': True,
})
# Test followers-based project
cls.project_followers = cls.env['project.project'].create({
'alias_name': 'help',
'name': 'Goats',
'partner_id': cls.partner_1.id,
'privacy_visibility': 'followers',
'type_ids': [
(0, 0, {
'mail_template_id': cls.test_template.id,
'name': 'New',
'sequence': 1,
}),
(0, 0, {
'name': 'Validated',
'sequence': 10,
})],
})
cls.project_followers_alias = cls.project_followers.alias_id
# add some project followers to check followers propagation notably
cls.project_followers.message_subscribe(
partner_ids=(cls.user_projectuser.partner_id + cls.user_projectmanager.partner_id).ids,
# follow 'new tasks' to receive notification for incoming emails directly
subtype_ids=(cls.env.ref('mail.mt_comment') + cls.env.ref('project.mt_project_task_new')).ids
)
def setUp(self):
super().setUp()
with mute_logger('odoo.addons.mail.models.mail_thread'):
self.test_task = self.format_and_process(
MAIL_TEMPLATE, self.user_portal.email_formatted,
self.project_followers_alias.alias_full_name,
cc=self.partner_2.email_formatted,
subject='Data Test Task',
target_model='project.task',
)
self.flush_tracking()
def test_assert_initial_values(self):
""" Check base values coherency for tests clarity """
self.assertEqual(
self.project_followers.message_partner_ids,
self.user_projectuser.partner_id + self.user_projectmanager.partner_id)
self.assertEqual(self.test_task.project_id, self.project_followers)
# check for partner creation, should not pre-exist
self.assertFalse(self.env['res.partner'].search(
[('email_normalized', 'in', {'new.cc@test.agrolait.com', 'new.customer@test.agrolait.com', 'new.author@test.agrolait.com'})])
)
def test_project_notify_get_recipients_groups(self):
projects = self.env['project.project'].create([
{
'name': 'public project',
'privacy_visibility': 'portal',
'partner_id': self.partner_1.id,
},
{
'name': 'internal project',
'privacy_visibility': 'employees',
'partner_id': self.partner_1.id,
},
{
'name': 'private project',
'privacy_visibility': 'followers',
'partner_id': self.partner_1.id,
},
])
for project in projects:
groups = project._notify_get_recipients_groups(self.env['mail.message'], False)
groups_per_key = {g[0]: g for g in groups}
for key, group in groups_per_key.items():
has_button_access = group[2]['has_button_access']
if key in ['portal', 'portal_customer']:
self.assertEqual(
has_button_access,
project.name == 'public project',
"Only the public project should have its name clickable in the email sent to the customer when an email is sent via a email template set in the project stage for instance."
)
elif key == 'user':
self.assertTrue(has_button_access)
def test_task_creation_no_stage(self):
""" Test receiving email in a project without stage, should create task as intended """
internal_followers = self.user_projectuser.partner_id + self.user_projectmanager.partner_id
self.project_followers.type_ids = [(5, 0)]
incoming_cc = f'"New Cc" <new.cc@test.agrolait.com>, {self.partner_2.email_formatted}'
incoming_to = f'{self.project_followers_alias.alias_full_name}, {self.partner_1.email_formatted}, "New Customer" <new.customer@test.agrolait.com>'
incoming_to_filtered = f'{self.partner_1.email_formatted}, "New Customer" <new.customer@test.agrolait.com>'
with self.mock_mail_gateway():
task = self.format_and_process(
MAIL_TEMPLATE,
self.user_portal.email_formatted,
incoming_to,
cc=incoming_cc,
subject=f'Test from {self.user_portal.name}',
target_model='project.task',
)
self.flush_tracking()
self.assertEqual(task.project_id, self.project_followers)
self.assertFalse(task.stage_id)
self.assertEqual(len(task.message_ids), 1)
self.assertMailNotifications(
task.message_ids,
[
{
'content': 'Please call me as soon as possible',
'message_type': 'email',
'message_values': {
'author_id': self.user_portal.partner_id,
'email_from': self.user_portal.email_formatted,
# coming from incoming email
'incoming_email_cc': incoming_cc,
'incoming_email_to': incoming_to_filtered,
'mail_server_id': self.env['ir.mail_server'],
# followers of 'new task' subtype (but not original To as they
# already received the email)
'notified_partner_ids': internal_followers,
# deduced from 'To' and 'Cc' (recognized only)
'partner_ids': self.partner_1 + self.partner_2,
'parent_id': self.env['mail.message'],
'reply_to': formataddr((
self.user_portal.name,
self.project_followers_alias.alias_full_name
)),
'subject': f'Test from {self.user_portal.name}',
'subtype_id': self.env.ref('project.mt_task_new'),
},
'notif': [
{'partner': self.user_projectmanager.partner_id, 'type': 'inbox',},
{'partner': self.user_projectuser.partner_id, 'type': 'email',},
],
},
],
)
def test_task_creation_notifies_author(self):
""" Check auto acknowledgment mail sent at new task. It should notify
task creator, based on stage template. """
internal_followers = self.user_projectuser.partner_id + self.user_projectmanager.partner_id
new_partner_email = '"New Author" <new.author@test.agrolait.com>'
incoming_cc = f'"New Cc" <new.cc@test.agrolait.com>, {self.partner_2.email_formatted}'
incoming_to = f'{self.project_followers_alias.alias_full_name}, {self.partner_1.email_formatted}, "New Customer" <new.customer@test.agrolait.com>'
incoming_to_filtered = f'{self.partner_1.email_formatted}, "New Customer" <new.customer@test.agrolait.com>'
for test_user in (self.user_employee, self.user_portal, False):
with self.subTest(user_name=test_user.name if test_user else new_partner_email):
email_from = test_user.email_formatted if test_user else new_partner_email
with self.mock_mail_gateway():
task = self.format_and_process(
MAIL_TEMPLATE, email_from,
incoming_to,
cc=incoming_cc,
subject=f'Test from {email_from}',
target_model='project.task',
)
self.flush_tracking()
if test_user:
author = test_user.partner_id
else:
author = self.env['res.partner'].search([('email_normalized', '=', 'new.author@test.agrolait.com')])
self.assertTrue(author, 'Project automatically creates a partner for incoming email')
self.assertEqual(author.email, 'new.author@test.agrolait.com', 'Should parse name/email correctly')
self.assertEqual(author.name, 'New Author', 'Should parse name/email correctly')
# do not converts Cc into partners, used only to populate email_cc field
new_partner_cc = self.env['res.partner'].search([('email_normalized', '=', 'new.cc@test.agrolait.com')])
self.assertFalse(new_partner_cc)
# do not convert other people in To, simply recognized if they exist
new_partner_customer = self.env['res.partner'].search([('email_normalized', '=', 'new.customer@test.agrolait.com')])
self.assertFalse(new_partner_customer)
self.assertIn('Please call me as soon as possible', task.description)
self.assertEqual(task.email_cc, f'"New Cc" <new.cc@test.agrolait.com>, {self.partner_2.email_formatted}, {self.partner_1.email_formatted}, "New Customer" <new.customer@test.agrolait.com>')
self.assertEqual(task.name, f'Test from {author.email_formatted}')
self.assertEqual(task.partner_id, author)
self.assertEqual(task.project_id, self.project_followers)
self.assertEqual(task.stage_id, self.project_followers.type_ids[0])
# followers: email cc is added in followers at creation time, aka only recognized partners
self.assertEqual(task.message_partner_ids, internal_followers + author + self.partner_1 + self.partner_2)
# messages
self.assertEqual(len(task.message_ids), 2)
# first message: incoming email: sent to email followers
incoming_email = task.message_ids[1]
self.assertMailNotifications(
incoming_email,
[
{
'content': 'Please call me as soon as possible',
'message_type': 'email',
'message_values': {
'author_id': author,
'email_from': formataddr((author.name, author.email_normalized)),
# coming from incoming email
'incoming_email_cc': incoming_cc,
'incoming_email_to': incoming_to_filtered,
'mail_server_id': self.env['ir.mail_server'],
# followers of 'new task' subtype (but not original To as they
# already received the email)
'notified_partner_ids': internal_followers,
# deduced from 'To' and 'Cc' (recognized partners)
'partner_ids': self.partner_1 + self.partner_2,
'parent_id': self.env['mail.message'],
'reply_to': formataddr((author.name, self.project_followers_alias.alias_full_name)),
'subject': f'Test from {author.email_formatted}',
'subtype_id': self.env.ref('project.mt_task_new'),
},
'notif': [
{'partner': self.user_projectmanager.partner_id, 'type': 'inbox',},
{'partner': self.user_projectuser.partner_id, 'type': 'email',},
],
},
],
)
# second message: acknowledgment: sent to email author
acknowledgement = task.message_ids[0]
# task created by odoobot if not incoming user -> odoobot author of ack email
acknowledgement_author = test_user.partner_id if test_user else self.partner_root
self.assertMailNotifications(
acknowledgement,
[
{
'content': f'Hello {author.name}',
'message_type': 'auto_comment',
'message_values': {
'author_id': acknowledgement_author,
'email_from': acknowledgement_author.email_formatted,
'incoming_email_cc': False,
'incoming_email_to': False,
'mail_server_id': self.env['ir.mail_server'],
# default recipients: partner_id, no note followers
'notified_partner_ids': author,
# default recipients: partner_id
'partner_ids': author,
'parent_id': incoming_email,
'reply_to': formataddr((acknowledgement_author.name, self.project_followers_alias.alias_full_name)),
'subject': f'Test Acknowledge {task.name}',
# defined by _track_template
'subtype_id': self.env.ref('mail.mt_note'),
},
'notif': [
# specific email for portal customer, due to portal mixin
{'partner': author, 'type': 'email', 'group': 'portal_customer',},
],
},
],
)
# uses Chatter: fetches suggested recipients, post a message
# - checks all suggested: email_cc field, primary email
# ------------------------------------------------------------
suggested_all = task.with_user(self.user_projectuser)._message_get_suggested_recipients(
reply_discussion=True, no_create=False,
)
new_partner_cc = self.env['res.partner'].search(
[('email_normalized', '=', 'new.cc@test.agrolait.com')]
)
self.assertEqual(new_partner_cc.email, 'new.cc@test.agrolait.com')
self.assertEqual(new_partner_cc.name, 'New Cc')
new_partner_customer = self.env['res.partner'].search(
[('email_normalized', '=', 'new.customer@test.agrolait.com')]
)
self.assertEqual(new_partner_customer.email, 'new.customer@test.agrolait.com')
self.assertEqual(new_partner_customer.name, 'New Customer')
expected_all = []
if not test_user:
expected_all = [
{ # last message recipient is proposed
'create_values': {},
'email': 'new.author@test.agrolait.com',
'name': 'New Author',
'partner_id': author.id, # already created by project upon initial email reception
}
]
elif test_user == self.user_portal:
expected_all = [
{ # customer is proposed, even if follower, because shared
'create_values': {},
'email': self.user_portal.email_normalized,
'name': self.user_portal.name,
'partner_id': self.user_portal.partner_id.id,
}
]
expected_all += [
{ # mail.thread.cc: email_cc field
'create_values': {},
'email': 'new.cc@test.agrolait.com',
'name': 'New Cc',
'partner_id': new_partner_cc.id,
},
{ # incoming email other recipients (new.customer)
'create_values': {},
'email': 'new.customer@test.agrolait.com',
'name': 'New Customer',
'partner_id': new_partner_customer.id,
},
# other CC (partner_2) and customer (partner_id) already follower
]
for suggested, expected in zip(suggested_all, expected_all, strict=True):
self.assertDictEqual(suggested, expected)
# finally post the message with recipients
with self.mock_mail_gateway():
recipients = new_partner_cc + new_partner_customer
if not test_user:
recipients += author
responsible_answer = task.with_user(self.user_projectuser).message_post(
body='<p>Well received !',
partner_ids=recipients.ids,
message_type='comment',
subject=f'Re: {task.name}',
subtype_id=self.env.ref('mail.mt_comment').id,
)
self.assertEqual(task.message_partner_ids, internal_followers + author + self.partner_1 + self.partner_2)
external_partners = self.partner_1 + self.partner_2 + new_partner_cc + new_partner_customer
self.assertMailNotifications(
responsible_answer,
[
{
'content': 'Well received !',
'mail_mail_values': {
'mail_server_id': self.env['ir.mail_server'], # no specified server
},
'message_type': 'comment',
'message_values': {
'author_id': self.user_projectuser.partner_id,
'email_from': self.user_projectuser.partner_id.email_formatted,
'incoming_email_cc': False,
'incoming_email_to': False,
'mail_server_id': self.env['ir.mail_server'],
# projectuser not notified of its own message, even if follower
'notified_partner_ids': self.user_projectmanager.partner_id + author + external_partners,
'parent_id': incoming_email,
# coming from post
'partner_ids': recipients,
'reply_to': formataddr((self.user_projectuser.name, self.project_followers_alias.alias_full_name)),
'subject': f'Re: {task.name}',
'subtype_id': self.env.ref('mail.mt_comment'),
},
'notif': [
# original author has a specific email with links and tokens
{'partner': author, 'type': 'email', 'group': 'portal_customer'},
{'partner': self.partner_1, 'type': 'email'},
{'partner': self.partner_2, 'type': 'email'},
{'partner': new_partner_cc, 'type': 'email'},
{'partner': new_partner_customer, 'type': 'email'},
{'partner': self.user_projectmanager.partner_id, 'type': 'inbox'},
],
},
],
)
# SMTP emails really sent (not Inbox guy then)
# expected Msg['To'] : Reply-All behavior: actual recipient, then
# all "not internal partners" and catchall (to receive answers)
for partner in (responsible_answer.notified_partner_ids - self.user_projectmanager.partner_id):
exp_msg_to_partners = partner | external_partners
if author != self.user_employee.partner_id: # external only !
exp_msg_to_partners |= author
exp_msg_to = exp_msg_to_partners.mapped('email_formatted')
with self.subTest(name=partner.name):
self.assertSMTPEmailsSent(
mail_server=self.mail_server_notification,
msg_from=formataddr((self.user_projectuser.name, f'{self.default_from}@{self.alias_domain}')),
smtp_from=self.mail_server_notification.from_filter,
smtp_to_list=[partner.email_normalized],
msg_to_lst=exp_msg_to,
)
# customer replies using "Reply All" + adds new people
# ------------------------------------------------------------
self.gateway_mail_reply_from_smtp_email(
MAIL_TEMPLATE, [author.email_normalized], reply_all=True,
cc=f'"Another Cc" <another.cc@test.agrolait.com>, {self.partner_3.email}',
target_model='project.task',
)
self.assertEqual(
task.email_cc,
'"Another Cc" <another.cc@test.agrolait.com>, valid.poilboeuf@gmail.com, "New Cc" <new.cc@test.agrolait.com>, '
'"Valid Poilvache" <valid.other@gmail.com>, "Valid Lelitre" <valid.lelitre@agrolait.com>, "New Customer" <new.customer@test.agrolait.com>',
'Updated with new Cc')
self.assertEqual(len(task.message_ids), 4, 'Incoming email + acknowledgement + chatter reply + customer reply')
self.assertEqual(
task.message_partner_ids,
internal_followers + author + self.partner_1 + self.partner_2 + self.partner_3 + new_partner_cc + new_partner_customer,
'Project adds recognized recipients as followers')
self.assertMailNotifications(
task.message_ids[0],
[
{
'content': 'Please call me as soon as possible',
'message_type': 'email',
'message_values': {
'author_id': author,
'email_from': author.email_formatted,
# coming from incoming email
'incoming_email_cc': f'"Another Cc" <another.cc@test.agrolait.com>, {self.partner_3.email}',
# To: received email Msg-To - customer who replies, without email Reply-To
'incoming_email_to': ', '.join(external_partners.mapped('email_formatted')),
'mail_server_id': self.env['ir.mail_server'],
# notified: followers - already emailed, aka internal only
'notified_partner_ids': internal_followers,
'parent_id': responsible_answer,
# same reasoning as email_to/cc
'partner_ids': external_partners + self.partner_3,
'reply_to': formataddr((author.name, self.project_followers_alias.alias_full_name)),
'subject': f'Re: Re: {task.name}',
'subtype_id': self.env.ref('mail.mt_comment'),
},
'notif': [
{'partner': self.user_projectuser.partner_id, 'type': 'email',},
{'partner': self.user_projectmanager.partner_id, 'type': 'inbox',},
],
},
],
)
# clear for other loops
(new_partner_cc + new_partner_customer).unlink()
@users('bastien')
def test_task_notification_on_project_update(self):
""" Test changing task's project notifies people following 'New Task' """
test_task = self.test_task.with_user(self.env.user)
with self.mock_mail_gateway():
test_task.project_id = False
self.flush_tracking()
# voiding project should not do anything
self.assertNotSentEmail()
with self.mock_mail_gateway():
test_task.project_id = self.project_goats.id
self.flush_tracking()
self.assertNotSentEmail()
with self.mock_mail_gateway():
test_task.project_id = self.project_followers.id
self.flush_tracking()
# find notification, not in message_ids as it is a personal message
notification_msg = self.env['mail.message'].search([
('model', '=', 'project.task'), ('res_id', '=', test_task.id),
('body', 'ilike', 'Transferred from Project')
])
self.assertTrue(notification_msg)
# should trigger a notification
self.assertSentEmail(self.env.user.email_formatted, [self.user_projectuser.email_formatted])
self.assertMailNotifications(
notification_msg,
[
{
'content': 'Transferred from Project',
'message_type': 'user_notification',
'message_values': {
'author_id': self.user_projectmanager.partner_id,
'email_from': self.user_projectmanager.partner_id.email_formatted,
'mail_server_id': self.env['ir.mail_server'],
# followers of 'new task' type but not author itself
'notified_partner_ids': self.user_projectuser.partner_id,
# followers of 'new task' type
'partner_ids': (self.user_projectuser + self.user_projectmanager).partner_id,
'parent_id': self.env['mail.message'],
'reply_to': formataddr((self.user_projectmanager.name, self.project_followers_alias.alias_full_name
)),
'subject': test_task.name,
'subtype_id': self.env.ref('mail.mt_note'),
},
'notif': [
{'partner': self.user_projectuser.partner_id, 'type': 'email',},
],
},
],
)
def test_task_notification_on_user_ids_update(self):
""" This test will check that an assignment mail is sent when adding an assignee to a task """
# avoid messing with followers to ease notif check
self.project_followers.message_unsubscribe(partner_ids=self.project_followers.message_partner_ids.ids)
with self.mock_mail_gateway():
test_task = self.env['project.task'].create({
'name': 'Mail Task',
'user_ids': self.user_projectuser,
'project_id': self.project_followers.id
})
self.flush_tracking()
self.assertSentEmail(self.env.user.email_formatted, [self.user_projectuser.email_formatted])
with self.mock_mail_gateway():
test_task.copy()
self.flush_tracking()
# check that no mail was received for the assignee of the task
self.assertNotSentEmail(self.user_projectuser.email_formatted)
def test_copy_task_logs_chatter(self):
"""Test that copying a task logs a message in the chatter."""
copied_task = self.task_1.copy()
# Ensure only one message is logged in chatter
self.assertEqual(
'Task Created', copied_task.message_ids[0].preview,
"Expected 'Task Created' message not found in copied task's chatter."
)
def test_task_portal_share_adds_followers(self):
""" Test that sharing a task through the portal share wizard adds recipients as followers.
Test Cases:
===========
1) Verify that the portal user is not a follower of the task.
2) Create and execute a portal share wizard to share the task with the portal user.
3) Verify that the portal user has been added as a follower after sharing.
"""
self.assertNotIn(self.user_portal.partner_id, self.task_1.message_partner_ids,
"Portal user's partner should not be a follower initially")
share_wizard = self.env['portal.share'].create({
'res_model': 'project.task',
'res_id': self.task_1.id,
'partner_ids': [Command.set(self.user_portal.partner_id.ids)]
})
with self.mock_mail_gateway():
share_wizard.action_send_mail()
self.assertIn(self.user_portal.partner_id, self.task_1.message_partner_ids,
"Portal user's partner should be added as a follower after sharing")
def test_mail_alais_assignees_from_recipient_list(self):
# including all types of users in recipient list
new_user = new_test_user(self.env, 'int_user')
# format: Name <some@email.com>
incoming_to_emails_with_name = (
f"\"{self.project_goats.name}\" <{self.project_goats.alias_name}@{self.project_goats.alias_domain_id.name}>"
f"\"{self.user_public.name}\" <{self.user_public.email}>,"
f"\"{self.user_projectmanager.name}\" <{self.user_projectmanager.email}>,"
f"\"{self.user_portal.name}\" <{self.user_portal.email}>,"
f"\"{self.user_projectuser.name}\" <{self.user_projectuser.email}>,"
)
# format: some@email.com
incoming_to_emails = (
f"{self.project_goats.alias_name}@{self.project_goats.alias_domain_id.name},"
f"{self.user_public.email},"
f"{self.user_projectmanager.email},"
f"{self.user_portal.email},"
f"{self.user_projectuser.email},"
)
for incoming_to in [incoming_to_emails_with_name, incoming_to_emails]:
with self.mock_mail_gateway():
task = self.format_and_process(
MAIL_TEMPLATE,
self.user_employee.email,
incoming_to,
cc=f"{new_user.email}",
subject=f'Test task assignees from email to address with {incoming_to}',
target_model='project.task',
)
self.flush_tracking()
self.assertTrue(task, "Task has not been created from a incoming email")
# only internal users are set as asssignees
self.assertEqual(task.user_ids, self.user_projectmanager + self.user_projectuser, "Assignees have not been set from the to address of the mail")
# public and portal users are ignored
self.assertNotIn(task.user_ids, self.user_public + self.user_portal, "Assignees should not be set for user other than internal users")
# sender should not be added as user in the task
self.assertNotIn(task.user_ids, self.user_employee, "Sender can never be in assignees")
# internal users in cc of mail shoudl be added in email_cc field
self.assertEqual(task.email_cc, new_user.email, "The internal user in CC is not added into email_cc field")
def test_task_creation_removes_email_signatures(self):
"""
Tests that email signature is correctly removed from a task
description when a task is created from an email alias.
"""
gmail_email_source = f"""From: "{self.user_portal.name}" <{self.user_portal.email_formatted}>
To: {self.project_followers_alias.alias_full_name}
Subject: Test Gmail Signature Removal
Content-Type: text/html;
<p>This is the main email content that should be kept.</p>
<p>Some more important content here.</p>
<span>--</span>
<div data-smartmail="gmail_signature">
<p>John Doe</p>
<p>Software Engineer</p>
</div>
"""
outlook_email_source = f"""From: "{self.user_portal.name}" <{self.user_portal.email_formatted}>
To: {self.project_followers_alias.alias_full_name}
Subject: Test Outlook Signature Removal
Content-Type: text/html;
<p>This is the main email content that should be kept.</p>
<p>Some more important content here.</p>
<div id="Signature">
<p>John Smith</p>
<p>Software Developer</p>
</div>
"""
with self.mock_mail_gateway():
gmail_task_id = self.env['mail.thread'].message_process(
model='project.task',
message=gmail_email_source,
custom_values={'project_id': self.project_followers.id}
)
outlook_task_id = self.env['mail.thread'].message_process(
model='project.task',
message=outlook_email_source,
custom_values={'project_id': self.project_followers.id}
)
# Verify Gmail signature removal
self.assertTrue(gmail_task_id, "Gmail task creation should return a valid ID.")
gmail_task = self.env['project.task'].browse(gmail_task_id)
self.assertIn("This is the main email content that should be kept", gmail_task.description)
self.assertNotIn("--", gmail_task.description, "The Gmail signature separator should have been removed.")
self.assertNotIn("John Doe", gmail_task.description, "The Gmail signature should have been removed.")
self.assertNotIn("Software Engineer", gmail_task.description, "The Gmail signature should have been removed.")
# Verify Outlook signature removal
self.assertTrue(outlook_task_id, "Outlook task creation should return a valid ID.")
outlook_task = self.env['project.task'].browse(outlook_task_id)
self.assertIn("This is the main email content that should be kept", outlook_task.description)
self.assertNotIn("John Smith", outlook_task.description, "The Outlook signature should have been removed.")
self.assertNotIn("Software Developer", outlook_task.description, "The Outlook signature should have been removed.")
@mute_logger('odoo.addons.mail.models.mail_thread')
def test_task_creation_from_mail(self):
""" This test checks a `default_` key passed in the context with an invalid field doesn't prevent the task
creation.
This is related to the `_ensure_fields_write` method checking field write access rights
for collaborator portals
"""
server = self.env['fetchmail.server'].create({
'name': 'Test server',
'user': 'test@example.com',
'password': '',
})
task_id = self.env["mail.thread"].with_context(
default_fetchmail_server_id=server.id
).message_process(
server.object_id.model,
self.format(
MAIL_TEMPLATE,
email_from="chell@gladys.portal",
to=f"project+pigs@{self.alias_domain}",
subject="In a cage",
msg_id="<on.antibiotics@example.com>",
),
save_original=server.original,
strip_attachments=not server.attach,
)
task = self.env['project.task'].browse(task_id)
self.assertEqual(task.name, "In a cage")
self.assertEqual(task.project_id, self.project_pigs)

View file

@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields
from odoo.tests import Form, tagged
from dateutil.relativedelta import relativedelta
from .test_project_base import TestProjectCommon
@ -11,45 +13,89 @@ class TestProjectMilestone(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.milestone = cls.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Milestone',
cls.env.user.group_ids |= cls.env.ref('project.group_project_milestone')
cls.milestone_pigs, cls.milestone_goats = cls.env['project.milestone'].with_context({'mail_create_nolog': True}).create([{
'name': 'Milestone Pigs',
'project_id': cls.project_pigs.id,
})
}, {
'name': 'Milestone Goats',
'project_id': cls.project_goats.id,
}])
def test_milestones_settings_change(self):
# To be sure the feature is disabled globally to begin the test.
self.env['res.config.settings'] \
.create({'group_project_milestone': False}) \
.execute()
self.assertFalse(self.env.user.has_group('project.group_project_milestone'), 'The "Milestones" feature should not be globally enabled by default.')
self.assertFalse(self.project_pigs.allow_milestones, 'The "Milestones" feature should not be enabled by default.')
self.env['res.config.settings'] \
.create({'group_project_milestone': True}) \
.execute()
self.assertTrue(self.env.user.has_group('project.group_project_milestone'), 'The "Milestones" feature should globally be enabled.')
self.assertTrue(self.project_pigs.allow_milestones, 'The "Milestones" feature should be enabled by default on the project when the feature is enabled.')
project = self.env['project.project'].create({'name': 'Test allow_milestones on New Project'})
self.assertTrue(project.allow_milestones, 'The "Milestones" feature should be enabled by default when the feature is enabled globally.')
self.env.user.group_ids -= self.env.ref('project.group_project_milestone')
project1 = self.env['project.project'].create({'name': 'Test allow_milestones on New Project'})
self.assertFalse(project1.allow_milestones, 'The "Milestones" feature should be disabled by default when the feature is disabled globally.')
with Form(self.env['project.project']) as project_form:
project_form.name = 'My Mouses Project'
self.assertTrue(project_form.allow_milestones, 'New projects allow_milestones should be True by default.')
self.assertFalse(project_form.allow_milestones, 'New projects allow_milestones should be False by default.')
# Now, enable the feature
self.env.user.group_ids |= self.env.ref('project.group_project_milestone')
project2 = self.env['project.project'].create({'name': 'Test allow_milestones on New Project'})
self.assertFalse(project2.allow_milestones, 'The "Milestones" feature should still be disabled by default when the feature is enabled globally.')
with Form(self.env['project.project']) as project_form:
project_form.name = 'My Mouses Project'
self.assertFalse(project_form.allow_milestones, 'New projects allow_milestones should be False by default.')
def test_change_project_in_task(self):
""" Test when a task is linked to a milestone and when we change its project the milestone is removed
""" Test when a task is linked to a milestone and when we change its project the milestone is removed (and
we fallback on the parent milestone if it belongs to the same project)
Test Case:
=========
1) Set a milestone on the task
2) Change the project of that task
3) Check no milestone is linked to the task
3) Check no milestone is linked to the task (or the one of its parent is used if relevant)
"""
self.task_1.milestone_id = self.milestone
self.assertEqual(self.task_1.milestone_id, self.milestone)
# A. No parent task
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs)
self.task_1.project_id = self.project_goats
self.assertFalse(self.task_1.milestone_id, 'No milestone should be linked to the task since its project has changed')
# B. Parent task with no milestone set
task_2 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Child MilestoneTask',
'user_ids': self.user_projectmanager,
'project_id': self.project_pigs.id,
'parent_id': self.task_1.id,
'milestone_id': self.milestone_pigs.id,
})
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
task_2.project_id = self.project_goats
self.assertFalse(task_2.milestone_id, 'No milestone should be linked to the task since its project has changed and its parent task has no milestone')
# C. Parent task with a milestone set but on a different project
self.task_1.project_id = self.project_pigs
self.task_1.milestone_id = self.milestone_pigs
task_2.project_id = self.project_pigs
task_2.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
task_2.project_id = self.project_goats
self.assertFalse(task_2.milestone_id, 'No milestone should be linked to the task since its project has changed and its parent task belongs to another project')
# D. Parent task with a milestone set on the same project
self.task_1.project_id = self.project_goats
self.task_1.milestone_id = self.milestone_goats
task_2.project_id = self.project_pigs
task_2.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
task_2.project_id = self.project_goats
self.assertEqual(task_2.milestone_id, self.milestone_goats,
'The milestone of the task should be replaced by the one of its parent task as they now belong to the same project')
# E. No milestone for private task
task_2.parent_id = False
self.assertEqual(task_2.milestone_id, self.milestone_goats)
task_2.project_id = False
self.assertFalse(task_2.milestone_id, 'No milestone should be linked to a private task')
def test_duplicate_project_duplicates_milestones_on_tasks(self):
"""
Test when we duplicate the project with tasks linked to its' milestones,
@ -96,4 +142,228 @@ class TestProjectMilestone(TestProjectCommon):
self.assertNotEqual(copied_task.milestone_id, False,
"We should copy the milestone and it shouldn't be reset to false from _compute_milestone_id")
self.assertEqual(orig_task.milestone_id.name, copied_task.milestone_id.name,
"the copied milestone should be a copy if the original ")
"the copied milestone should be a copy of the original ")
def test_duplicate_project_with_milestones_disabled(self):
""" This test ensures that when a project that has some milestones linked to it but with the milesones feature disabled is copied,
the copy does not have the feature enabled, and none of the milestones have been copied."""
extra_milestone_pigs = self.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Extra Milestone',
'project_id': self.project_pigs.id,
})
self.task_1.milestone_id = self.milestone_pigs
self.task_2.milestone_id = extra_milestone_pigs
self.project_pigs.allow_milestones = False
project_copied = self.project_pigs.copy()
project_copied.task_ids._compute_milestone_id()
self.assertFalse(project_copied.allow_milestones, "The project copied should have the milestone feature disabled")
self.assertFalse(project_copied.milestone_ids, "The project copied should not have any milestone")
self.assertFalse(project_copied.task_ids.milestone_id, "None of the project's task should have a milestone")
def test_basic_milestone_write(self):
""" Testing basic milestone/project write operation on task, i.e:
1. Set/change the milestone of a task
2. Change the milestone/project of a task simultaneously
3. Set/change to an invalid milestone
"""
extra_milestone_pigs = self.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Extra Milestone',
'project_id': self.project_pigs.id,
})
# 1. Set/change the milestone of a task
self.task_1.project_id = self.project_pigs
self.assertEqual(self.task_1.project_id, self.project_pigs)
self.assertFalse(self.task_1.milestone_id)
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs,
"Assignation of a valid milestone to a task with no milestone is not working properly.")
self.task_1.milestone_id = extra_milestone_pigs
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs,
"Change of the milestone of a task to a milestone from the same project is not working properly.")
# 2. Change the milestone/project of a task simultaneously
self.assertEqual(self.task_1.project_id, self.project_pigs)
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.task_1.write({
'milestone_id': self.milestone_goats.id,
'project_id': self.project_goats.id,
})
self.assertEqual(self.task_1.milestone_id, self.milestone_goats,
"Changing the project of a task and its milestone simultaneously is not working properly.")
self.assertEqual(self.task_1.project_id, self.project_goats,
"Changing the project of a task and its milestone simultaneously is not working properly.")
# 3. Set/change to an invalid milestone
self.assertEqual(self.task_1.project_id, self.project_goats)
self.assertEqual(self.task_1.milestone_id, self.milestone_goats)
self.task_1.milestone_id = self.milestone_pigs
self.assertFalse(self.task_1.milestone_id,
"Setting the milestone of a task to an invalid value should reset the value of milestone_id.")
def test_set_milestone_parent_task(self):
""" When a milestone is set on a parent task, it is set as well on its child tasks if they have no milestone set yet and
if they belong to the same project (or they have no project set).
Test Case:
=========
1) Set a milestone on the task (or not)
2) Change the milestone of its parent
3) Check the result
"""
# A. Child task with no milestone set and belonging to the same project
task_2, task_3 = self.env['project.task'].with_context({'mail_create_nolog': True}).create([{
'name': 'Child MilestoneTask',
'user_ids': self.user_projectmanager,
'project_id': self.project_pigs.id,
'parent_id': self.task_1.id,
}, {
'name': 'Grand-child MilestoneTask',
'user_ids': self.user_projectmanager,
'project_id': self.project_pigs.id,
}])
self.assertFalse(self.task_1.milestone_id)
self.assertFalse(task_2.milestone_id)
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs,
"The milestone of the parent task should be set to its subtasks if they belong to the same project (or the subtask has not project set) and the subtask has no milestone already set.")
# B. Child task with a milestone already set
extra_milestone_pigs = self.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
'name': 'Extra Milestone Pigs',
'project_id': self.project_pigs.id,
})
self.task_1.milestone_id = False
task_2.milestone_id = extra_milestone_pigs
self.assertFalse(self.task_1.milestone_id)
self.assertEqual(task_2.milestone_id, extra_milestone_pigs)
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, extra_milestone_pigs, "The milestone of the child task should not be modified has it has already one set.")
# C. Child task with no milestone set but belonging to another project
task_2.project_id = self.project_goats
self.assertFalse(task_2.milestone_id)
self.task_1.milestone_id = extra_milestone_pigs
self.assertFalse(task_2.milestone_id, "The milestone of the parent task should not be set to its child task as they belong to different projects.")
# D. Recursion test (grand-parent task's milestone set to grand-child task)
task_3.parent_id = task_2
self.task_1.project_id = task_2.project_id = task_3.project_id = self.project_pigs
self.task_1.milestone_id = task_2.milestone_id = task_3.milestone_id = False
self.assertFalse(task_2.milestone_id)
self.assertFalse(task_3.milestone_id)
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(task_3.milestone_id, self.milestone_pigs, "The milestone of the parent task should be set to its (grand)child tasks recursively.")
# E. Recursion test 2 (grand-child task has no milestone but first level child does)
self.task_1.milestone_id = False
task_2.milestone_id = self.milestone_pigs
task_3.milestone_id = False
self.assertFalse(self.task_1.milestone_id)
self.assertFalse(task_3.milestone_id)
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
self.task_1.milestone_id = extra_milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
self.assertFalse(task_3.milestone_id,
"The milestone of the parent task should be set to its (grand)child tasks recursively. If a child task milestone should not be updated, it stops the recursion.")
# F. Update of the parent's milestone, trigger the update of the subtask's milestone if they were the same before change
self.task_1.milestone_id = self.milestone_pigs
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs)
self.task_1.milestone_id = extra_milestone_pigs
self.assertEqual(task_2.milestone_id, extra_milestone_pigs,
"If parent and child tasks share the same milestone, the update of the parent's milestone should trigger the update of its child's milestone.")
# G. Same as F but project and milestone of the parent task are changed at the same time -> The milestone of the child change as it will follow its parent in the other project
self.assertEqual(task_2.milestone_id, extra_milestone_pigs)
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.task_1.write({
'project_id': self.project_goats.id,
'milestone_id': self.milestone_goats.id,
})
self.assertTrue(
task_2.milestone_id == self.task_1.milestone_id == self.milestone_goats,
"The child milestone should be updated if the parent task's project is changed.",
)
# H. Same as G but project the project writen value is the same as the previous one -> No actual change of project_id so update the subtask milestone
self.task_1.project_id = self.project_pigs
task_2._compute_milestone_id() # For some reason, though it should be auto triggered, because task_2.project_id changes with previous line, it's not
self.task_1.milestone_id = extra_milestone_pigs
self.assertEqual(task_2.milestone_id, extra_milestone_pigs)
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.task_1.write({
'project_id': self.project_pigs.id,
'milestone_id': self.milestone_pigs.id,
})
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs)
self.assertEqual(task_2.milestone_id, self.milestone_pigs,
"The child milestone should be updated as the project of the parent task does not actually change.")
# I. Same case as G but the display_on_project is set to False on the child task -> Both project and milestone of the subtask should be updated
self.task_1.write({
'project_id': self.project_pigs.id,
'milestone_id': extra_milestone_pigs.id,
})
self.assertEqual(task_2.milestone_id, extra_milestone_pigs)
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.task_1.write({
'project_id': self.project_goats.id,
'milestone_id': self.milestone_goats.id,
})
self.assertEqual(self.task_1.milestone_id, self.milestone_goats)
self.assertEqual(task_2.project_id, self.project_goats)
self.assertEqual(task_2.milestone_id, self.milestone_goats,
"The child milestone should be updated if the parent task's project is changed only if dislay_on_project is set to False for the subtask.")
# J. Same case as F but subtask is closed -> no update of its milestone
self.task_1.project_id = task_2.project_id = self.project_pigs
self.task_1.milestone_id = task_2.milestone_id = self.milestone_pigs
task_2.state = '1_done'
self.assertEqual(task_2.milestone_id, self.milestone_pigs)
self.assertEqual(self.task_1.milestone_id, self.milestone_pigs)
self.task_1.write({
'milestone_id': extra_milestone_pigs.id,
})
self.assertEqual(self.task_1.milestone_id, extra_milestone_pigs)
self.assertEqual(task_2.milestone_id, self.milestone_pigs,
"The child milestone should not be updated if it is closed.")
def test_project_milestone_color(self):
"""
Test Steps:
1. Assign `milestone_pigs` to `task_1` and mark it as 'done'.
2. Set `milestone_goats` deadline to yesterday.
3. Compute the next milestone for `project_pigs` and `project_goats`.
4. Validate that:
- `project_goats` has exceeded the milestone deadline.
- `project_pigs` has not exceeded the milestone deadline.
- `project_pigs` can mark the milestone as done.
- `project_goats` cannot mark the milestone as done.
"""
self.task_1.write({
'milestone_id': self.milestone_pigs.id,
'state': '1_done',
})
self.milestone_goats.write({'deadline': fields.Date.today() + relativedelta(days=-1)})
(self.project_pigs | self.project_goats)._compute_next_milestone_id()
self.assertTrue(self.project_goats.is_milestone_deadline_exceeded, "Expected project_goats to have exceeded the milestone deadline.")
self.assertFalse(self.project_pigs.is_milestone_deadline_exceeded, "Expected project_pigs to not have exceeded the milestone deadline.")
self.assertTrue(self.project_pigs.can_mark_milestone_as_done, "Expected project_pigs to be able to mark the milestone as done.")
self.assertFalse(self.project_goats.can_mark_milestone_as_done, "Expected project_goats to not be able to mark the milestone as done.")

View file

@ -10,6 +10,7 @@ class TestProjectProfitabilityCommon(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.user.group_ids += cls.env.ref('project.group_project_manager')
cls.partner = cls.env['res.partner'].create({
'name': 'Georges',
@ -17,7 +18,6 @@ class TestProjectProfitabilityCommon(TransactionCase):
cls.analytic_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan A',
'company_id': False,
})
cls.analytic_account = cls.env['account.analytic.account'].create({
'name': 'Project - AA',
@ -27,7 +27,7 @@ class TestProjectProfitabilityCommon(TransactionCase):
cls.project = cls.env['project.project'].with_context({'mail_create_nolog': True}).create({
'name': 'Project',
'partner_id': cls.partner.id,
'analytic_account_id': cls.analytic_account.id,
'account_id': cls.analytic_account.id,
})
cls.task = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Task',
@ -37,7 +37,19 @@ class TestProjectProfitabilityCommon(TransactionCase):
'revenues': {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}},
'costs': {'data': [], 'total': {'billed': 0.0, 'to_bill': 0.0}},
}
cls.foreign_currency = cls.env['res.currency'].create({
'name': 'Chaos orb',
'symbol': '',
'rounding': 0.001,
'position': 'after',
'currency_unit_label': 'Chaos',
'currency_subunit_label': 'orb',
})
cls.env['res.currency.rate'].create({
'name': '2016-01-01',
'rate': '5.0',
'currency_id': cls.foreign_currency.id,
})
class TestProfitability(TestProjectProfitabilityCommon):
def test_project_profitability(self):
@ -46,9 +58,8 @@ class TestProfitability(TestProjectProfitabilityCommon):
In this module, the project profitability should have no data.
So the no revenue and cost should be found.
"""
profitability_items = self.project._get_profitability_items(False)
self.assertDictEqual(
profitability_items,
self.project._get_profitability_items(False),
self.project_profitability_items_empty,
'The profitability data of the project should be return no data and so 0 for each total amount.'
)

View file

@ -1,12 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.osv import expression
from odoo.exceptions import AccessError
from odoo.fields import Command, Domain
from odoo.tests import Form, tagged
from odoo.tools import mute_logger
from odoo.tests import tagged
from odoo.tests.common import Form
from .test_project_base import TestProjectCommon
@ -74,6 +71,19 @@ class TestProjectSharingCommon(TestProjectCommon):
view=self.project_sharing_form_view_xml_id
)
def get_project_share_link(self):
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_no_collabo.id,
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id, 'access_mode': 'edit'}),
],
}).action_send_mail()
return self.env['mail.message'].search([
('partner_ids', 'in', self.user_portal.partner_id.id),
])
@tagged('project_sharing')
class TestProjectSharing(TestProjectSharingCommon):
@ -86,24 +96,162 @@ class TestProjectSharing(TestProjectSharingCommon):
2) Check if no access rights are given to a portal user
3) Add access rights to a portal user
"""
project_share_wizard = self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_portal.id,
'access_mode': 'edit',
})
self.assertFalse(project_share_wizard.partner_ids, 'No collaborator should be in the wizard.')
self.assertFalse(self.project_portal.with_user(self.user_portal)._check_project_sharing_access(), 'The portal user should not have accessed in project sharing views.')
project_share_wizard.write({'partner_ids': [Command.link(self.user_portal.partner_id.id)]})
self.project_portal.message_unsubscribe(partner_ids=self.user_portal.partner_id.ids)
project_share_form = Form(self.env['project.share.wizard'].with_context(active_model='project.project', active_id=self.project_portal.id))
self.assertFalse(project_share_form.collaborator_ids, 'No collaborator should be in the wizard.')
with self.assertRaises(AccessError, msg='The public user should not have any access to project sharing feature of the portal project.'):
self.project_portal.with_user(self.user_portal)._check_project_sharing_access()
with project_share_form.collaborator_ids.new() as collaborator_form:
collaborator_form.partner_id = self.user_portal.partner_id
collaborator_form.access_mode = 'edit'
project_share_wizard = project_share_form.save()
project_share_wizard.action_send_mail()
self.assertEqual(len(self.project_portal.collaborator_ids), 1, 'The access right added in project share wizard should be added in the project when the user confirm the access in the wizard.')
self.assertDictEqual({
'partner_id': self.project_portal.collaborator_ids.partner_id,
'project_id': self.project_portal.collaborator_ids.project_id,
'limited_access': self.project_portal.collaborator_ids.limited_access,
}, {
'partner_id': self.user_portal.partner_id,
'project_id': self.project_portal,
'limited_access': False,
}, 'The access rights added should be the read access for the portal project for Chell Gladys.')
self.assertTrue(self.project_portal.with_user(self.user_portal)._check_project_sharing_access(), 'The portal user should have read access to the portal project with project sharing feature.')
project_share_wizard = self.env['project.share.wizard'].with_context(active_model="project.project", active_id=self.project_portal.id).new({})
self.assertEqual(len(project_share_wizard.collaborator_ids), 1, 'The access right added in project share wizard should be added in the project when the user confirm the access in the wizard.')
self.assertDictEqual({
'partner_id': project_share_wizard.collaborator_ids.partner_id,
'access_mode': project_share_wizard.collaborator_ids.access_mode,
}, {
'partner_id': self.user_portal.partner_id,
'access_mode': 'edit',
})
def test_project_share_wizard_add_collaborator_with_limited_access(self):
ProjectShare = self.env['project.share.wizard'].with_context(active_model="project.project", active_id=self.project_portal.id)
self.project_portal.write({
'collaborator_ids': [
Command.create({'partner_id': self.partner_1.id}),
],
})
self.project_portal.message_unsubscribe(partner_ids=[self.user_portal.partner_id.id])
project_share_form = Form(ProjectShare)
self.assertEqual(len(project_share_form.collaborator_ids), 1)
with project_share_form.collaborator_ids.new() as collaborator_form:
collaborator_form.partner_id = self.user_portal.partner_id
collaborator_form.access_mode = 'edit_limited'
project_share_wizard = project_share_form.save()
project_share_wizard.action_send_mail()
self.assertEqual(len(self.project_portal.collaborator_ids), 2, 'The access right added in project share wizard should be added in the project when the user confirm the access in the wizard.')
self.assertEqual(self.project_portal.collaborator_ids.partner_id, self.user_portal.partner_id + self.partner_1)
for collaborator in self.project_portal.collaborator_ids:
collaborator_vals = {
'partner_id': collaborator.partner_id,
'project_id': collaborator.project_id,
'limited_access': collaborator.limited_access,
}
if collaborator.partner_id == self.user_portal.partner_id:
self.assertDictEqual(collaborator_vals, {
'partner_id': self.user_portal.partner_id,
'project_id': self.project_portal,
'limited_access': True,
})
else:
self.assertDictEqual(collaborator_vals, {
'partner_id': self.partner_1,
'project_id': self.project_portal,
'limited_access': False,
})
self.assertTrue(self.project_portal.with_user(self.user_portal)._check_project_sharing_access(), 'The portal user should have read access to the portal project with project sharing feature.')
project_share_wizard = ProjectShare.new({})
self.assertEqual(len(project_share_wizard.collaborator_ids), 2, 'The access right added in project share wizard should be added in the project when the user confirm the access in the wizard.')
for collaborator in project_share_wizard.collaborator_ids:
collaborator_vals = {
'partner_id': collaborator.partner_id,
'access_mode': collaborator.access_mode,
}
if collaborator.partner_id == self.user_portal.partner_id:
self.assertDictEqual(collaborator_vals, {
'partner_id': self.user_portal.partner_id,
'access_mode': 'edit_limited',
})
else:
self.assertDictEqual(collaborator_vals, {
'partner_id': self.partner_1,
'access_mode': 'edit',
})
def test_project_share_wizard_remove_collaborators(self):
PortalShare = self.env['project.share.wizard'].with_context(active_model="project.project", active_id=self.project_portal.id)
self.project_portal.write({
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id}),
Command.create({'partner_id': self.partner_1.id, 'limited_access': True}),
],
})
self.project_portal.message_subscribe(partner_ids=[self.partner_2.id])
with Form(PortalShare) as project_share_form:
self.assertEqual(len(project_share_form.collaborator_ids), 3, "2 external collaborators should be found for that project.")
collaborator_vals_per_id = project_share_form.collaborator_ids._field_value._data
collaborator_access_mode_per_partner_id = {
c['partner_id']: c['access_mode']
for c in collaborator_vals_per_id.values()
}
self.assertIn(self.user_portal.partner_id.id, collaborator_access_mode_per_partner_id)
self.assertIn(self.partner_1.id, collaborator_access_mode_per_partner_id)
self.assertIn(self.partner_2.id, collaborator_access_mode_per_partner_id)
access_mode_expected_per_partner_id = {
self.user_portal.partner_id.id: 'edit',
self.partner_1.id: 'edit_limited',
self.partner_2.id: 'read',
}
self.assertDictEqual(collaborator_access_mode_per_partner_id, access_mode_expected_per_partner_id)
collaborator_ids_to_remove = {c_id for c_id, vals in collaborator_vals_per_id.items() if vals['access_mode'] != 'read'}
index = 0
for collaborator_id in project_share_form.collaborator_ids.ids:
if collaborator_id in collaborator_ids_to_remove:
project_share_form.collaborator_ids.remove(index)
else:
index += 1
self.assertFalse(self.project_portal.collaborator_ids)
self.assertIn(self.partner_2, self.project_portal.message_partner_ids, "The readonly partner should still be a follower.")
self.assertNotIn(self.user_portal.partner_id, self.project_portal.message_partner_ids, "The readonly partner should still be a follower.")
self.assertNotIn(self.partner_1, self.project_portal.message_partner_ids, "The readonly partner should still be a follower.")
def test_project_share_wizard_alter_access_mode_collaborators(self):
ProjectShare = self.env['project.share.wizard'].with_context(active_model="project.project", active_id=self.project_portal.id)
self.project_portal.write({
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id}),
Command.create({'partner_id': self.partner_1.id, 'limited_access': True}),
],
'message_partner_ids': [ # readonly access
Command.link(self.partner_2.id),
],
})
with Form(ProjectShare) as project_share_form:
access_updated_per_partner_id = {
self.user_portal.partner_id.id: 'edit_limited',
self.partner_2.id: 'edit',
}
for index in range(len(project_share_form.collaborator_ids.ids)):
with project_share_form.collaborator_ids.edit(index) as collaborator_form:
if collaborator_form.partner_id.id in access_updated_per_partner_id:
collaborator_form.access_mode = access_updated_per_partner_id[collaborator_form.partner_id.id]
self.assertEqual(len(self.project_portal.collaborator_ids), 3, "3 collaborators should be found for that project.")
self.assertEqual(
self.project_portal.collaborator_ids.partner_id,
self.user_portal.partner_id + self.partner_1 + self.partner_2,
"The collaborators should be the portal user, Valid Lelitre and Valid Poilvache.",
)
self.assertEqual(
self.project_portal.collaborator_ids.filtered(lambda c: c.limited_access).partner_id,
self.user_portal.partner_id + self.partner_1,
"The portal user and Valid Lelitre should have limited access.",
)
def test_project_sharing_access(self):
""" Check if the different user types can access to project sharing feature as expected. """
@ -125,7 +273,6 @@ class TestProjectSharing(TestProjectSharingCommon):
3) Give the 'edit' access mode to a portal user in a project and try to create task with this user.
3.1) Try to change the project of the new task with this user.
"""
self.project_portal.allow_subtasks = True
Task = self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_portal.id, 'default_user_ids': [(4, self.user_portal.id)]})
# 1) Give the 'read' access mode to a portal user in a project and try to create task with this user.
with self.assertRaises(AccessError, msg="Should not accept the portal user create a task in the project when he has not the edit access right."):
@ -146,6 +293,7 @@ class TestProjectSharing(TestProjectSharingCommon):
self.assertEqual(task.name, 'Test')
self.assertEqual(task.project_id, self.project_portal)
self.assertFalse(task.portal_user_names)
self.assertTrue(task.stage_id)
# Check creating a sub-task while creating the parent task works as expected.
self.assertEqual(task.child_ids.name, 'Test Subtask')
@ -170,32 +318,40 @@ class TestProjectSharing(TestProjectSharingCommon):
task = Task.with_context(default_parent_id=self.task_no_collabo.id).create({'name': 'foo'})
# Create/Update a forbidden task through child_ids
with self.assertRaisesRegex(AccessError, "You cannot write on description"):
Task.create({'name': 'foo', 'child_ids': [Command.create({'name': 'Foo', 'description': 'Foo'})]})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
Task.create({'name': 'foo', 'child_ids': [Command.update(self.task_no_collabo.id, {'name': 'Foo'})]})
with self.assertRaisesRegex(AccessError, "not allowed to delete 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
Task.create({'name': 'foo', 'child_ids': [Command.delete(self.task_no_collabo.id)]})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
Task.create({'name': 'foo', 'child_ids': [Command.unlink(self.task_no_collabo.id)]})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
Task.create({'name': 'foo', 'child_ids': [Command.link(self.task_no_collabo.id)]})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
Task.create({'name': 'foo', 'child_ids': [Command.set([self.task_no_collabo.id])]})
# Same thing but using context defaults
with self.assertRaisesRegex(AccessError, "You cannot write on description"):
Task.with_context(default_child_ids=[Command.create({'name': 'Foo', 'description': 'Foo'})]).create({'name': 'foo'})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
# However, cache is updated, but nothing is written.
with self.assertRaisesRegex(AccessError, "top-secret records"):
Task.with_context(default_child_ids=[Command.update(self.task_no_collabo.id, {'name': 'Foo'})]).create({'name': 'foo'})
with self.assertRaisesRegex(AccessError, "not allowed to delete 'Task'"):
Task.with_context(default_child_ids=[Command.delete(self.task_no_collabo.id)]).create({'name': 'foo'})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
Task.with_context(default_child_ids=[Command.unlink(self.task_no_collabo.id)]).create({'name': 'foo'})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
Task.with_context(default_child_ids=[Command.link(self.task_no_collabo.id)]).create({'name': 'foo'})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
Task.with_context(default_child_ids=[Command.set([self.task_no_collabo.id])]).create({'name': 'foo'})
with Task.env.cr.savepoint() as sp:
task = Task.with_context(default_child_ids=[Command.delete(self.task_no_collabo.id)]).create({'name': 'foo'})
task.env.invalidate_all()
self.assertTrue(self.task_no_collabo.exists(), "Task should still be there, no delete is sent")
sp.rollback()
with self.env.cr.savepoint() as sp:
self.task_no_collabo.parent_id = self.task_no_collabo.create({'name': 'parent collabo'})
task = Task.with_context(default_child_ids=[Command.unlink(self.task_no_collabo.id)]).create({'name': 'foo'})
task.env.invalidate_all()
self.assertTrue(self.task_no_collabo.parent_id, "Task should still be there, no delete is sent")
sp.rollback()
with self.assertRaisesRegex(AccessError, "top-secret records"):
task = Task.with_context(default_child_ids=[Command.link(self.task_no_collabo.id)]).create({'name': 'foo'})
task.env.invalidate_all()
self.assertFalse(task.child_ids)
with self.assertRaisesRegex(AccessError, "top-secret records"):
task = Task.with_context(default_child_ids=[Command.set([self.task_no_collabo.id])]).create({'name': 'foo'})
task.env.invalidate_all()
self.assertFalse(task.child_ids)
# Create/update a tag through tag_ids
with self.assertRaisesRegex(AccessError, "not allowed to create 'Project Tags'"):
@ -208,15 +364,23 @@ class TestProjectSharing(TestProjectSharingCommon):
# Same thing but using context defaults
with self.assertRaisesRegex(AccessError, "not allowed to create 'Project Tags'"):
Task.with_context(default_tag_ids=[Command.create({'name': 'Bar'})]).create({'name': 'foo'})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Project Tags'"):
Task.with_context(default_tag_ids=[Command.update(self.task_tag.id, {'name': 'Bar'})]).create({'name': 'foo'})
with self.assertRaisesRegex(AccessError, "not allowed to delete 'Project Tags'"):
with Task.env.cr.savepoint() as sp:
task = Task.with_context(default_tag_ids=[Command.update(self.task_tag.id, {'name': 'Bar'})]).create({'name': 'foo'})
task.env.invalidate_all()
self.assertNotEqual(self.task_tag.name, 'Bar')
sp.rollback()
with Task.env.cr.savepoint() as sp:
Task.with_context(default_tag_ids=[Command.delete(self.task_tag.id)]).create({'name': 'foo'})
task.env.invalidate_all()
self.assertTrue(self.task_tag.exists())
sp.rollback()
task = Task.create({'name': 'foo', 'tag_ids': [Command.link(self.task_tag.id)]})
task = Task.create({'name': 'foo', 'color': 1, 'tag_ids': [Command.link(self.task_tag.id)]})
self.assertEqual(task.color, 1)
self.assertEqual(task.tag_ids, self.task_tag)
Task.create({'name': 'foo', 'tag_ids': [Command.set([self.task_tag.id])]})
task = Task.create({'name': 'foo', 'color': 4, 'tag_ids': [Command.set([self.task_tag.id])]})
self.assertEqual(task.color, 4)
self.assertEqual(task.tag_ids, self.task_tag)
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@ -231,11 +395,9 @@ class TestProjectSharing(TestProjectSharingCommon):
3.1) Try to change the project of the new task with this user.
3.2) Create a sub-task
3.3) Create a second sub-task
4.1) Restrict to edit with limited access and try to edit a task with and without following it
4.2) Restrict to read and check he can no longer edit the tasks, even if he is within the followers
"""
# 0) Allow to create subtasks in the project tasks
# Required for `child_ids` to be visible in the view
# {'invisible': [('allow_subtasks', '=', False)]}
self.project_cows.allow_subtasks = True
# 1) Give the 'read' access mode to a portal user in a project and try to create task with this user.
with self.assertRaises(AccessError, msg="Should not accept the portal user create a task in the project when he has not the edit access right."):
with self.get_project_sharing_form_view(self.task_cow.with_context({'tracking_disable': True, 'default_project_id': self.project_cows.id}), self.user_portal) as form:
@ -243,15 +405,15 @@ class TestProjectSharing(TestProjectSharingCommon):
task = form.save()
project_share_wizard = self.env['project.share.wizard'].create({
'access_mode': 'edit',
'res_model': 'project.project',
'res_id': self.project_cows.id,
'partner_ids': [
Command.link(self.user_portal.partner_id.id),
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id, 'access_mode': 'edit'}),
],
})
project_share_wizard.action_send_mail()
# the portal user is set as follower for the task_cow. Without it he does not have read access to the task, and thus can not access its view form
self.task_cow.message_subscribe(partner_ids=self.user_portal.partner_id.ids)
with self.get_project_sharing_form_view(self.task_cow.with_context({'tracking_disable': True, 'default_project_id': self.project_cows.id, 'uid': self.user_portal.id}), self.user_portal) as form:
form.name = 'Test'
task = form.save()
@ -268,7 +430,7 @@ class TestProjectSharing(TestProjectSharingCommon):
with form.child_ids.new() as subtask_form:
subtask_form.name = 'Test Subtask'
with self.assertRaises(AssertionError, msg="Should not accept the portal user changes the project of the task."):
subtask_form.display_project_id = self.project_portal
subtask_form.project_id = self.project_portal
self.assertEqual(task.child_ids.name, 'Test Subtask')
self.assertEqual(task.child_ids.project_id, self.project_cows)
self.assertFalse(task.child_ids.portal_user_names, 'by default no user should be assigned to a subtask created by the portal user.')
@ -298,17 +460,15 @@ class TestProjectSharing(TestProjectSharingCommon):
task.write({'parent_id': self.task_no_collabo.id})
# Create/Update a forbidden task through child_ids
with self.assertRaisesRegex(AccessError, "You cannot write on description"):
task.write({'child_ids': [Command.create({'name': 'Foo', 'description': 'Foo'})]})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
task.write({'child_ids': [Command.update(self.task_no_collabo.id, {'name': 'Foo'})]})
with self.assertRaisesRegex(AccessError, "not allowed to delete 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
task.write({'child_ids': [Command.delete(self.task_no_collabo.id)]})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
task.write({'child_ids': [Command.unlink(self.task_no_collabo.id)]})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
task.write({'child_ids': [Command.link(self.task_no_collabo.id)]})
with self.assertRaisesRegex(AccessError, "not allowed to modify 'Task'"):
with self.assertRaisesRegex(AccessError, "top-secret records"):
task.write({'child_ids': [Command.set([self.task_no_collabo.id])]})
# Create/update a tag through tag_ids
@ -332,6 +492,44 @@ class TestProjectSharing(TestProjectSharingCommon):
task.write({'tag_ids': [Command.set([self.task_tag.id])]})
self.assertEqual(task.tag_ids, self.task_tag)
# 4.1) Restrict the collaborator access to edit with limited access, restricting the collaborator to edit task
# on which he is in the followers only
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_cows.id,
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id, 'access_mode': 'edit_limited'}),
],
})
self.assertTrue(self.project_cows.collaborator_ids.limited_access)
# Removing the collaborator from the followers prevents him to edit the task
task.sudo().message_partner_ids -= self.user_portal.partner_id
with self.assertRaises(AccessError):
task.write({'name': 'foo'})
# Adding the collaborator back to the followers grants him to edit the task
task.sudo().message_partner_ids += self.user_portal.partner_id
task.write({'name': 'foo'})
# 4.2) Restrict the access to read and check he can no longer edit the tasks, even if he is within the followers
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_cows.id,
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id, 'access_mode': 'read'}),
# Create a second collaborator with edit just so that the project sharing record rules
# do not get automatically disabled when removing the last remaining edit collaborator
Command.create({'partner_id': self.env['res.partner'].create({'name': 'Alain'}).id, 'access_mode': 'edit'}),
],
})
# Sanity check: Assert the project sharing record rule is still active
self.assertTrue(self.env.ref('project.project_task_rule_portal_project_sharing').active)
# Assert the collaborator can no longer write on the task despite he is still in the followers of the task
self.assertIn(self.user_portal.partner_id, task.sudo().message_partner_ids)
with self.assertRaises(AccessError):
task.write({'name': 'foo'})
def test_portal_user_cannot_see_all_assignees(self):
""" Test when the portal sees a task he cannot see all the assignees.
@ -350,15 +548,15 @@ class TestProjectSharing(TestProjectSharingCommon):
self.assertEqual(len(self.task_cow.user_ids), 2, '2 users should be assigned in this task.')
project_share_wizard = self.env['project.share.wizard'].create({
'access_mode': 'edit',
'res_model': 'project.project',
'res_id': self.project_cows.id,
'partner_ids': [
Command.link(self.user_portal.partner_id.id),
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id, 'access_mode': 'edit'}),
],
})
project_share_wizard.action_send_mail()
# subscribe the portal user to give him read access to the task.
self.task_cow.message_subscribe(partner_ids=self.user_portal.partner_id.ids)
self.assertFalse(self.task_cow.with_user(self.user_portal).user_ids, 'the portal user should see no assigness in the task.')
task_portal_read = self.task_cow.with_user(self.user_portal).read(['portal_user_names'])
self.assertEqual(self.task_cow.portal_user_names, task_portal_read[0]['portal_user_names'], 'the portal user should see assignees name in the task via the `portal_user_names` field.')
@ -370,65 +568,52 @@ class TestProjectSharing(TestProjectSharingCommon):
if a email template is set in `rating_template_id` field in the new stage.
"""
self.project_portal.write({
'rating_active': True,
'rating_status': 'stage',
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id}),
],
})
self.task_portal.with_user(self.user_portal).write({'stage_id': self.project_portal.type_ids[-1].id})
stage = self.project_portal.type_ids[-1]
stage.write({
'rating_active': True,
'rating_status': 'stage',
})
self.task_portal.with_user(self.user_portal).write({'stage_id': stage.id})
def test_orm_method_with_true_false_domain(self):
""" Test orm method overriden in project for project sharing works with TRUE_LEAF/FALSE_LEAF
""" Test orm method overriden in project for project sharing works
Test Case
=========
1) Share a project in edit mode for portal user
2) Search the portal task contained in the project shared by using a domain with TRUE_LEAF
2) Search the portal task contained in the project shared by using a TRUE domain
3) Check the task is found with the `search` method
4) filter the task with `TRUE_DOMAIN` and check if the task is always returned by `filtered_domain` method
5) filter the task with `FALSE_DOMAIN` and check if no task is returned by `filtered_domain` method
6) Search the task with `FALSE_LEAF` and check no task is found with `search` method
7) Call `read_group` method with `TRUE_LEAF` in the domain and check if the task is found
8) Call `read_group` method with `FALSE_LEAF` in the domain and check if no task is found
4) Search the task with `FALSE` and check no task is found with `search` method
5) Call `read_group` method with `TRUE` in the domain and check if the task is found
6) Call `read_group` method with `FALSE` in the domain and check if no task is found
"""
domain = [('id', '=', self.task_portal.id)]
domain = Domain('id', '=', self.task_portal.id)
self.project_portal.write({
'collaborator_ids': [Command.create({
'partner_id': self.user_portal.partner_id.id,
})],
})
task = self.env['project.task'].with_user(self.user_portal).search(
expression.AND([
expression.TRUE_DOMAIN,
domain,
])
)
task = self.env['project.task'].with_user(self.user_portal).search(domain)
self.assertTrue(task, 'The task should be found.')
self.assertEqual(task, task.filtered_domain(expression.TRUE_DOMAIN), 'The task found should be kept since the domain is truly')
self.assertFalse(task.filtered_domain(expression.FALSE_DOMAIN), 'The task should not be found since the domain is falsy')
task = self.env['project.task'].with_user(self.user_portal).search(
expression.AND([
expression.FALSE_DOMAIN,
domain,
]),
)
task = self.env['project.task'].with_user(self.user_portal).search(Domain.FALSE)
self.assertFalse(task, 'No task should be found since the domain contained a falsy tuple.')
task_read_group = self.env['project.task'].read_group(
expression.AND([expression.TRUE_DOMAIN, domain]),
['id'],
[],
task_read_group = self.env['project.task'].formatted_read_group(
domain,
aggregates=['id:min', '__count'],
)
self.assertEqual(task_read_group[0]['__count'], 1, 'The task should be found with the read_group method containing a truly tuple.')
self.assertEqual(task_read_group[0]['id'], self.task_portal.id, 'The task should be found with the read_group method containing a truly tuple.')
self.assertEqual(task_read_group[0]['__count'], 1, 'The task should be found with the formatted_read_group method containing a truly tuple.')
self.assertEqual(task_read_group[0]['id:min'], self.task_portal.id, 'The task should be found with the formatted_read_group method containing a truly tuple.')
task_read_group = self.env['project.task'].read_group(
expression.AND([expression.FALSE_DOMAIN, domain]),
['id'],
[],
task_read_group = self.env['project.task'].formatted_read_group(
Domain.FALSE,
aggregates=['__count'],
)
self.assertFalse(task_read_group[0]['__count'], 'No result should found with the read_group since the domain is falsy.')
self.assertFalse(task_read_group[0]['__count'], 'No result should found with the formatted_read_group since the domain is falsy.')
def test_milestone_read_access_right(self):
""" This test ensures that a portal user has read access on the milestone of the project that was shared with him """
@ -456,3 +641,99 @@ class TestProjectSharing(TestProjectSharingCommon):
'name': 'Test Project new Milestone',
'project_id': self.project_portal.id,
})
def test_add_followers_from_share_edit_wizard(self):
"""
This test ensures that when a project is shared in edit mode, the partners are correctly set as follower in the project and their respective tasks.
"""
company_partner = self.env.company.partner_id
partners = partner_a, partner_b, partner_d = self.env['res.partner'].create([
{'name': "Solanum", 'parent_id': company_partner.id},
{'name': "Zana", 'parent_id': company_partner.id},
{'name': "Thresh"},
])
partners |= company_partner
project_to_share = self.env['project.project'].create({'name': "project to share"})
task_with_partner_1, task_with_partner_2, task_with_parent_partner, task_without_partner = self.env['project.task'].create([{
'name': "Task with partner 1",
'partner_id': partner_a.id,
'project_id': project_to_share.id,
}, {
'name': "Task with partner 2",
'partner_id': partner_b.id,
'project_id': project_to_share.id,
}, {
'name': "Task with company",
'partner_id': company_partner.id,
'project_id': project_to_share.id,
}, {
'name': "Task with no partner",
'project_id': project_to_share.id,
}])
project_to_share._add_followers(partners)
self.assertEqual(partners, project_to_share.message_partner_ids, "All the partner should be set as a new follower of the project")
self.assertEqual(partner_a, task_with_partner_1.message_partner_ids, "Only the first partner should be set as a new follower for the task 1")
self.assertEqual(partner_b, task_with_partner_2.message_partner_ids, "Only the second partner should be set as a new follower for the task 2")
self.assertEqual(partners - partner_d, task_with_parent_partner.message_partner_ids,
"The first, second, and the company partner should be set as new followers for the task 3 because the partner of this task is the parent of the other 2")
self.assertFalse(task_without_partner.message_partner_ids, "Since this task has no partner, no follower should be added")
def test_project_manager_remains_follower_after_sharing(self):
"""
Test that the project manager remains a follower when collaborators are added
"""
project = self.env['project.project'].with_context({'mail_create_nolog': True}).create({
'name': 'project',
'privacy_visibility': 'followers',
'user_id': self.user_projectmanager.id,
})
self.assertIn(self.user_projectmanager.partner_id, project.message_partner_ids, "Project manager should be a follower of the project")
project_share_wizard = self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': project.id,
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id, 'access_mode': 'read'}),
],
})
project_share_wizard.action_send_mail()
self.assertIn(self.user_projectmanager.partner_id, project.message_partner_ids, "Project manager should still be a follower after sharing the project")
self.assertEqual(len(project.message_follower_ids), 2, "number of followers should be 2")
def test_portal_user_with_edit_rights_can_close_recurring_task(self):
"""
Test that a portal user with edit rights can close a recurrent task.
Test Case:
==========
1) Create a project with a recurrent task.
2) Create a portal user and give them edit rights on the project.
3) Ensure the portal user can close the recurrent task.
"""
portal_user = self.env['res.users'].create({
'name': 'Portal User',
'login': 'portaluser',
'email': 'portaluser@odoo.com',
'group_ids': [(6, 0, [self.env.ref('base.group_portal').id])],
})
project = self.env['project.project'].create({
'name': 'Project with Portal User',
})
project.task_ids = [Command.create({
'name': 'Recurrent Task',
'recurring_task': True,
'repeat_type': 'forever',
})]
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': project.id,
'collaborator_ids': [
Command.create({'partner_id': portal_user.partner_id.id, 'access_mode': 'edit'}),
],
})
task = project.task_ids[0]
self.env.invalidate_all()
task.with_user(portal_user).write({'state': '1_done'})
self.assertEqual(task.state, '1_done', "The portal user with edit rights should be able to mark the task as done.")
next_task = task.recurrence_id.task_ids.filtered(lambda t: t != task)
self.assertTrue(next_task, "The next occurrence of the recurrent task should be created.")

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
@ -8,9 +7,9 @@ from lxml import etree
from re import search
from odoo import Command
from odoo.tools import mute_logger
from odoo.tools import mute_logger, config
from odoo.exceptions import AccessError
from odoo.tests import tagged, HttpCase
from odoo.tests import HttpCase, tagged
from .test_project_sharing import TestProjectSharingCommon
@ -21,38 +20,63 @@ class TestProjectSharingPortalAccess(TestProjectSharingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
project_share_wizard = cls.env['project.share.wizard'].create({
'access_mode': 'edit',
cls.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': cls.project_portal.id,
'partner_ids': [
Command.link(cls.partner_portal.id),
'collaborator_ids': [
Command.create({'partner_id': cls.partner_portal.id, 'access_mode': 'edit'}),
],
})
project_share_wizard.action_send_mail()
Task = cls.env['project.task']
readable_fields, writeable_fields = Task._portal_accessible_fields()
cls.read_protected_fields_task = OrderedDict([
(k, v)
for k, v in Task._fields.items()
if k in Task.SELF_READABLE_FIELDS
if k in readable_fields
])
cls.write_protected_fields_task = OrderedDict([
(k, v)
for k, v in Task._fields.items()
if k in Task.SELF_WRITABLE_FIELDS
if k in writeable_fields
])
cls.readonly_protected_fields_task = OrderedDict([
(k, v)
for k, v in Task._fields.items()
if k in Task.SELF_READABLE_FIELDS and k not in Task.SELF_WRITABLE_FIELDS
if k in readable_fields and k not in writeable_fields
])
cls.other_fields_task = OrderedDict([
(k, v)
for k, v in Task._fields.items()
if k not in Task.SELF_READABLE_FIELDS
if k not in readable_fields
])
def test_mention_suggestions(self):
data = self.task_portal.with_user(self.user_portal).get_mention_suggestions(search="")
suggestion_ids = {partner.get("id") for partner in data.get("res.partner")}
self.assertEqual(
suggestion_ids,
{self.user_projectuser.partner_id.id, self.user_portal.partner_id.id},
"Portal user as a project collaborator should have access to mention suggestions",
)
self.assertEqual(
data["res.partner"][0]["mention_token"],
self.user_projectuser.partner_id._get_mention_token(),
)
self.assertEqual(
data["res.partner"][1]["mention_token"],
self.user_portal.partner_id._get_mention_token(),
)
# remove portal user from the project collaborators
self.project_portal.collaborator_ids.filtered(
lambda rec: rec.partner_id == self.user_portal.partner_id
).unlink()
self.assertEqual(
{},
self.task_portal.with_user(self.user_portal).get_mention_suggestions(search=""),
"Non collaborator portal user should not have access to mention suggestions",
)
def test_readonly_fields(self):
""" The fields are not writeable should not be editable by the portal user. """
view_infos = self.task_portal.get_view(self.env.ref(self.project_sharing_form_view_xml_id).id)
@ -68,48 +92,101 @@ class TestProjectSharingPortalAccess(TestProjectSharingCommon):
form.__setattr__(field, 'coucou')
def test_read_task_with_portal_user(self):
self.task_portal.with_user(self.user_portal).read(self.read_protected_fields_task)
with self.assertRaises(AccessError):
self.task_portal.with_user(self.user_portal).read(self.other_fields_task)
def test_write_with_portal_user(self):
for field in self.readonly_protected_fields_task:
with self.assertRaises(AccessError):
self.task_portal.with_user(self.user_portal).write({field: 'dummy'})
task = self.task_portal.with_user(self.user_portal)
task.check_access('read')
task.read(self.read_protected_fields_task)
for field in self.other_fields_task:
with self.assertRaises(AccessError):
self.task_portal.with_user(self.user_portal).write({field: 'dummy'})
task.invalidate_recordset()
with self.assertRaises(AccessError, msg=f"Field {field} should be inaccessible"):
task.read([field])
def test_write_task_with_portal_user(self):
task = self.task_portal.with_user(self.user_portal)
task.check_access('write')
def dummy_value(field_name):
field = task._fields[field_name]
if field.is_text:
value = 'dummy'
if field.type == 'html':
value = f'<p>{value}</p>'
return value
if field.relational and field.comodel_name != 'ir.attachment':
value = task.env[field.comodel_name].search([], limit=1).id
if field.type != 'many2one':
value = [value]
return value
if field.name == 'id':
return 42
return task.default_get([field_name]).get(field_name, False)
for field_name in self.write_protected_fields_task:
field = task._fields[field_name]
if field.comodel_name == 'project.task':
other_task = self.env['project.task'].create({'name': 'Parent task', 'project_id': task.project_id.id})
value = other_task.id if field.type == 'many2one' else other_task.ids
task.write({field_name: value})
self.assertEqual(task[field_name], other_task)
else:
value = dummy_value(field_name)
task.write({field_name: value})
actual_value = task[field_name]
expected_value = field.convert_to_record(value, task)
self.assertEqual(actual_value, expected_value, f"Field {field} should be editable.")
for field in self.readonly_protected_fields_task:
with self.assertRaises(AccessError, msg=f"Field {field} should be readonly"):
task.write({field: dummy_value(field)})
for field in self.other_fields_task:
with self.assertRaises(AccessError, msg=f"Field {field} should be inaccessible"):
task.write({field: dummy_value(field)})
def test_wizard_confirm(self):
partner_portal_no_user = self.env['res.partner'].create({
'name': 'NoUser portal',
'email': 'no@user.portal',
'company_id': False,
'user_ids': [],
})
project_share_wizard_no_user = self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_portal.id,
'collaborator_ids': [
Command.create({'partner_id': partner_portal_no_user.id, 'access_mode': 'edit'}),
],
})
self.env["res.config.settings"].create({"auth_signup_uninvited": 'b2b'}).execute()
project_share_wizard_no_user_action = project_share_wizard_no_user.action_share_record()
self.assertEqual(project_share_wizard_no_user_action['type'], 'ir.actions.act_window', 'Sharing a project with partner without user should display a confimation dialog')
project_share_wizard_confirmation = self.env['project.share.wizard'].browse(project_share_wizard_no_user_action['res_id'])
project_share_wizard_confirmation.action_send_mail()
mail_partner = self.env['mail.message'].search([('partner_ids', '=', partner_portal_no_user.id)], limit=1)
self.assertTrue(mail_partner, 'A mail should have been sent to the non portal user')
self.assertIn(f'href="http://localhost:{config["http_port"]}/web/signup', str(mail_partner.body), 'The message link should contain the url to register to the portal')
self.assertIn('token=', str(mail_partner.body), 'The message link should contain a personalized token to register to the portal')
class TestProjectSharingChatterAccess(TestProjectSharingCommon, HttpCase):
@mute_logger('odoo.addons.http_routing.models.ir_http', 'odoo.http')
def test_post_chatter_as_portal_user(self):
self.project_no_collabo.privacy_visibility = 'portal'
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_no_collabo.id,
'access_mode': 'edit',
'partner_ids': [Command.set([self.user_portal.partner_id.id])],
}).action_send_mail()
message = self.env['mail.message'].search([
('partner_ids', 'in', self.user_portal.partner_id.id),
])
message = self.get_project_share_link()
share_link = str(message.body.split('href="')[1].split('">')[0])
match = search(r"access_token=([^&]+)&amp;pid=([^&]+)&amp;hash=([^&]*)", share_link)
access_token, pid, _hash = match.groups()
res = self.url_open(
url="/mail/chatter_post",
url="/mail/message/post",
data=json.dumps({
"params": {
"res_model": 'project.task',
"res_id": self.task_no_collabo.id,
"message": '(-b ±√[b²-4ac]) / 2a',
"attachment_ids": None,
"attachment_tokens": None,
"thread_model": self.task_no_collabo._name,
"thread_id": self.task_no_collabo.id,
"post_data": {'body': '(-b ±√[b²-4ac]) / 2a'},
"token": access_token,
"pid": pid,
"hash": _hash,

View file

@ -3,28 +3,27 @@
from odoo import Command
from odoo.tests import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestProjectSharingUi(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
user = cls.env['res.users'].with_context({'no_reset_password': True, 'mail_create_nolog': True}).create({
cls.user_portal = cls.env['res.users'].with_context({'no_reset_password': True, 'mail_create_nolog': True}).create({
'name': 'Georges',
'login': 'georges1',
'password': 'georges1',
'email': 'georges@project.portal',
'signature': 'SignGeorges',
'notification_type': 'email',
'groups_id': [Command.set([cls.env.ref('base.group_portal').id])],
'group_ids': [Command.set([cls.env.ref('base.group_portal').id, cls.env.ref('project.group_project_milestone').id])],
})
cls.partner_portal = cls.env['res.partner'].with_context({'mail_create_nolog': True}).create({
'name': 'Georges',
'email': 'georges@project.portal',
'company_id': False,
'user_ids': [user.id],
'user_ids': [cls.user_portal.id],
})
cls.project_portal = cls.env['project.project'].with_context({'mail_create_nolog': True}).create({
'name': 'Project Sharing',
@ -35,12 +34,61 @@ class TestProjectSharingUi(HttpCase):
Command.create({'name': 'To Do', 'sequence': 1}),
Command.create({'name': 'Done', 'sequence': 10})
],
'allow_milestones': True,
})
cls.env['res.config.settings'].create({'group_project_milestone': True}).execute()
cls.env.user.group_ids |= cls.env.ref('project.group_project_milestone')
def test_blocked_task_with_project_sharing_string_portal(self):
"""
Ensure the portal user shows the message 'This task is currently blocked...'.
Flow:
- Activated Task Dependencies in a portal project
- Create a 'New' task stage
- Create a project(Test Project)
- Ensure the portal user receives the message 'This task is currently blocked..'.
- Create task(Test Task)
- Create a task with a Blocked task (Test Task)
"""
self.project_portal.write({
'allow_task_dependencies': True,
'collaborator_ids': [
Command.create({'partner_id': self.partner_portal.id}),
],
})
project = self.env['project.project'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Project',
})
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_portal.id,
'collaborator_ids': [
Command.create({'partner_id': self.partner_portal.id, 'access_mode': 'edit'}),
],
})
task = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Task',
'project_id': project.id,
})
self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Portal Task',
'project_id': self.project_portal.id,
'depend_on_ids': task.ids,
'stage_id': self.project_portal.type_ids[0].id,
})
self.start_tour("/odoo", 'project_sharing_with_blocked_task_tour', login="georges1")
def test_01_project_sharing(self):
""" Test Project Sharing UI with an internal user """
self.start_tour("/web", 'project_sharing_tour', login="admin")
self.env.ref('base.user_admin').write({
'email': 'mitchell.admin@example.com',
})
self.start_tour("/odoo", 'project_sharing_tour', login="admin")
def test_02_project_sharing(self):
""" Test project sharing ui with a portal user.
@ -50,15 +98,13 @@ class TestProjectSharingUi(HttpCase):
Since a problem to logout Mitchell Admin to log in as Georges user, this test is created
to launch a tour with portal user.
"""
project_share_wizard = self.env['project.share.wizard'].create({
'access_mode': 'edit',
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_portal.id,
'partner_ids': [
Command.link(self.partner_portal.id),
'collaborator_ids': [
Command.create({'partner_id': self.partner_portal.id, 'access_mode': 'edit'}),
],
})
project_share_wizard.action_send_mail()
self.project_portal.write({
'task_ids': [Command.create({
@ -67,3 +113,76 @@ class TestProjectSharingUi(HttpCase):
})],
})
self.start_tour("/my/projects", 'portal_project_sharing_tour', login='georges1')
def test_03_project_sharing(self):
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_portal.id,
'collaborator_ids': [
Command.create({'partner_id': self.partner_portal.id, 'access_mode': 'edit'}),
],
})
self.project_portal.write({
'task_ids': [Command.create({
'name': "Test Project Sharing",
'stage_id': self.project_portal.type_ids.filtered(lambda stage: stage.sequence == 10)[:1].id,
})],
'allow_milestones': False,
})
self.start_tour("/my/projects", 'portal_project_sharing_tour_with_disallowed_milestones', login='georges1')
def test_04_project_sharing_chatter_message_reactions(self):
# portal users can load chatter messages containing partner reactions
self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_portal.id,
'collaborator_ids': [
Command.create({'partner_id': self.partner_portal.id, 'access_mode': 'edit'}),
],
})
user_john = self.env["res.users"].create({
'name': 'John',
'login': 'john',
'password': 'john1234',
'email': 'john@example.com',
'group_ids': [Command.set([
self.env.ref('base.group_user').id,
self.env.ref('project.group_project_user').id
])]
})
task = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Task with messages',
'project_id': self.project_portal.id,
})
self.authenticate("georges1", "georges1")
message = task.message_post(
body='TestingMessage',
message_type="comment",
subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
)
self.authenticate("john", "john")
self.project_portal.message_subscribe(partner_ids=[user_john.partner_id.id])
self.make_jsonrpc_request(
route="/mail/message/reaction",
params={"action": "add", "content": "👀", "message_id": message.id},
)
self.start_tour("/my/projects", 'test_04_project_sharing_chatter_message_reactions', login='georges1')
def test_05_project_sharing_chatter_mention_users(self):
self.env["project.share.wizard"].create(
{
"res_model": "project.project",
"res_id": self.project_portal.id,
"collaborator_ids": [
Command.create({"partner_id": self.partner_portal.id, "access_mode": "edit"}),
],
}
)
self.env["project.task"].with_context({"mail_create_nolog": True}).create(
{
"name": "Test Task",
"project_id": self.project_portal.id,
}
)
self.start_tour("/my/projects", "portal_project_sharing_chatter_mention_users", login="georges1")

View file

@ -0,0 +1,141 @@
from .test_multicompany import TestMultiCompanyProject
from odoo.exceptions import UserError
class TestProjectStagesMulticompany(TestMultiCompanyProject):
@classmethod
def setUpClass(cls):
super(TestProjectStagesMulticompany, cls).setUpClass()
Users = cls.env['res.users'].with_context({'no_reset_password': True})
cls.user_manager_companies = Users.create({
'name': 'Manager Companies',
'login': 'manager-all',
'email': 'manager@companies.com',
'company_id': cls.company_a.id,
'company_ids': [(4, cls.company_a.id), (4, cls.company_b.id)],
'group_ids':
[(6, 0, [
cls.env.ref('base.group_user').id,
cls.env.ref('project.group_project_stages').id,
cls.env.ref('project.group_project_manager').id,
])]
})
cls.stage_company_a, cls.stage_company_b, cls.stage_company_none = cls.env['project.project.stage'].create([{
'name': 'Stage Company A',
'company_id': cls.company_a.id,
}, {
'name': 'Stage Company B',
'company_id': cls.company_b.id,
}, {
'name': 'Stage Company None',
}])
cls.project_company_none = cls.env['project.project'].create({
'name': 'Project Company None'
})
def test_move_linked_project_stage_other_company(self):
""" This test will check that an error is raised if a project belonging to a stage
(both linked to company A) is moved to another stage (belonging to company B) """
self.project_company_a.stage_id = self.stage_company_a.id
with self.assertRaises(UserError):
self.project_company_a.stage_id = self.stage_company_b.id
def test_move_project_stage_other_company(self):
""" This test will check that an error is raised a project belonging to a stage (both
not linked to any company) is moved to another stage (belonging to company B) """
self.project_company_none.stage_id = self.stage_company_none.id
with self.assertRaises(UserError):
self.project_company_none.stage_id = self.stage_company_b.id,
def test_move_linked_project_stage_same_company(self):
""" This test will check that no error is raised if a project belonging to a stage (with
only the project belonging to company B and the stage not linked to any company) is moved
to another stage (belonging to company B) """
self.project_company_b.stage_id = self.stage_company_none.id
self.project_company_b.stage_id = self.stage_company_b.id
def test_move_project_stage_same_company(self):
""" This test will check that no error is raised if a project belonging to a stage (both
linked to company A) is moved to another stage (also belonging to company A) """
self.project_company_a.stage_id = self.stage_company_a.id
self.stage_company_none.company_id = self.company_a.id
self.project_company_a.stage_id = self.stage_company_none.id
def test_change_project_company(self):
""" This test will check that a project's stage is changed according to the
company it is linked to. When a project (belonging to a stage with both the
project and the stage linked to company A) changes company for company B,
the stage should change for the lowest stage in sequence that is linked to
company B. If there is no stage linked to company B, then the lowest stage
in sequence linked to no company will be chosen """
project = self.project_company_a.with_user(self.user_manager_companies)
project.stage_id = self.stage_company_a.id
project.company_id = self.company_b.id
# Check that project was moved to stage_company_b
self.assertFalse(self.project_company_a.stage_id.company_id, "Project Company A should now be in a stage without company")
def test_project_creation_default_stage(self):
"""
Check that when creating a project with a company set, the default stage
for this project has the same company as the project or no company.
If no company is set on the project, the first stage without a company
should be chosen.
"""
# Stage order: company A, company B, no company
self.stage_company_a.sequence = 1
self.stage_company_b.sequence = 3
project_company_b = self.env['project.project'].with_user(self.user_manager_companies).create({
'name': 'Project company B',
'company_id': self.company_b.id,
})
self.assertEqual(project_company_b.company_id, self.company_b)
self.assertEqual(project_company_b.stage_id, self.stage_company_b)
# Stage order: company A, no company, company B
self.stage_company_none.sequence = 2
project_company_b = self.env['project.project'].with_user(self.user_manager_companies).create({
'name': 'Project company B',
'company_id': self.company_b.id,
})
self.assertEqual(project_company_b.company_id, self.company_b)
self.assertEqual(project_company_b.stage_id, self.stage_company_none)
project_no_company = self.env['project.project'].with_user(self.user_manager_companies).create({
'name': 'Project no company',
})
self.assertFalse(project_no_company.company_id)
self.assertEqual(project_no_company.stage_id, self.stage_company_none)
self.env['project.project.stage'].search([]).active = False
project_no_company = self.env['project.project'].with_user(self.user_manager_companies).create({
'name': 'Project no company',
})
self.assertFalse(project_no_company.stage_id)
def test_project_creation_default_stage_in_context(self):
"""
Project's company should be the same as the default stage's company in the context.
"""
project = self.env['project.project'].with_user(self.user_manager_companies).with_context(default_stage_id=self.stage_company_b.id).create({
'name': 'Project company B',
})
self.assertEqual(project.company_id, self.company_b)
def test_create_project_in_stage(self):
""" Test create project inside a specific stage """
self.env['res.config.settings'].create({'group_project_stages': True}).execute()
stage = self.env['project.project.stage'].create({
'name': 'Stage 2',
'sequence': 100,
})
project = self.env['project.project'].create({
'name': 'Project in stage 2',
'stage_id': stage.id,
})
self.assertEqual(project.stage_id, stage)

View file

@ -1,22 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from lxml import etree
from psycopg2.errors import CheckViolation
from odoo import Command, _
from odoo.addons.project.tests.test_project_base import TestProjectCommon
from odoo.tests import tagged
from odoo.tests.common import Form
from odoo.tests import Form, tagged
from odoo.tools import mute_logger
from odoo.exceptions import ValidationError
@tagged('-at_install', 'post_install')
class TestProjectSubtasks(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Enable the company setting
cls.env['res.config.settings'].create({
'group_subtask_project': True
}).execute()
def test_task_display_project_with_default_form(self):
"""
Create a task in the default task form should take the project set in the form or the default project in the context
@ -27,14 +23,12 @@ class TestProjectSubtasks(TestProjectCommon):
task = task_form.save()
self.assertEqual(task.project_id, self.project_pigs, "The project should be assigned.")
self.assertEqual(task.display_project_id, task.project_id, "The display project of a first layer task should be assigned to project_id.")
with Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_pigs.id})) as task_form:
task_form.name = 'Test Task 2'
task = task_form.save()
self.assertEqual(task.project_id, self.project_pigs, "The project should be assigned from the default project.")
self.assertEqual(task.display_project_id, task.project_id, "The display project of a first layer task should be assigned to project_id.")
def test_task_display_project_with_task_form2(self):
"""
@ -46,25 +40,22 @@ class TestProjectSubtasks(TestProjectCommon):
task = task_form.save()
self.assertEqual(task.project_id, self.project_pigs, "The project should be assigned.")
self.assertEqual(task.display_project_id, task.project_id, "The display project of a first layer task should be assigned to project_id.")
with Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_pigs.id}), view="project.view_task_form2") as task_form:
task_form.name = 'Test Task 2'
task = task_form.save()
self.assertEqual(task.project_id, self.project_pigs, "The project should be assigned from the default project.")
self.assertEqual(task.display_project_id, task.project_id, "The display project of a first layer task should be assigned to project_id.")
def test_task_display_project_with_quick_create_task_form(self):
"""
Create a task in the quick create form should take the default project in the context
"""
with Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_pigs.id}), view="project.quick_create_task_form") as task_form:
task_form.name = 'Test Task 2'
task_form = Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_pigs.id}), view="project.quick_create_task_form")
task_form.display_name = 'Test Task 2'
task = task_form.save()
self.assertEqual(task.project_id, self.project_pigs, "The project should be assigned from the default project.")
self.assertEqual(task.display_project_id, task.project_id, "The display project of a first layer task should be assigned to project_id.")
def test_task_display_project_with_any_task_form(self):
"""
@ -72,172 +63,181 @@ class TestProjectSubtasks(TestProjectCommon):
"""
form_views = self.env['ir.ui.view'].search([('model', '=', 'project.task'), ('type', '=', 'form')])
for form_view in form_views:
task_form = Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_pigs.id, 'default_name': 'Test Task 1'}), view=form_view)
task_form = Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_pigs.id, 'default_name': 'Test Task 1', 'default_display_name': 'Test Task 1'}), view=form_view)
# Some views have the `name` field invisible
# As the goal is simply to test the default project field and not the name, we can skip setting the name
# in the view and set it using `default_name` instead
# Quick create form use display_name and for the same goal, we can add default_display_name for that form
task = task_form.save()
self.assertEqual(task.project_id, self.project_pigs, "The project should be assigned from the default project, form_view name : %s." % form_view.name)
self.assertEqual(task.display_project_id, task.project_id, "The display project of a first layer task should be assigned to project_id, form_view name : %s." % form_view.name)
def test_subtask_display_project(self):
@mute_logger('odoo.sql_db')
def test_subtask_project(self):
"""
1) Create a subtask
- Should have the same project as its parent
- Shouldn't have a display project set.
2) Set display project on subtask
- Should have a project set
- Shouldn't be displayed
2) Set project on subtask
- Should not change parent project
- Should change the subtask project
- Display project should be correct
3) Reset the display project to False
- Should make the project equal to parent project
- Display project should be correct
4) Change parent task project
- Should make the subtask project follow parent project
- Display project should stay false
5) Set display project on subtask and change parent task project
- Should make the subtask project follow new display project id
- Display project should be correct
- Project should be correct
- Should be displayed
3) Reset the project to False
- Should raise an error
3bis) Reset the parent task project to False
- Should raise an error
4) Set project on parent to same project as subtask
- Project should be correct
- Shouldn't change subtask's display
5) Set project on subtask and change parent task project
- Project should be the one set by the user
6) Remove parent task:
- The project id should remain unchanged
- The display project id should follow the project id
7) Remove display project id then parent id:
- The project id should be the one from the parent :
- Since the display project id was removed, the project id is updated to the parent one
- The display project id should follow the project id
7) Remove project id then parent id:
- Project should be removed
- Parent should be removed
"""
# 1)
test_subtask_1 = self.env['project.task'].create({
'name': 'Test Subtask 1',
})
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids.add(test_subtask_1)
parent_task = self.task_1.with_context({'tracking_disable': True})
self.assertEqual(self.task_1.child_ids.project_id, self.project_pigs, "The project should be assigned from the default project.")
self.assertFalse(self.task_1.child_ids.display_project_id, "The display project of a sub task should be false to project_id.")
# 1)
child_task = parent_task.create({
'name': 'Test Subtask 1',
'parent_id': parent_task.id,
'project_id': parent_task.project_id.id,
}).with_context({'tracking_disable': True})
self.assertEqual(child_task.project_id, self.task_1.project_id, "The project should be inheritted from parent.")
self.assertFalse(child_task.display_in_project, "By default, subtasks shouldn't be displayed in project.")
# 2)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids[0].display_project_id = self.project_goats
child_task.project_id = self.project_goats
self.assertEqual(self.task_1.project_id, self.project_pigs, "Changing the project of a subtask should not change parent project")
self.assertEqual(self.task_1.child_ids.display_project_id, self.project_goats, "Display Project of the task should be well assigned")
self.assertEqual(self.task_1.child_ids.project_id, self.project_goats, "Changing display project id on a subtask should change project id")
self.assertEqual(child_task.project_id, self.project_goats, "Display Project of the task should be well assigned")
self.assertTrue(child_task.display_in_project, "As the subtask isn't in the same project as its parent, it should be displayed")
# 3)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids[0].display_project_id = self.env['project.project']
with self.assertRaises(CheckViolation):
child_task.project_id = False
self.assertFalse(self.task_1.child_ids.display_project_id, "Display Project of the task should be well assigned, to False")
self.assertEqual(self.task_1.child_ids.project_id, self.project_pigs, "Resetting display project to False on a subtask should change project id to parent project id")
# 3bis)
with self.assertRaises(ValidationError):
parent_task.project_id = False
# 4)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.project_id = self.project_goats
self.assertEqual(self.task_1.project_id, self.project_goats, "Parent project should change.")
self.assertFalse(self.task_1.child_ids.display_project_id, "Display Project of the task should be False")
self.assertEqual(self.task_1.child_ids.project_id, self.project_goats, "Resetting display project to False on a subtask should follow project of its parent")
parent_task.project_id = self.task_1.child_ids.project_id
self.assertEqual(self.task_1.project_id, self.project_goats, "Parent project should change")
self.assertEqual(child_task.project_id, self.project_goats, "Child project should change")
self.assertTrue(child_task.display_in_project, "Changing the project of the task shouldn't change de value of display_in_project of its subtask")
# 5)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids[0].display_project_id = self.project_goats
task_form.project_id = self.project_pigs
parent_task.write({
'project_id': self.project_pigs.id,
'child_ids': [(1, parent_task.child_ids[0].id, {
'project_id': self.project_goats.id
})],
})
self.assertEqual(self.task_1.project_id, self.project_pigs, "Parent project should change back.")
self.assertEqual(self.task_1.child_ids.display_project_id, self.project_goats, "Display Project of the task should be well assigned")
self.assertEqual(self.task_1.child_ids.project_id, self.project_goats, "Changing display project id on a subtask should change project id")
self.assertEqual(self.task_1.project_id, self.project_pigs, "Parent project should change back")
self.assertEqual(child_task.project_id, self.project_goats, "The project of the subtask should have the one set by the user")
# Debug mode required for `parent_id` to be visible in the view
with self.debug_mode():
# 6)
with Form(self.task_1.child_ids.with_context({'tracking_disable': True})) as subtask_form:
subtask_form.parent_id = self.env['project.task']
orphan_subtask = subtask_form.save()
# 6)
child_task.parent_id = False
self.assertEqual(child_task.project_id, self.project_goats, "The project of the orphan task should stay the same even if it no longer has a parent task")
self.assertFalse(child_task.parent_id, "Parent should be false")
self.assertEqual(orphan_subtask.display_project_id, self.project_goats, "Display Project of the task should be well assigned")
self.assertEqual(orphan_subtask.project_id, self.project_goats, "Changing display project id on a subtask should change project id")
self.assertFalse(orphan_subtask.parent_id, "Parent should be false")
# 7)
test_subtask_1 = self.env['project.task'].create({
'name': 'Test Subtask 1',
})
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids.add(test_subtask_1)
task_form.child_ids[0].display_project_id = self.project_goats
with Form(self.task_1.child_ids.with_context({'tracking_disable': True})) as subtask_form:
subtask_form.display_project_id = self.env['project.project']
subtask_form.parent_id = self.env['project.task']
orphan_subtask = subtask_form.save()
self.assertEqual(orphan_subtask.project_id, self.project_pigs, "Removing parent should not change project")
self.assertEqual(orphan_subtask.display_project_id, self.project_pigs, "Removing parent should make the display project set as project.")
# 7)
other_child_task = parent_task.create({
'name': 'Test Subtask 1',
'parent_id': parent_task.id,
'project_id': self.project_goats.id,
})
other_child_task.write({
'project_id': False,
'parent_id': False,
})
self.assertFalse(other_child_task.project_id, "The project should be removed as expected")
self.assertFalse(other_child_task.parent_id, "The parent should be removed as expected")
def test_subtask_stage(self):
"""
The stage of the new child must be the default one of the project
"""
parent_task = self.task_1.with_context({'tracking_disable': True})
stage_a = self.env['project.task.type'].create({'name': 'a', 'sequence': 1})
stage_b = self.env['project.task.type'].create({'name': 'b', 'sequence': 10})
self.project_pigs.type_ids |= stage_a
self.project_pigs.type_ids |= stage_b
test_subtask_1 = self.env['project.task'].create({
child_task = parent_task.create({
'name': 'Test Subtask 1',
})
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids.add(test_subtask_1)
'parent_id': parent_task.id,
'project_id': parent_task.project_id.id,
}).with_context({'tracking_disable': True})
self.assertEqual(child_task.stage_id, stage_a, "Stage should be set on the subtask since it inheritted the project of its parent.")
self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should be the default one of the project.")
child_task.project_id = parent_task.project_id
self.assertEqual(child_task.stage_id, stage_a, "The stage of the child task should be the default one of the project.")
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.stage_id = stage_b
parent_task.stage_id = stage_b
self.assertEqual(child_task.stage_id, stage_a, "The stage of the child task should remain the same while changing parent task stage.")
self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should remain the same while changing parent task stage.")
test_subtask_2 = self.env['project.task'].create({
parent_task.child_ids = False
other_child_task = parent_task.create({
'name': 'Test Subtask 2',
})
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids.remove(test_subtask_1.id)
task_form.child_ids.add(test_subtask_2)
'parent_id': parent_task.id,
'project_id': parent_task.project_id.id,
}).with_context({'tracking_disable': True})
self.assertEqual(other_child_task.stage_id, stage_a, "The stage of the child task should be the default one of the project even if parent stage id is different.")
self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should be the default one of the project even if parent stage id is different.")
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids[0].display_project_id = self.project_goats
self.assertEqual(self.task_1.child_ids.stage_id.name, "New", "The stage of the child task should be the default one of the display project id, once set.")
other_child_task.project_id = self.project_goats
self.assertEqual(other_child_task.stage_id.name, "New", "The stage of the child task should be the default one of the display project id, once set.")
def test_copy_project_with_subtasks(self):
self.project_goats.allow_subtasks = True
self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Parent Task',
'project_id': self.project_goats.id,
'child_ids': [
Command.create({'name': 'child 1', 'stage_id': self.project_goats.type_ids[0].id}),
Command.create({'name': 'child 2', 'display_project_id': self.project_goats.id}),
Command.create({'name': 'child 3', 'display_project_id': self.project_pigs.id}),
Command.create({'name': 'child 4 with subtask', 'child_ids': [Command.create({'name': 'child 5'}), Command.create({'name': 'child 6 with project', 'display_project_id': self.project_goats.id})]}),
Command.create({'name': 'child archived', 'active': False}),
Command.create({'name': 'child 1', 'project_id': self.project_goats.id}),
Command.create({'name': 'child 2', 'project_id': self.project_pigs.id}),
Command.create({
'name': 'child 3 with subtask',
'project_id': self.project_goats.id,
'child_ids': [
Command.create({'name': 'granchild 3.1', 'project_id': self.project_goats.id}),
]}),
Command.create({'name': 'child archived', 'project_id': self.project_goats.id, 'active': False}),
],
'stage_id': self.project_goats.type_ids[0].id
})
task_count_with_subtasks_including_archived_in_project_goats = self.project_goats.with_context(active_test=False).task_count_with_subtasks
task_count_with_subtasks_including_archived = 6
task_count_in_project_pigs = self.project_pigs.task_count
self.project_goats._compute_task_count() # recompute without archived tasks and subtasks
task_count_in_project_goats = self.project_goats.task_count
project_goats_duplicated = self.project_goats.copy()
self.project_pigs._compute_task_count() # retrigger since a new task should be added in the project after the duplication of Project Goats
def dfs(task):
# ABGH: i used dfs to avoid visiting a task 2 times as it can be a direct task for the project and a subtask for another task like child 6
visited[task.id] = True
total_count = 1
for child_id in task.child_ids:
if child_id.id not in visited:
total_count += dfs(child_id)
return total_count
visited = {}
tasks_copied_count = 0
for task in project_goats_duplicated.tasks:
if not task.id in visited:
tasks_copied_count += dfs(task)
self.assertEqual(
project_goats_duplicated.with_context(active_test=False).task_count_with_subtasks,
task_count_with_subtasks_including_archived_in_project_goats - 1,
tasks_copied_count,
task_count_with_subtasks_including_archived - 1,
'The number of duplicated tasks (subtasks included) should be equal to the number of all tasks (with active subtasks included) of both projects, '
'that is only the active subtasks are duplicated.')
self.assertEqual(self.project_goats.task_count, task_count_in_project_goats, 'The number of tasks should be the same before and after the duplication of this project.')
self.assertEqual(self.project_pigs.task_count, task_count_in_project_pigs + 1, 'The project pigs should an additional task after the duplication of the project goats.')
self.assertEqual(project_goats_duplicated.tasks[0].child_ids[0].stage_id.id, self.project_goats.type_ids[0].id, 'The stage of subtasks should be copied too.')
def test_subtask_creation_with_form(self):
"""
@ -246,25 +246,388 @@ class TestProjectSubtasks(TestProjectCommon):
3) test the creation of sub-sub-tasks
4) check the correct nb of sub-tasks is displayed in the 'sub-tasks' stat button and on the parent task kanban card
5) sub-tasks should be copied when the parent task is duplicated
6) verify if there is a copy in the subtask name.
"""
test_subtask_1 = self.env['project.task'].create({
'name': 'Test Subtask 1',
})
task_form = Form(self.task_1.with_context({'tracking_disable': True}))
task_form.child_ids.add(test_subtask_1)
task_form.child_ids[0].display_project_id = self.env['project.project']
with task_form.child_ids.new() as child_task_form:
child_task_form.name = 'Test Subtask 1'
child_task_form.project_id = task_form.project_id
task = task_form.save()
child_subtask = self.task_1.child_ids[0]
test_subtask_2 = self.env['project.task'].create({
'name': 'Test Subtask 2',
with (
Form(child_subtask.with_context(tracking_disable=True)) as subtask_form,
subtask_form.child_ids.new() as child_subtask_form,
):
child_subtask_form.name = 'Test Subtask 2'
self.assertEqual(child_subtask_form.project_id, subtask_form.project_id)
self.assertFalse(child_subtask_form.display_in_project)
self.assertEqual(task.subtask_count, 1, "Parent task should have 1 children")
task_2 = task.copy()
self.assertEqual(task_2.subtask_count, 1, "If the parent task is duplicated then the sub task should be copied")
self.assertEqual(task_2.child_ids[0].name, "Test Subtask 1 (copy)", "The name of the subtask should contain the word 'copy'.")
def test_subtask_copy_display_in_project(self):
"""
Check if `display_in_project` of subtask is not set to `True` during copy
"""
project = self.env['project.project'].create({
'name': 'Project',
})
task_A, task_B = self.env['project.task'].create([
{
'name': 'Task A',
'project_id': project.id,
},
{
'name': 'Task B',
'project_id': project.id,
},
])
self.env['project.task'].create([
{
'name': 'Subtask A 1',
'parent_id': task_A.id,
'project_id': project.id,
},
{
'name': 'Subtask A 2',
'parent_id': task_A.id,
'project_id': project.id,
},
{
'name': 'Subtask B 1',
'parent_id': task_B.id,
'project_id': project.id,
},
{
'name': 'Subtask B 2',
'parent_id': task_B.id,
'project_id': project.id,
}
])
subtask_not_display_in_project = project.task_ids.child_ids.filtered(lambda t: not t.display_in_project)
self.assertEqual(len(subtask_not_display_in_project), 4, "No subtask should be displayed in the project")
project_copy = project.copy()
self.assertEqual(len(project_copy.task_ids.child_ids), 4)
subtask_not_display_in_project_copy = project_copy.task_ids.child_ids.filtered(lambda t: not t.display_in_project)
self.assertEqual(len(subtask_not_display_in_project_copy), 4, "No subtask should be displayed in the duplicate project")
def test_subtask_copy_name(self):
""" This test ensure that the name of task and project have the '(copy)' added to their name when needed.
If a project is copied, the project's name should contain the 'copy' but the project's task should keep the same name as their original.
If a task is copied (alone or in a recordset), its name as well as the name of its children should contain the 'copy'.
"""
project = self.env['project.project'].create({
'name': 'Project',
})
task_A = self.env['project.task'].create({
'name': 'Task A',
'project_id': project.id,
'child_ids': [Command.create({
'name': 'Subtask A 1',
'project_id': project.id,
'child_ids': [Command.create({
'name': 'Sub Subtask A 1',
'project_id': project.id,
})]
}), Command.create({
'name': 'Subtask A 2',
'project_id': project.id,
})]
})
project_copied = project.copy()
self.assertEqual(project_copied.name, 'Project (copy)', 'The name of the project should contains the extra (copy).')
parent_task = self.env['project.task'].search([('project_id', '=', project_copied.id), ('parent_id', '=', False)])
self.assertEqual(parent_task.name, 'Task A', 'The task is copied from project.copy(). Its name should be the same.')
self.assertEqual(parent_task.child_ids[0].name, 'Subtask A 1', 'The task is copied from project.copy(). Its name should be the same.')
self.assertEqual(parent_task.child_ids[1].name, 'Subtask A 2', 'The task is copied from project.copy(). Its name should be the same.')
self.assertEqual(parent_task.child_ids[0].child_ids.name, 'Sub Subtask A 1', 'The task is copied from project.copy(). Its name should be the same.')
copied_task = task_A.copy()
self.assertEqual(copied_task.name, 'Task A (copy)', 'The task is copied from task.copy(). Its name should contain the extra (copy).')
self.assertEqual(copied_task.child_ids[0].name, 'Subtask A 1 (copy)', 'The task is copied from task.copy(). Its name should contain the extra (copy).')
self.assertEqual(copied_task.child_ids[1].name, 'Subtask A 2 (copy)', 'The task is copied from task.copy(). Its name should contain the extra (copy).')
self.assertEqual(copied_task.child_ids[0].child_ids.name, 'Sub Subtask A 1 (copy)', 'The task is copied from task.copy(). Its name should contain the extra (copy).')
def test_subtask_unlinking(self):
task_form = Form(self.task_1.with_context({'tracking_disable': True}))
with task_form.child_ids.new() as child_task_form:
child_task_form.name = 'Test Subtask 1'
child_task_form.project_id = task_form.project_id
task_form.save()
child_subtask = self.task_1.child_ids[0]
self.task_1.unlink()
self.assertFalse(self.task_1.exists())
self.assertFalse(child_subtask.exists(), 'Subtask should be removed if the parent task has been deleted')
def test_get_all_subtasks(self):
subsubtasks = self.env['project.task'].create([{
'name': 'Subsubtask 1',
'project_id': self.project_pigs.id,
}, {
'name': 'Subsubtask 2',
'project_id': self.project_goats.id,
}, {
'name': 'Subsubtask 3',
'project_id': self.project_pigs.id,
}])
subtasks = self.env['project.task'].create([{
'name': 'Subtask 1',
'project_id': self.project_pigs.id,
'child_ids': subsubtasks[:2],
}, {
'name': 'Subtask 2',
'project_id': self.project_goats.id,
'child_ids': subsubtasks[2],
}])
task = self.env['project.task'].create({
'name': 'Task 1',
'project_id': self.project_goats.id,
'child_ids': subtasks,
})
with Form(child_subtask.with_context(tracking_disable=True)) as subtask_form:
subtask_form.child_ids.add(test_subtask_2)
subtask_form.child_ids[0].display_project_id = self.env['project.project']
all_subtasks = task._get_all_subtasks()
self.assertEqual(all_subtasks, subtasks | subsubtasks)
self.assertEqual(task.subtask_count, 2, "Parent task should have 2 children")
task_2 = task.copy()
self.assertEqual(task_2.subtask_count, 2, "If the parent task is duplicated then the sub task should be copied")
all_subtasks_by_task_id = task._get_subtask_ids_per_task_id()
self.assertEqual(len(all_subtasks_by_task_id), 1, "The result should only contain one item: the common ancestor")
for parent_id, subtask_ids in all_subtasks_by_task_id.items():
self.assertEqual(parent_id, task.id, "The key should be the common ancestor")
self.assertEqual(set(subtask_ids), set(all_subtasks.ids),
"All subtasks linked to the common ancestor should be returned by _get_subtask_ids_per_task_id method")
def test_subtask_copy_followers(self):
""" This test will check that a task will propagate its followers to its subtasks """
task_form = Form(self.task_1.with_context({'tracking_disable': True}))
with task_form.child_ids.new() as child_task_form:
child_task_form.name = 'Child Task'
child_task_form.project_id = task_form.project_id
task = task_form.save()
self.assertEqual(task.message_follower_ids.mapped('email'), task.child_ids[0].message_follower_ids.mapped('email'), "The parent and child message_follower_ids should have the same emails")
def test_toggle_active_task_with_subtasks(self):
""" This test will check archiving task should archive it's subtasks and vice versa """
parent_task = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Parent Task',
'project_id': self.project_goats.id,
'child_ids': [
Command.create({
'name': 'child 1',
'project_id': self.project_goats.id,
'child_ids': [
Command.create({
'name': 'Child 1 (Subtask 1)',
'project_id': self.project_goats.id,
}),
Command.create({
'name': 'Child 1 (Subtask 2)',
'project_id': self.project_goats.id,
'child_ids': [Command.create({
'name': 'Subsubtask',
'project_id': self.project_goats.id,
})],
}),
],
}),
Command.create({
'name': 'child 2',
'project_id': self.project_goats.id,
}),
Command.create({
'name': 'child 3',
'project_id': self.project_pigs.id,
'child_ids': [
Command.create({
'name': 'Child 3 (Subtask 1)',
'project_id': self.project_pigs.id,
}),
Command.create({
'name': 'Child 3 (Subtask 2)',
'project_id': self.project_pigs.id,
}),
],
}),
Command.create({
'name': 'child 4',
'project_id': self.project_pigs.id,
}),
],
})
child_1, child_2, child_3, child_4 = parent_task.child_ids
self.assertEqual(9, len(parent_task._get_all_subtasks()), "Should have 9 subtasks")
parent_task.action_archive()
self.assertFalse(all((parent_task + child_1._get_all_subtasks() + child_2).mapped('active')),
"Parent, `child 1` task (with its descendant tasks) and `Child 2` task should be archived")
self.assertTrue(all(child_3._get_all_subtasks().mapped('active')), "`child 3` task and its descendant tasks should be unarchived")
self.assertEqual(2, len(parent_task.child_ids), "Should have 2 direct non archived subtasks")
self.assertEqual(parent_task.child_ids, child_3 + child_4, "Should have 2 direct non archived subtasks")
self.assertEqual(4, len(parent_task._get_all_subtasks().filtered('active')), "Should have 4 non archived subtasks")
def test_display_in_project_unset_parent(self):
""" Test _onchange_parent_id when there is no parent task
"""
Task = self.env['project.task']
task = Task.create({
'name': 'Task',
'parent_id': self.task_1.id,
'project_id': self.task_1.project_id.id,
})
view = self.env.ref('project.view_task_form2')
tree = etree.fromstring(view.arch)
for node in tree.xpath('//field[@name="parent_id"][@invisible]'):
node.attrib.pop('invisible')
view.arch = etree.tostring(tree)
with Form(task) as task_form:
task_form.parent_id = Task
task._compute_display_in_project()
self.assertEqual(task.project_id, self.task_1.project_id, "project_id should be affected")
self.assertTrue(task.display_in_project, "display_in_project should be True when there is no parent task")
def test_invisible_subtask_became_visible_when_converted_to_task(self):
task = self.env['project.task'].create({
'name': 'Parent task',
'project_id': self.project_goats.id,
'child_ids': [Command.create({'name': 'Sub-task invisible', 'project_id': self.project_goats.id})],
})
invisible_subtask = task.child_ids
self.assertFalse(invisible_subtask.display_in_project)
with Form(invisible_subtask, view="project.project_task_convert_to_subtask_view_form") as subtask_form:
subtask_form.parent_id = self.env['project.task']
self.assertTrue(invisible_subtask.display_in_project)
def test_convert_tasks_to_subtask(self):
"""
Check if the parent task is linked with the subtask through the 'Convert to Subtask' wizard.
Steps:
- Open the subtask wizard
- Choose the parent task
- Check the parent and subtask
"""
with Form(self.task_1, view="project.project_task_convert_to_subtask_view_form") as subtask:
subtask.parent_id = self.task_2
self.assertTrue(self.task_2 in self.task_1.parent_id, "Task2 should have Task1 as its parent.")
self.assertTrue(self.task_1 in self.task_2.child_ids, "Task1 should have Task2 as its child.")
def test_action_convert_to_subtask_on_private_task(self):
"""
Check if a warning is triggered when the user selects a private task as a subtask.
Steps:
- Create a private task
- Perform the action convert_to_subtask
- Check the returned action result
"""
private_task = self.env['project.task'].create({
'name': 'Private task',
'project_id': False,
})
private_task_notification = private_task.action_convert_to_subtask()
self.assertDictEqual(private_task_notification, {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'danger',
'message': 'Private tasks cannot be converted into sub-tasks. Please set a project on the task to gain access to this feature.',
},
})
def test_display_in_project_is_correctly_set_when_parent_task_changes(self):
task = self.env['project.task'].create({
'name': 'Parent task',
'project_id': self.project_goats.id,
'child_ids': [
Command.create({'name': 'Sub-task 1', 'project_id': self.project_goats.id}),
Command.create({'name': 'Sub-task 1', 'project_id': self.project_pigs.id}),
],
})
subtask_1, subtask_2 = task.child_ids
self.assertFalse(subtask_1.display_in_project)
self.assertTrue(subtask_2.display_in_project)
form_view = self.env.ref("project.project_task_convert_to_subtask_view_form")
with Form(subtask_1, view=form_view) as subtask_form:
subtask_form.parent_id = self.env['project.task']
self.assertTrue(subtask_1.display_in_project)
with Form(subtask_1, view=form_view) as subtask_form:
subtask_form.parent_id = task
self.assertFalse(subtask_1.display_in_project)
with Form(subtask_2, view=form_view) as subtask_form:
subtask_form.parent_id = self.env['project.task']
self.assertTrue(subtask_2.display_in_project)
with Form(subtask_2, view=form_view) as subtask_form:
subtask_form.parent_id = task
self.assertTrue(subtask_2.display_in_project)
def test_subtask_private_project_and_parent_task(self):
"""
Test that an assigned employee to a subtask can open it even when
they don't have access to the parent task or project.
Test Case:
==========
1) Create a private project with a parent task and a subtask.
2) assign an employee to the subtask.
3) Ensure the employee can access the subtask even if they don't have
access to the parent task or project.
"""
private_project = self.env['project.project'].create({
'name': 'Private Project',
'privacy_visibility': 'followers',
})
task = self.env['project.task'].create({
'name': 'Parent Task',
'project_id': private_project.id,
})
employee = self.env['res.users'].create({
'name': 'Employee',
'login': 'employee',
'email': 'employee@odoo.com',
'group_ids': [(6, 0, [self.env.ref('project.group_project_user').id])],
})
subtask = self.env['project.task'].create({
'name': 'Subtask',
'parent_id': task.id,
'project_id': private_project.id,
'user_ids': [(4, employee.id)],
})
# Ensure the employee can read subtask fields that depends on the parent task
parent_dependent_fields = [
name for name, field in self.env['project.task']._fields.items()
if field.compute and any(dep.startswith('parent_id') for dep in field.get_depends(self.env['project.task'])[0])
]
self.env.invalidate_all()
subtask_data = subtask.with_user(employee).read(parent_dependent_fields)
self.assertTrue(subtask_data, "The employee should be able to read the subtask data.")
def test_subtasks_inherits_tags_of_parent(self):
task = self.env['project.task'].create({
'name': 'Parent task',
'project_id': self.project_goats.id,
'tag_ids': [Command.create({'name': 'tag1', 'color': 0}), Command.create({'name': 'tag2', 'color': 1})],
})
subtask1 = self.env['project.task'].with_context({'default_parent_id': task.id}).create({
'name': 'Sub-task 1',
'project_id': self.project_goats.id,
})
self.assertEqual(subtask1.tag_ids, task.tag_ids, "Subtask should inherit tags from parent task")

View file

@ -55,4 +55,4 @@ class TestProjectTags(HttpCase, TestProjectCommon):
])
def test_01_project_tags(self):
self.start_tour("/web", 'project_tags_filter_tour', login="admin")
self.start_tour("/odoo", 'project_tags_filter_tour', login="admin")

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common_tracking import MailTrackingDurationMixinCase
from odoo.tests import Form, tagged
@tagged('is_query_count')
class TestProjectTaskMailTrackingDuration(MailTrackingDurationMixinCase):
@classmethod
def setUpClass(cls):
super().setUpClass('project.task', {'project_id': 'create'})
def test_project_task_mail_tracking_duration(self):
self._test_record_duration_tracking()
def test_project_task_mail_tracking_duration_batch(self):
self._test_record_duration_tracking_batch()
def test_project_task_queries_batch_mail_tracking_duration(self):
self._test_queries_batch_duration_tracking()
def test_task_mail_tracking_duration_during_onchange_stage(self):
"""
Checks that the status bar duration is correctly set during an onchange of its stage_id.
"""
task = self.rec_1
task.stage_id = self.stage_1
initial_tracking = task.duration_tracking
with Form(task) as task_form:
task_form.stage_id = self.stage_2
final_tracking = task.duration_tracking
self.assertEqual(initial_tracking[str(self.stage_1.id)], final_tracking[str(self.stage_1.id)])
self.assertEqual(final_tracking[str(self.stage_2.id)], 0)

View file

@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.project.tests.test_project_base import TestProjectCommon
from odoo.tests import Form
class TestProjectTaskQuickCreate(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user1, cls.user2 = cls.env['res.users'].with_context({'no_reset_password': True}).create([{
'name': 'Raouf 1',
'login': 'raouf1',
'password': 'raouf1aa',
'email': 'raouf1@example.com',
}, {
'name': 'Raouf 2',
'login': 'raouf2',
'password': 'raouf2aa',
'email': 'raouf2@example.com',
}])
def test_create_task_with_valid_expressions(self):
# dict format = {display name: (expected name, expected tags count, expected users count, expected priority, expected planned hours)}
valid_expressions = {
'task A 30H 2.5h #Tag1 #tag2 @Armande @Bast @raouf1 @raouf2 !': ('task A 30H 2.5h', 2, 4, "1", 0),
'task A 30H 2.5h #Tag1 #tag2 #tag3 @Armande @Bast @raouf1 ! @raouf2': ('task A 30H 2.5h', 3, 4, "1", 0),
'task A ! 30H 2.5h #Tag1 #tag2 #tag3 @Armande @Bast ! @raouf1 #tag4': ('task A 30H 2.5h', 4, 3, "1", 0),
'task A': ('task A', 0, 0, "0", 0),
'task A !': ('task A', 0, 0, "1", 0),
'task A 30H 2.5h #Tag1 #tag2 #tag3 @Armande @Bast @raouf1 @raouf2': ('task A 30H 2.5h', 3, 4, "0", 0),
'task A 30H 2.5h #Tag1 @Armande #tag3 @Bast @raouf1 #tag2 @raouf2 #tag4': ('task A 30H 2.5h', 4, 4, "0", 0),
'task A 30H #tag1 @raouf1 Nothing !': ('task A 30H #tag1 @raouf1 Nothing', 0, 0, '1', 0),
'task A 30H 2.5h #Tag1 #tag2 #tag3 @Armande @Bast @raouf !': ('task A 30H 2.5h @raouf', 3, 2, "1", 0),
'task A 30H 2.5h #Tag1 #tag2 #tag3 @Armande @Bastttt @raouf1 @raouf2 !': ('task A 30H 2.5h @Bastttt', 3, 3, "1", 0),
'task A 30H 2.5h #TAG1 #tag1 #TAG2': ('task A 30H 2.5h', 2, 0, "0", 0),
'task A 30H 2.5h #Tag1 #tag2 @Armande @Bast @raouf1 @raouf2 !!': ('task A 30H 2.5h', 2, 4, "2", 0),
'task A !!': ('task A', 0, 0, "2", 0),
'task A 30H 2.5h #Tag1 #tag2 #tag3 @Armande @Bastttt @raouf1 @raouf2 !!': ('task A 30H 2.5h @Bastttt', 3, 3, "2", 0),
'task A 30H 2.5h #Tag1 #tag2 @Armande @Bast @raouf1 @raouf2 !!!': ('task A 30H 2.5h', 2, 4, "3", 0),
'task A !!!': ('task A', 0, 0, "3", 0),
'task A 30H 2.5h #Tag1 #tag2 #tag3 @Armande @Bastttt @raouf1 @raouf2 !!!': ('task A 30H 2.5h @Bastttt', 3, 3, "3", 0),
}
for expression, values in valid_expressions.items():
task_form = Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_pigs.id}), view="project.quick_create_task_form")
task_form.display_name = expression
task = task_form.save()
results = (task.name, len(task.tag_ids), len(task.user_ids), task.priority, task.allocated_hours)
self.assertEqual(results, values)
def test_create_task_with_invalid_expressions(self):
invalid_expressions = (
'#tag1 #tag2 #tag3 @Armande @Bast @raouf1 @raouf2',
'@Armande @Bast @raouf1 @raouf2',
'!',
'task A!',
'task A!!',
'task A!!!',
'!!',
'!!!',
'!! @Armande',
'!!! #tag1',
)
for expression in invalid_expressions:
task_form = Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_pigs.id}), view="project.quick_create_task_form")
task_form.display_name = expression
task = task_form.save()
results = (task.name, len(task.tag_ids), len(task.user_ids), task.priority, task.allocated_hours)
self.assertEqual(results, (expression, 0, 0, '0', 0))
def test_set_stage_on_project_from_task(self):
new_stage = self.env['project.task.type'].create({
'name': 'New Stage',
})
self.env['project.task'].create({
'name': 'Test Task',
'stage_id': new_stage.id,
'project_id': self.project_pigs.id,
})
self.assertEqual(self.project_pigs.type_ids, new_stage, "Task stage is not set in project")
def test_create_task_with_default_value(self):
self.project_pigs.write({
'company_id': self.env.company,
})
project_ids = self.env['project.project'].search([]).ids
self.env['ir.default'].discard_values('project.task', 'project_id', project_ids)
self.env['ir.default'].set('project.task', 'project_id', self.project_pigs.id)
field_specs = {'project_id': {}, 'company_id': {'fields': {}}}
task_values = self.env['project.task'].onchange({}, [], field_specs)['value']
self.assertEqual(task_values['project_id'], self.project_pigs.id, "The task project_id should be set")

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.exceptions import UserError
from odoo.addons.project.tests.test_project_base import TestProjectCommon
@ -14,137 +13,69 @@ class TestProjectTaskType(TestProjectCommon):
cls.stage_created = cls.env['project.task.type'].create({
'name': 'Stage Already Created',
'project_ids': cls.project_goats.ids,
})
def test_create_stage(self):
'''
Verify that it is not possible to add to a newly created stage a `user_id` and a `project_ids`
Verify that 'user_id' is removed when a stage is created with `project_ids` set or set by default to the curent user if not
'''
with self.assertRaises(UserError):
self.env['project.task.type'].create({
self.assertFalse(self.env['project.task.type'].create({
'name': 'New Stage',
'user_id': self.uid,
'project_ids': [self.project_goats.id],
})
}).user_id,
"user_id should be reset if a project is set on the current stage",
)
self.assertEqual(self.env['project.task.type'].create({
'name': 'Other new Stage',
}).user_id.id,
self.env.uid,
"user_id should be set to the current user if no project is set at stage creation",
)
def test_modify_existing_stage(self):
'''
- case 1: [`user_id`: not set, `project_ids`: not set] | Add `user_id` and `project_ids` => UserError
- case 2: [`user_id`: set, `project_ids`: not set] | Add `project_ids` => UserError
- case 1: [`user_id`: not set, `project_ids`: set] | Remove `project_ids` => user_id should not be set (no transformation of project stage to personal stage)
- case 2: [`user_id`: not set, `project_ids`: not set] | Add `user_id` and `project_ids` => user_id reset
- case 3: [`user_id`: not set, `project_ids`: set] | Add `user_id` => UserError
- case 4: [`user_id`: set, `project_ids`: not set] | Add `project_ids` => user_id reset
'''
# case 1
with self.assertRaises(UserError):
self.stage_created.write({
'user_id': self.uid,
'project_ids': [self.project_goats.id],
})
self.assertTrue(not self.stage_created.user_id and self.stage_created.project_ids)
self.stage_created.write({'project_ids': False})
self.assertFalse(
self.stage_created.user_id,
"When project_ids is reset, user_id should not be set (no transformation of project related stage to personal stage)",
)
# case 2
self.assertTrue(not self.stage_created.user_id and not self.stage_created.project_ids)
self.stage_created.write({
'user_id': self.uid,
})
with self.assertRaises(UserError):
self.stage_created.write({
'project_ids': [self.project_goats.id],
})
# case 3
self.stage_created.write({
'user_id': False,
'project_ids': [self.project_goats.id],
})
self.assertFalse(
self.stage_created.user_id,
"user_id should be reset if a project is set on the current stage",
)
# case 3
with self.assertRaises(UserError):
self.stage_created.write({
'user_id': self.uid,
})
def test_group_by_personal_stage(self):
"""
Check the consistence of search_read and read_group when one groups project.tasks by personal stages.
Supose we have a user and his manager. Group all tasks by personal stage in the "list view".
A `web_read_group` is performed to classify the tasks and a `web_search_read` is performed to display the lines.
We check the consitency of both operations for tasks that are not linked to a personal stage of the current user.
"""
if 'hr.employee' not in self.env:
self.skipTest("This test requires to set a manager")
project = self.project_goats
user = self.user_projectmanager
manager_user = self.env['res.users'].create({
'name': 'Roger Employee',
'login': 'Roger',
'email': 'rog.projectmanager@example.com',
'groups_id': [Command.set([self.ref('base.group_user'), self.ref('project.group_project_manager')])],
# case 4
self.stage_created.write({
'user_id': self.env.uid,
'project_ids': False,
})
manager = self.env['hr.employee'].create({
'user_id': manager_user.id,
'image_1920': False,
self.assertTrue(self.stage_created.user_id)
self.stage_created.write({
'project_ids': [self.project_goats.id],
})
(user | manager_user).employee_id.write({'parent_id': manager.id})
user_personal_stages = self.env['project.task.type'].search([('user_id', '=', user.id)])
# we create tasks for the user with different types of assignement
self.env['project.task'].with_user(user).create([
{
'name': f"Task: {stage.id}",
'project_id': project.id,
'personal_stage_type_id': stage.id,
'user_ids': [Command.link(user.id)],
}
for stage in user_personal_stages],
self.assertFalse(
self.stage_created.user_id,
"user_id should be reset if a project is set on the current stage",
)
self.env['project.task'].with_user(user).create([
{
'name': f"Task: {stage.id}",
'project_id': project.id,
'personal_stage_type_id': stage.id,
'user_ids': [Command.link(user.id), Command.link(manager_user.id)],
}
for stage in user_personal_stages],
)
# this task is created to create the default personal stages of manager user
self.env['project.task'].with_user(manager_user).create({
'name': "Manager's task",
'project_id': project.id,
'user_ids': [Command.link(manager_user.id)],
})
manager_user_personal_stages = self.env['project.task.type'].search([('user_id', '=', manager_user.id)])
self.env['project.task'].with_user(manager_user).create([
{
'name': f"Task : {stage.id}",
'project_id': project.id,
'stage_id': stage.id,
'user_ids': [Command.link(manager_user.id)],
}
for stage in manager_user_personal_stages],
)
self.env.uid = user.id
base_domain = [("user_ids.employee_parent_id.user_id", "=", manager_user.id)]
tasks = self.env['project.task'].with_user(user.id).search(base_domain)
tasks_with_personal_stage = tasks.filtered(lambda t: user in t.personal_stage_type_id.user_id)
tasks_without_personal_stage = tasks - tasks_with_personal_stage
fields = [
"id",
"name",
"project_id",
"milestone_id",
"partner_id",
"user_ids",
"activity_ids",
"stage_id",
"personal_stage_type_ids",
"tag_ids",
"priority",
"company_id",
]
groupby = ["personal_stage_type_ids"]
user_read_group = self.env['project.task'].with_user(user).read_group(domain=base_domain, fields=fields, groupby=groupby)
number_of_tasks_in_groups = sum(gr['personal_stage_type_ids_count'] if gr['personal_stage_type_ids'] and gr['personal_stage_type_ids'][0] in user_personal_stages.ids else 0 for gr in user_read_group)
self.assertEqual(len(tasks_with_personal_stage), number_of_tasks_in_groups)
tasks_found_for_user = [task['id'] for task in self.env['project.task'].with_user(user.id).search_read(domain=base_domain, fields=fields)]
self.assertEqual(tasks.ids, tasks_found_for_user)
domain = ["&", ("personal_stage_type_ids", "=", False), ("user_ids.employee_parent_id.user_id", "=", manager_user.id)]
tasks_diplayed_without_personal_stage = [task['id'] for task in self.env['project.task'].with_user(user.id).search_read(domain=domain, fields=fields)]
self.assertEqual(tasks_without_personal_stage.ids, tasks_diplayed_without_personal_stage)

View file

@ -0,0 +1,239 @@
from datetime import date
from odoo import Command
from odoo.tests import freeze_time
from odoo.addons.project.tests.test_project_base import TestProjectCommon
class TestProjectTemplates(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.project_template = cls.env["project.project"].create({
"name": "Project Template",
"is_template": True,
"date_start": date(2025, 6, 1),
"date": date(2025, 6, 11),
})
cls.task_inside_template, cls.task_template_inside_template = cls.env["project.task"].create([{
"name": "A Task in Project Template",
"project_id": cls.project_template.id,
}, {
"name": "B Task Template in Project Template",
"project_id": cls.project_template.id,
"is_template": True,
}])
def test_convert_project_to_template(self):
"""
Converting a project to a project template should leave the tasks unchanged.
"""
self.project_template.is_template = False
client_action = self.project_template.action_create_template_from_project()
created_template = self.env["project.project"].browse(client_action["params"]["project_id"])
task_a, task_b = created_template.task_ids.sorted("name")
self.assertEqual(task_a.name, self.task_inside_template.name, "The copied task should have the same name as the original.")
self.assertFalse(task_a.is_template, "The copied tasks should retain their template status.")
self.assertEqual(task_b.name, self.task_template_inside_template.name, "The copied task should have the same name as the original.")
self.assertTrue(task_b.is_template, "The copied tasks should retain their template status.")
def test_create_from_template(self):
"""
Creating a project through the action should result in a non template copy
"""
project = self.project_template.action_create_from_template()
self.assertEqual(project.name, "Project Template", "The project name should be `Project Template`.")
self.assertFalse(project.is_template, "The created Project should be a normal project and not a template.")
self.assertFalse(project.partner_id, "The created Project should not have a customer.")
self.assertEqual(len(project.task_ids), 2, "The tasks of the template should be copied too.")
task_a, task_b = project.task_ids.sorted("name")
self.assertEqual(task_a.name, self.task_inside_template.name, "The copied task should have the same name as the original.")
self.assertFalse(task_a.is_template, "The copied tasks should retain their template status.")
self.assertEqual(task_b.name, self.task_template_inside_template.name, "The copied task should have the same name as the original.")
self.assertTrue(task_b.is_template, "The copied tasks should retain their template status.")
def test_copy_template(self):
"""
A copy of a template should be a template
"""
copied_template = self.project_template.copy()
self.assertEqual(copied_template.name, "Project Template (copy)", "The project name should be `Project Template` (copy).")
self.assertTrue(copied_template.is_template, "The copy of the template should also be a template.")
self.assertEqual(len(copied_template.task_ids), 2, "The child of the template should be copied too.")
task_a, task_b = copied_template.task_ids.sorted("name")
self.assertEqual(task_a.name, self.task_inside_template.name, "The copied task should have the same name as the original.")
self.assertFalse(task_a.is_template, "The copied tasks should retain their template status.")
self.assertEqual(task_b.name, self.task_template_inside_template.name, "The copied task should have the same name as the original.")
self.assertTrue(task_b.is_template, "The copied tasks should retain their template status.")
def test_revert_template(self):
"""
A revert of a template should not be a template
"""
self.project_template.action_undo_convert_to_template()
self.assertFalse(self.project_template.is_template, "The reverted template should become a normal template.")
def test_tasks_dispatching_from_template(self):
"""
The tasks of a project template should be dispatched to the new project according to the role-to-users mapping defined
on the project template wizard.
"""
role1, role2, role3, role4, role5 = self.env['project.role'].create([
{'name': 'Developer'},
{'name': 'Designer'},
{'name': 'Project Manager'},
{'name': 'Tester'},
{'name': 'Product Owner'},
])
project_template = self.env['project.project'].create({
'name': 'Project template',
'is_template': True,
'task_ids': [
Command.create({
'name': 'Task 1',
'role_ids': [role1.id, role3.id],
}),
Command.create({
'name': 'Task 2',
'role_ids': [role5.id, role4.id],
}),
Command.create({
'name': 'Task 3',
'role_ids': [role2.id, role5.id],
'child_ids': [Command.create({
'name': 'Sub Task 1',
}), Command.create({
'name': 'Sub Task 2',
})],
}),
Command.create({
'name': 'Task 4',
'role_ids': [role3.id],
}),
Command.create({
'name': 'Task 5',
'role_ids': [role5.id],
}),
Command.create({
'name': 'Task 6',
}),
Command.create({
'name': 'Task 7',
'role_ids': [role2.id, role3.id],
'user_ids': [self.user_projectuser.id, self.user_projectmanager.id],
}),
],
})
user1, user2 = self.env['res.users'].create([
{
'name': 'Test User 1',
'login': 'test1',
'password': 'testuser1',
'email': 'test1.test@example.com',
},
{
'name': 'Test User 2',
'login': 'test2',
'password': 'testuser2',
'email': 'test2.test@example.com',
}
])
wizard = self.env['project.template.create.wizard'].create({
'template_id': project_template.id,
'name': 'New Project from Template',
'role_to_users_ids': [
Command.create({
'role_id': role1.id,
'user_ids': [self.user_projectuser.id, self.user_projectmanager.id],
}),
Command.create({
'role_id': role2.id,
'user_ids': [user1.id],
}),
Command.create({
'role_id': role3.id,
'user_ids': [user2.id],
}),
Command.create({
'role_id': role4.id,
'user_ids': [self.user_projectuser.id],
}),
],
})
new_project = wizard._create_project_from_template()
self.assertEqual(
new_project.task_ids.filtered(lambda t: t.name == 'Task 1').user_ids,
self.user_projectuser + self.user_projectmanager + user2,
'Task 1 should be assigned to the users mapped to `role1` and `role3`.',
)
self.assertEqual(
new_project.task_ids.filtered(lambda t: t.name == 'Task 2').user_ids,
self.user_projectuser,
'Task 2 should be assigned to the users mapped to `role4`. As `role5` is not in the mapping.',
)
self.assertEqual(
new_project.task_ids.filtered(lambda t: t.name == 'Task 3').user_ids,
user1,
'Task 3 should be assigned to the users mapped to `role2`. As `role5` is not in the mapping.',
)
self.assertEqual(
new_project.task_ids.filtered(lambda t: t.name == 'Task 4').user_ids,
user2,
'Task 4 should be assigned to the users mapped to `role3`.'
)
self.assertFalse(
new_project.task_ids.filtered(lambda t: t.name == 'Task 5').user_ids,
'Task 5 should not be assigned to any user as `role5` is not in the mapping.',
)
self.assertFalse(
new_project.task_ids.filtered(lambda t: t.name == 'Task 6').user_ids,
'Task 6 should not be assigned to any user as it has no role.',
)
self.assertEqual(
new_project.task_ids.filtered(lambda t: t.name == 'Task 7').user_ids,
self.user_projectuser + self.user_projectmanager + user1 + user2,
'Task 7 should be assigned to the users mapped to `role2` and `role3`, plus the users who were already assigned to the task.'
)
@freeze_time("2025-06-15")
def test_create_from_template_no_dates(self):
today = date.today()
new_project = self.project_template.action_create_from_template({
"name": "New Project",
"is_template": False,
})
expected_delta = self.project_template.date - self.project_template.date_start
self.assertEqual(new_project.date_start, today, "Start date should default to today")
self.assertEqual(new_project.date, today + expected_delta, "End date should be start + template delta")
def test_create_from_template_start_only(self):
new_start = date(2025, 7, 1)
new_project = self.project_template.action_create_from_template({
"name": "New Project",
"is_template": False,
"date_start": new_start,
})
expected_delta = self.project_template.date - self.project_template.date_start
self.assertEqual(new_project.date_start, new_start, "Start date should be the one provided")
self.assertEqual(new_project.date, new_start + expected_delta, "End date should be start + template delta")
def test_create_from_template_with_dates(self):
new_start = date(2025, 7, 1)
new_end = date(2025, 7, 15)
new_project = self.project_template.action_create_from_template({
"name": "New Project",
"is_template": False,
"date_start": new_start,
"date": new_end,
})
self.assertEqual(new_project.date_start, new_start, "Start date should be the one provided")
self.assertEqual(new_project.date, new_end, "End date should be the one provided")

View file

@ -0,0 +1,23 @@
from odoo.tests import HttpCase, tagged
@tagged("post_install", "-at_install")
class TestProjectTemplatesTour(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.project_template = cls.env["project.project"].create({
"name": "Project Template",
"is_template": True,
})
cls.task_inside_template = cls.env["project.task"].create({
"name": "Task in Project Template",
"project_id": cls.project_template.id,
})
def test_project_templates_tour(self):
user_admin = self.env.ref('base.user_admin')
user_admin.write({
'email': 'mitchell.admin@example.com',
})
self.start_tour("/odoo", "project_templates_tour", login="admin")

View file

@ -9,7 +9,42 @@ class TestUi(odoo.tests.HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env['res.config.settings'].create({'group_project_milestone': True}).execute()
cls.env.ref('base.group_user').sudo().implied_ids |= cls.env.ref('project.group_project_milestone')
def test_01_project_tour(self):
self.start_tour("/web", 'project_tour', login="admin")
self.start_tour("/odoo", 'project_tour', login="admin")
def test_project_task_history(self):
"""This tour will check that the history works properly."""
stage = self.env['project.task.type'].create({'name': 'To Do'})
_dummy, project2 = self.env['project.project'].create([{
'name': 'Without tasks project',
'type_ids': stage.ids,
}, {
'name': 'Test History Project',
'type_ids': stage.ids,
}])
self.env['project.task'].create({
'name': 'Test History Task',
'stage_id': stage.id,
'project_id': project2.id,
})
self.start_tour('/odoo?debug=1', 'project_task_history_tour', login='admin')
def test_project_task_last_history_steps(self):
"""This tour will check that the history works properly."""
stage = self.env['project.task.type'].create({'name': 'To Do'})
project = self.env['project.project'].create([{
'name': 'Test History Project',
'type_ids': stage.ids,
}])
self.env['project.task'].create({
'name': 'Test History Task',
'stage_id': stage.id,
'project_id': project.id,
})
self.start_tour('/odoo', 'project_task_last_history_steps_tour', login='admin')

View file

@ -3,8 +3,7 @@
from dateutil.relativedelta import relativedelta
from odoo import fields
from odoo.tests import tagged
from odoo.tests.common import Form
from odoo.tests import Form, tagged
from odoo.addons.project.tests.test_project_base import TestProjectCommon
@ -13,9 +12,8 @@ class TestProjectUpdate(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env['res.config.settings'] \
.create({'group_project_milestone': True}) \
.execute()
cls.env.user.group_ids |= cls.env.ref('project.group_project_milestone')
cls.project_pigs.allow_milestones = True
def test_project_update_form(self):
with Form(self.env['project.milestone'].with_context({'default_project_id': self.project_pigs.id})) as milestone_form:
@ -53,7 +51,7 @@ class TestProjectUpdate(TestProjectCommon):
template_values = self.env['project.update']._get_template_values(self.project_pigs)
self.assertTrue(template_values['milestones']['show_section'], 'The milestone section should not be visible since the feature is disabled')
self.assertTrue(template_values['milestones']['show_section'], 'The milestone section should be visible since the feature is enabled')
self.assertEqual(len(template_values['milestones']['list']), 2, "Milestone list length should be equal to 2")
self.assertEqual(len(template_values['milestones']['created']), 3, "Milestone created length tasks should be equal to 3")
@ -65,17 +63,6 @@ class TestProjectUpdate(TestProjectCommon):
self.assertEqual(len(template_values['milestones']['list']), 0, "Milestone list length should be equal to 0 because the Milestones feature is disabled.")
self.assertEqual(len(template_values['milestones']['created']), 0, "Milestone created length tasks should be equal to 0 because the Milestones feature is disabled.")
self.project_pigs.write({'allow_milestones': True})
self.env['res.config.settings'] \
.create({'group_project_milestone': False}) \
.execute()
template_values = self.env['project.update']._get_template_values(self.project_pigs)
self.assertFalse(template_values['milestones']['show_section'], 'The milestone section should not be visible since the feature is disabled')
self.assertEqual(len(template_values['milestones']['list']), 0, "Milestone list length should be equal to 0 because the Milestones feature is disabled.")
self.assertEqual(len(template_values['milestones']['created']), 0, "Milestone created length tasks should be equal to 0 because the Milestones feature is disabled.")
def test_project_update_panel(self):
with Form(self.env['project.milestone'].with_context({'default_project_id': self.project_pigs.id})) as milestone_form:
milestone_form.name = "Test 1"
@ -106,9 +93,34 @@ class TestProjectUpdate(TestProjectCommon):
self.assertNotIn('milestones', panel_data, 'Since the "Milestones" feature is disabled in this project, the "Milestones" section is not loaded.')
# Disable globally the Milestones feature and check the Milestones section is not loaded.
self.project_pigs.write({'allow_milestones': True})
self.env['res.config.settings'] \
.create({'group_project_milestone': False}) \
.execute()
self.env.user.group_ids -= self.env.ref('project.group_project_milestone')
panel_data = self.project_pigs.get_panel_data()
self.assertNotIn('milestones', panel_data, 'Since the "Milestones" feature is globally disabled, the "Milestones" section is not loaded.')
def test_project_update_reflects_task_changes(self):
"""
Check if the project update reflects according to the task changes or not.
Steps:
1) Create a project update
2) Check the task count, closetask, and closed task percentag
3) Move Task1 to the Done stage
4) Repeat steps 1 and 2
5) Move Task2 to the Canceled stage
6) Create a new task
7) Repeat steps 1 and 2
"""
def create_project_update_view():
update_form = Form(self.env['project.update'].with_context({'default_project_id': self.project_pigs.id}))
update_form.name = "Test"
project_update = update_form.save()
return [project_update.task_count, project_update.closed_task_count, project_update.closed_task_percentage]
project_update_data_list = create_project_update_view()
self.assertListEqual(project_update_data_list, [self.project_pigs.task_count, 0, 0])
self.task_1.state = '1_done'
project_update_data_list = create_project_update_view()
self.assertListEqual(project_update_data_list, [self.project_pigs.task_count, 1, 50])
self.task_2.state = '1_canceled'
self.task_2.copy()
project_update_data_list = create_project_update_view()
self.assertListEqual(project_update_data_list, [self.project_pigs.task_count, 2, 67])

View file

@ -4,14 +4,14 @@ from odoo.tests import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestProjectUpdateUi(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Enable the "Milestones" feature to be able to create milestones on this tour.
cls.env['res.config.settings'] \
.create({'group_project_milestone': True}) \
.execute()
def test_01_project_tour(self):
self.start_tour("/web", 'project_update_tour', login="admin")
# Enable milestones to avoid a different behavior when running the tour with or without demo data.
# Indeed, when we check Milestones on the Settings tab of a newly created project,
# we ensure milestones are globally enabled. If the feature was disabled, it causes a full page reload.
# The tour step should then have a expectUnloadPage depending on whether milestones are already enabled.
# As it is too complicated to determine this value from the tour itself, we avoid this page reload completely.
self.env.ref('base.group_user').implied_ids |= self.env.ref('project.group_project_milestone')
self.start_tour("/odoo", 'project_update_tour', login="admin")
self.start_tour("/odoo", 'project_tour', login="admin")

View file

@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged, Form, TransactionCase
@tagged('post_install', '-at_install')
class TestResConfigSettings(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.analytic_plan_1, cls.analytic_plan_2 = cls.env['account.analytic.plan'].create([
{
'name': 'Plan 1',
'default_applicability': 'optional',
'company_id': False,
}, {
'name': 'Plan 2',
'default_applicability': 'optional',
'company_id': False,
},
])
cls.company_1, cls.company_2 = cls.env['res.company'].create([
{'name': 'Test Company 1'},
{'name': 'Test Company 2'},
])
(cls.analytic_plan_1 + cls.analytic_plan_2).write({
'company_id': cls.company_1.id,
})
def test_set_default_analytic_plan(self):
"""
Test that we can set the default analytic plan in the settings per company.
When there are no analytic plans for the company, a new one named "Default" should be created.
"""
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
settings_company_1 = self.env['res.config.settings'].with_company(self.company_1).create({})
with Form(settings_company_1) as form:
self.assertEqual(form.analytic_plan_id, self.analytic_plan_1)
form.analytic_plan_id = self.analytic_plan_2
form.save()
self.assertEqual(settings_company_1.analytic_plan_id, self.analytic_plan_2)
settings_company_2 = self.env['res.config.settings'].with_company(self.company_2).create({})
with Form(settings_company_2) as form:
self.assertNotEqual(form.analytic_plan_id, self.analytic_plan_1)
self.assertNotEqual(form.analytic_plan_id, self.analytic_plan_2)
self.assertEqual(form.analytic_plan_id.name, "Default")

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from odoo.fields import Command
from odoo.fields import Command, Datetime
from odoo.exceptions import ValidationError
from odoo.tests import tagged
@ -15,7 +15,7 @@ class TestTaskDependencies(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.user.group_ids |= cls.env.ref('project.group_project_task_dependencies')
cls.project_pigs.write({
'allow_task_dependencies': True,
})
@ -80,89 +80,37 @@ class TestTaskDependencies(TestProjectCommon):
self.assertEqual(len(self.task_2.depend_on_ids), 1, "The task 2 should have one dependency.")
# 4) Add task1 as dependency in task3 and check a validation error is raised
with self.assertRaises(ValidationError), self.cr.savepoint():
with self.assertRaises(ValidationError):
self.task_3.write({
'depend_on_ids': [Command.link(self.task_1.id)],
})
self.assertEqual(len(self.task_3.depend_on_ids), 0, "The dependency should not be added in the task 3 because of a cyclic dependency.")
# 5) Add task1 as dependency in task2 and check a validation error is raised
with self.assertRaises(ValidationError), self.cr.savepoint():
with self.assertRaises(ValidationError):
self.task_2.write({
'depend_on_ids': [Command.link(self.task_1.id)],
})
self.assertEqual(len(self.task_2.depend_on_ids), 1, "The number of dependencies should no change in the task 2 because of a cyclic dependency.")
def test_tracking_dependencies(self):
# Enable the company setting
self.env['res.config.settings'].create({
'group_project_task_dependencies': True
}).execute()
# `depend_on_ids` is tracked
self.task_1.with_context(mail_notrack=True).write({
'depend_on_ids': [Command.link(self.task_2.id)]
})
self.cr.precommit.clear()
# Check that changing a dependency tracked field in task_2 logs a message in task_1.
self.task_2.write({'date_deadline': date(1983, 3, 1)}) # + 1 message in task_1 and task_2
self.flush_tracking()
self.assertEqual(len(self.task_1.message_ids), 1,
'Changing the deadline on task 2 should have logged a message in task 1.')
# Check that changing a dependency tracked field in task_1 does not log a message in task_2.
self.task_1.date_deadline = date(2020, 1, 2) # + 1 message in task_1
self.flush_tracking()
self.assertEqual(len(self.task_2.message_ids), 1,
'Changing the deadline on task 1 should not have logged a message in task 2.')
# Check that changing a field that is not tracked at all on task 2 does not impact task 1.
self.task_2.color = 100 # no new message
self.flush_tracking()
self.assertEqual(len(self.task_1.message_ids), 2,
'Changing the color on task 2 should not have logged a message in task 1 since it is not tracked.')
# Check that changing multiple fields does not log more than one message.
self.task_2.write({
'date_deadline': date(2020, 1, 1),
'kanban_state': 'blocked',
}) # + 1 message in task_1 and task_2
self.flush_tracking()
self.assertEqual(len(self.task_1.message_ids), 3,
'Changing multiple fields on task 2 should only log one message in task 1.')
def test_task_dependencies_settings_change(self):
def set_task_dependencies_setting(enabled):
features_config = self.env["res.config.settings"].create({'group_project_task_dependencies': enabled})
features_config.execute()
self.project_pigs.write({
'allow_task_dependencies': False,
})
# As the Project General Setting group_project_task_dependencies needs to be toggled in order
# to be applied on the existing projects we need to force it so that it does not depends on anything
# (like demo data for instance)
set_task_dependencies_setting(False)
set_task_dependencies_setting(True)
self.assertTrue(self.project_pigs.allow_task_dependencies, "Projects allow_task_dependencies should follow group_project_task_dependencies setting changes")
# set group_project_task_dependencies(True)
self.project_chickens = self.env['project.project'].create({
'name': 'My Chicken Project'
})
self.assertTrue(self.project_chickens.allow_task_dependencies, "New Projects allow_task_dependencies should default to group_project_task_dependencies")
set_task_dependencies_setting(False)
self.assertFalse(self.project_pigs.allow_task_dependencies, "Projects allow_task_dependencies should follow group_project_task_dependencies setting changes")
self.assertFalse(self.project_chickens.allow_task_dependencies, "New Projects allow_task_dependencies should default to False")
# set group_project_task_dependencies(False)
self.env.user.group_ids -= self.env.ref('project.group_project_task_dependencies')
self.project_ducks = self.env['project.project'].create({
'name': 'My Ducks Project'
})
self.assertFalse(self.project_ducks.allow_task_dependencies, "New Projects allow_task_dependencies should default to group_project_task_dependencies")
self.assertFalse(self.project_ducks.allow_task_dependencies, "New Projects allow_task_dependencies should still default to False")
def test_duplicate_project_with_task_dependencies(self):
self.project_pigs.allow_task_dependencies = True
self.task_1.depend_on_ids = self.task_2
self.task_1.date_deadline = Datetime.now()
pigs_copy = self.project_pigs.copy()
task1_copy = pigs_copy.task_ids.filtered(lambda t: t.name == 'Pigs UserTask')
@ -182,16 +130,21 @@ class TestTaskDependencies(TestProjectCommon):
self.assertEqual(task1_copy_copy.depend_on_ids.ids, [self.task_1.id],
"Copy should not alter the relation if the other task is in a different project")
self.project_pigs.allow_task_dependencies = False
project_pigs_no_dep = self.project_pigs.copy()
self.assertFalse(project_pigs_no_dep.allow_task_dependencies, 'The copied project should have the dependencies feature disabled')
self.assertFalse(project_pigs_no_dep.task_ids.depend_on_ids, 'The copied task should not have any dependencies')
self.assertFalse(project_pigs_no_dep.task_ids.dependent_ids, 'The copied task should not have any dependencies')
def test_duplicate_project_with_subtask_dependencies(self):
self.project_goats.allow_task_dependencies = True
self.project_goats.allow_subtasks = True
parent_task = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Parent Task',
'project_id': self.project_goats.id,
'child_ids': [
Command.create({'name': 'Node 1'}),
Command.create({'name': 'SuperNode 2', 'child_ids': [Command.create({'name': 'Node 2'})]}),
Command.create({'name': 'Node 3'}),
Command.create({'name': 'Node 1', 'project_id': self.project_goats.id}),
Command.create({'name': 'SuperNode 2', 'project_id': self.project_goats.id, 'child_ids': [Command.create({'name': 'Node 2', 'project_id': self.project_goats.id})]}),
Command.create({'name': 'Node 3', 'project_id': self.project_goats.id}),
],
})

View file

@ -0,0 +1,55 @@
import odoo.tests
from odoo.tests.common import HttpCase, new_test_user
from odoo.tools.json import scriptsafe as json_safe
@odoo.tests.tagged('post_install', '-at_install')
class TestTaskLinkPreviewName(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.admin = new_test_user(cls.env, login='admin_user', groups='base.group_user,base.group_system')
cls.project_internal_link_display = cls.env['project.project'].create({
'name': 'project',
'display_name': 'project_display_name',
'description': 'project_description',
})
cls.task_internal_link_customized = cls.env['project.task'].create({
'name': 'task1',
'display_name': 'task1_display_name',
'link_preview_name': 'test1 | test parent',
'project_id': cls.project_internal_link_display.id,
'description': 'task1_description',
'user_ids': [(6, 0, [cls.admin.id])],
})
def test_01_task_link_preview_name(self):
self.authenticate(self.admin.login, self.admin.login)
# retrieve metadata of an record with customerized link_preview_name
response_with_preview_name = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": f"/odoo/all-tasks/{self.task_internal_link_customized.id}",
}
}),
headers={"Content-Type": "application/json"}
)
self.assertEqual(200, response_with_preview_name.status_code)
self.assertTrue('link_preview_name' in response_with_preview_name.text)
def test_my_tasks_path(self):
self.authenticate(self.admin.login, self.admin.login)
response_with_preview_name = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": f"/odoo/my-tasks/{self.task_internal_link_customized.id}",
}
}),
headers={"Content-Type": "application/json"}
)
self.assertEqual(200, response_with_preview_name.status_code)
self.assertTrue('link_preview_name' in response_with_preview_name.text)

View file

@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import tagged
from odoo.addons.project.tests.test_project_base import TestProjectCommon
@tagged('-at_install', 'post_install')
class TestTaskState(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.project_goats.write({
'allow_task_dependencies': True,
})
(cls.task_1 + cls.task_2).write({
'project_id': cls.project_goats.id,
})
def test_base_state(self):
""" Test the task base state features
Test Case:
=========
1) check that task_1 and task_2 are in_progress by default
2) add task_2 as a dependency for task_1, check that task_1 state has gone to waiting_normal.
3) force task_1 state to done, the state of task_1 should become done
4) switch task_1 state back to in progress, its state should automatically switch back to waiting normal because of the task_2 dependency
5) change task_2 state to canceled, check that task_1 state has gone back to in_progress.
"""
# 1) check that task_1 and task2 are in_progress by default
self.assertEqual(self.task_1.state, '01_in_progress', "The task_1 should be in progress by default")
self.assertEqual(self.task_2.state, '01_in_progress', "The task_2 should be in progress by default")
# 2) add task2 as a dependency for task 1, check that task_1 state has gone to waiting_normal.
self.task_1.write({
'depend_on_ids': [Command.link(self.task_2.id)],
})
self.assertEqual(self.task_1.state, '04_waiting_normal', "The task_1 should be in waiting_normal after depending on another open task")
# 3) force task_1 state to done, the state of task_1 should become done
self.task_1.write({
'state': '1_done',
})
self.assertEqual(self.task_1.state, '1_done', "The task_1 should be in done even if it has a depending task not closed")
# 4) switch task_1 state back to in progress, its state should automatically switch back to waiting normal because of the task2 dependency
self.task_1.write({
'state': '01_in_progress',
})
self.assertEqual(self.task_1.state, '04_waiting_normal', "task_1 state should automatically switch back to waiting_normal because of the task2 dependency")
# 5) change task_2 state to done, check that task_1 state has gone back to in_progress.
self.task_2.write({
'state': '1_canceled',
})
self.assertEqual(self.task_1.state, '01_in_progress', "task_1 state should automatically switch back to in_progress when its dependency closes")
def test_change_stage_or_project(self):
"""
Test special cases where the task is moved from a stage to another or a project to another
Test Case:
=========
1) change task_1 to an open state and task_2 to a closed state
2) change task_1 and task_2 stage, task_1 should go back to in_progress, task_2 should stay in its closing state
3) change task_1 and task_2 project, they should both go back to in_progress
"""
# 1) change task_1 to an open state and task_2 to a closed state
stage_won = self.env['project.task.type'].search([('name', '=', 'Won')])
project_pigs = self.env['project.project'].search([('name', '=', 'Pigs')])
self.task_1.write({
'state': '02_changes_requested',
})
self.task_2.write({
'state': '1_canceled',
})
# 2) change task_1 and task_2 from stage, task_1 should go back to in_progress, task_2 should stay in its closing state
(self.task_1 + self.task_2).write({
'stage_id': stage_won.id,
})
self.assertEqual(self.task_1.state, '01_in_progress', "task_1 state should automatically switch back to in_progress when its stage changes")
self.assertEqual(self.task_2.state, '1_canceled', "task_2 state should stay in its closed state")
# 3) change task_1 and task_2 project, they should both go back to in_progress
# we make change the task_1 state back to an open state
self.task_1.write({
'state': '02_changes_requested',
})
(self.task_1 + self.task_2).write({
'project_id': project_pigs.id
})
self.task_1._onchange_project_id()
self.assertEqual(self.task_1.state, '01_in_progress', "task_1 state should automatically switch back to in_progress when its project changes")
self.assertEqual(self.task_2.state, '01_in_progress', "task_2 state should automatically switch back to in_progress when its project changes")
def test_duplicate_dependent_task(self):
self.task_1.write({
'depend_on_ids': [Command.link(self.task_2.id)],
})
self.assertEqual(self.task_1.state, '04_waiting_normal', "The task_1 should be in waiting_normal after depending on another open task")
self.task_1_copy = self.task_1.copy()
self.assertEqual(self.task_1.state, '04_waiting_normal', "The task_1_copy should keep his dependence and stay in waiting_normal")
self.task_2.write({
'state': '03_approved',
})
self.task_2_copy = self.task_2.copy()
self.assertEqual(self.task_2_copy.state, '01_in_progress', "The task_2_copy should go back to in_progress")
self.task_2.write({
'state': '1_done',
})
self.assertEqual(self.task_1.state, '04_waiting_normal', "The task_1 should have both tasks as dependencies and so should stay in waiting when one of the two is completed")
self.assertEqual(self.task_1_copy.state, '04_waiting_normal', "The task_1_copy should have both tasks as dependencies and so should stay in waiting when one of the two is completed")
self.task_2_copy.write({
'state': '1_done',
})
self.assertEqual(self.task_1.state, '01_in_progress', "The task_1 should have both tasks as dependencies and so should stay go to 'done' when both dependencies are completed")
self.assertEqual(self.task_1_copy.state, '01_in_progress', "The task_1_copy should have both tasks as dependencies and so should stay go to 'done' when both dependencies are completed")
def test_duplicate_task_state_retention_with_closed_dependencies(self):
self.project_pigs.allow_task_dependencies = True
self.task_1.depend_on_ids = self.task_2
self.task_2.write({'state': '1_done'})
self.task_1.write({'state': '03_approved'})
task_1_copy = self.task_1.copy()
self.assertEqual(self.task_1.state, '03_approved', "The task_1 should retain its state after being copied.")
self.assertEqual(task_1_copy.state, '01_in_progress', "The task_1_copy should have a state of 'in progress'.")
def test_duplicate_task_state_retention_with_open_dependencies(self):
self.project_pigs.allow_task_dependencies = True
self.task_1.depend_on_ids = self.task_2
self.task_2.write({'state': '01_in_progress'})
task_1_copy = self.task_1.copy()
self.assertEqual(self.task_1.state, '04_waiting_normal')
self.assertEqual(task_1_copy.state, '04_waiting_normal')
def test_task_created_in_waiting_stage_gets_in_progress_state(self):
"""
Test that when a new task is created in the "Waiting" state (by grouping by state in Kanban view), it gets the state "In Progress" by default.
"""
project_pigs = self.env['project.project'].search([('name', '=', 'Pigs')])
task = self.env['project.task'].with_context({
'default_state': '04_waiting_normal',
}).create({
'name': 'Task initially waiting state',
'project_id': project_pigs.id,
})
self.assertEqual(task.state, '01_in_progress', "The task should be in progress")
def test_changing_parent_do_not_reset_task_state(self):
self.task_2.state = '04_waiting_normal'
self.task_2.parent_id = self.task_1
self.assertEqual(
self.task_2.state,
'04_waiting_normal',
"Changing the task's parent should not reset the task's state.",
)
def test_state_dont_reset_when_enabling_task_dependencies(self):
self.project_goats.allow_task_dependencies = False
self.env.user.group_ids -= self.env.ref('project.group_project_task_dependencies')
self.task_1.state = "03_approved"
self.task_2.state = "02_changes_requested"
self.project_goats.allow_task_dependencies = True
self.env.user.group_ids += self.env.ref('project.group_project_task_dependencies')
self.assertEqual(self.task_1.state, "03_approved")
self.assertEqual(self.task_2.state, "02_changes_requested")
def test_recompute_state_when_task_dependencies_feature_changes(self):
""" Test task state is correctly computed when the task dependencies feature changes
Test Case:
---------
1. Enable the task dependencies feature globally.
2. Add Task 2 in the dependencies of task 1.
3. Check task 1 as the right state ("Waiting" state expected).
4. Disable the task dependencies feature on the project linked to the both tasks.
5. Check the state of task 1 is correctly reset.
6. Enable again the task dependencies feature on the project.
7. Check task 1 state is `Waiting` state as before.
8. Mark as done task 2
9. Task 1 state should now be reset since all the tasks in dependencies are done
(in that case only task 2 is in the dependencies).
10. Change the state of task 1 to set it to `Approved` state.
11. Disable the task dependencies feature on the project
12. Check the state of task 1 did not change
13. Enable again the task dependencies on the project.
14. Check the state of task 1 did not change.
"""
self.assertTrue(self.project_goats.allow_task_dependencies)
self.assertTrue(self.env.user.has_group('project.group_project_task_dependencies'))
self.task_1.depend_on_ids = self.task_2
self.assertEqual(self.task_1.state, '04_waiting_normal')
self.project_goats.allow_task_dependencies = False
self.assertEqual(self.task_1.state, '01_in_progress')
self.project_goats.allow_task_dependencies = True
self.assertEqual(self.task_1.state, '04_waiting_normal')
self.task_2.state = '1_done'
self.assertEqual(self.task_1.state, '01_in_progress')
self.task_1.state = '03_approved'
self.project_goats.allow_task_dependencies = False
self.assertEqual(self.task_1.state, '03_approved')
self.project_goats.allow_task_dependencies = True
self.assertEqual(self.task_1.state, '03_approved')

View file

@ -0,0 +1,96 @@
from odoo.addons.project.tests.test_project_base import TestProjectCommon
class TestTaskTemplates(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.project_with_templates = cls.env["project.project"].create({
"name": "Project with Task Template",
})
cls.template_task = cls.env["project.task"].create({
"name": "Template",
"project_id": cls.project_with_templates.id,
"is_template": True,
"description": "Template description",
"partner_id": cls.partner_1.id,
})
cls.child_task = cls.env["project.task"].create({
"name": "Child Task",
"parent_id": cls.template_task.id,
"description": "Child description",
"partner_id": cls.partner_2.id,
})
def test_create_from_template(self):
"""
Creating a task through the action should result in a non template copy, with no partner_id
"""
task_id = self.template_task.action_create_from_template()
task = self.env["project.task"].browse(task_id)
self.assertFalse(task.is_template, "The created task should be a normal task and not a template.")
self.assertFalse(task.partner_id, "The created task should not have a partner.")
self.assertEqual(len(task.child_ids), 1, "The child of the template should be copied too.")
child_task = task.child_ids
self.assertFalse(child_task.is_template, "The child task should still not be a template.")
self.assertFalse(child_task.partner_id, "The child task should also not have a partner.")
# With a partner set on the project, new tasks should get the partner too, even if created from a template
self.project_with_templates.partner_id = self.partner_3
task_id = self.template_task.action_create_from_template()
task = self.env["project.task"].browse(task_id)
self.assertEqual(task.partner_id, self.partner_3, "The created task should have the same partner as the project.")
child_task = task.child_ids
self.assertEqual(child_task.partner_id, self.partner_3, "The child of the created task should have the same partner as the project.")
def test_copy_template(self):
"""
A copy of a template should be a template
"""
copied_template = self.template_task.copy()
self.assertTrue(copied_template.is_template, "The copy of the template should also be a template.")
self.assertEqual(len(copied_template.child_ids), 1, "The child of the template should be copied too.")
copied_template_child_task = copied_template.child_ids
self.assertFalse(copied_template_child_task.is_template, "The child of the copy should still not be a template.")
def test_copy_project_with_templates(self):
"""
Copying a project should also copy its task templates
"""
copied_project = self.project_with_templates.copy()
task = self.env["project.task"].search([("project_id", "=", copied_project.id)], order="id asc", limit=1)
self.assertTrue(task, "The copied project should contain a copy of the template.")
self.assertTrue(task.is_template, "The copied template should still be a template.")
def test_has_template_ancestor(self):
self.assertTrue(self.template_task.has_template_ancestor, "The template is a template.")
self.assertTrue(self.child_task.has_template_ancestor, "The child of the template has a template ancestor.")
task = self.env["project.task"].create({
"name": "Task",
"project_id": self.project_with_templates.id,
})
self.assertFalse(task.has_template_ancestor, "The task does not have ancestors and is not a template.")
child = self.env["project.task"].create({
"name": "Child",
"parent_id": task.id,
})
self.assertFalse(child.has_template_ancestor, "The task has ancestors, but none of them are templates.")
self.assertCountEqual(
self.env["project.task"].search(
[('project_id', '=', self.project_with_templates.id), ('has_template_ancestor', '=', True)],
),
self.template_task | self.child_task,
"The search should find the template and its child",
)
self.assertCountEqual(
self.env["project.task"].search(
[('project_id', '=', self.project_with_templates.id), ('has_template_ancestor', '=', False)],
),
task | child,
"The search should find the non template task and its child",
)

View file

@ -0,0 +1,24 @@
from odoo import Command
from odoo.tests import HttpCase, tagged
@tagged("post_install", "-at_install")
class TestTaskTemplatesTour(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.project_with_templates = cls.env["project.project"].create({
"name": "Project with Task Template",
"type_ids": [Command.create({
"name": "New",
})],
})
cls.template_task = cls.env["project.task"].create({
"name": "Template",
"project_id": cls.project_with_templates.id,
"is_template": True,
"description": "Template description",
})
def test_task_templates_tour(self):
self.start_tour("/odoo", "project_task_templates_tour", login="admin")