diff --git a/addons_extensions/project_gantt/__init__.py b/addons_extensions/project_gantt/__init__.py
new file mode 100644
index 000000000..2609681a9
--- /dev/null
+++ b/addons_extensions/project_gantt/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
+from . import report
diff --git a/addons_extensions/project_gantt/__manifest__.py b/addons_extensions/project_gantt/__manifest__.py
new file mode 100644
index 000000000..5daf120e0
--- /dev/null
+++ b/addons_extensions/project_gantt/__manifest__.py
@@ -0,0 +1,37 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': "Project Gantt",
+ 'summary': """Bridge module for project""",
+ 'description': """
+Bridge module for project
+ """,
+ 'category': 'Services/Project',
+ 'version': '1.0',
+ 'depends': ['project','web_gantt',],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/res_config_settings_views.xml',
+ 'views/project_task_views.xml',
+ 'views/project_views.xml',
+ 'views/project_sharing_templates.xml',
+ 'views/project_sharing_views.xml',
+ 'views/project_portal_project_task_templates.xml',
+ ],
+ 'demo': ['data/project_demo.xml'],
+ 'auto_install': True,
+ 'assets': {
+ 'web.assets_backend': [
+ 'project_gantt/static/src/scss/**/*',
+ 'project_gantt/static/src/components/**/*',
+ 'project_gantt/static/src/xml/**',
+ 'project_gantt/static/src/views/project_task_search_model.js',
+ 'project_gantt/static/src/views/project_highlight_tasks.js',
+ 'project_gantt/static/src/views/view_dialogs/**',
+ ],
+ 'web.assets_backend_lazy': [
+ 'project_gantt/static/src/views/task_gantt/**',
+ 'project_gantt/static/src/views/project_gantt/**',
+ ],
+ }
+}
diff --git a/addons_extensions/project_gantt/models/__init__.py b/addons_extensions/project_gantt/models/__init__.py
new file mode 100644
index 000000000..03317d177
--- /dev/null
+++ b/addons_extensions/project_gantt/models/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+from . import project_task
+from . import project_task_recurrence
+from . import res_users
diff --git a/addons_extensions/project_gantt/models/project_task.py b/addons_extensions/project_gantt/models/project_task.py
new file mode 100644
index 000000000..1ed6b1d6d
--- /dev/null
+++ b/addons_extensions/project_gantt/models/project_task.py
@@ -0,0 +1,1480 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import heapq
+from pytz import utc, timezone
+from collections import defaultdict
+from datetime import timedelta, datetime
+from dateutil.relativedelta import relativedelta
+from odoo.tools.date_utils import get_timedelta
+
+from odoo import api, fields, models
+from odoo.osv import expression
+from odoo.exceptions import UserError
+from odoo.tools import _, format_list, topological_sort
+from odoo.tools.sql import SQL
+from odoo.addons.resource.models.utils import filter_domain_leaf
+from odoo.osv.expression import is_leaf
+
+from odoo.addons.resource.models.utils import Intervals, sum_intervals
+
+PROJECT_TASK_WRITABLE_FIELDS = {
+ 'planned_date_begin',
+}
+
+
+class Task(models.Model):
+ _inherit = "project.task"
+
+ planned_date_begin = fields.Datetime("Start date", tracking=True)
+ # planned_date_start is added to be able to display tasks in calendar view because both start and end date are mandatory
+ planned_date_start = fields.Datetime(compute="_compute_planned_date_start", inverse='_inverse_planned_date_start', search="_search_planned_date_start")
+ allocated_hours = fields.Float(compute='_compute_allocated_hours', store=True, readonly=False)
+ # Task Dependencies fields
+ display_warning_dependency_in_gantt = fields.Boolean(compute="_compute_display_warning_dependency_in_gantt", export_string_translation=False)
+ planning_overlap = fields.Html(compute='_compute_planning_overlap', search='_search_planning_overlap', export_string_translation=False)
+ dependency_warning = fields.Html(compute='_compute_dependency_warning', search='_search_dependency_warning', export_string_translation=False)
+
+ # User names in popovers
+ user_names = fields.Char(compute='_compute_user_names', export_string_translation=False)
+ user_ids = fields.Many2many(group_expand="_group_expand_user_ids")
+ partner_id = fields.Many2one(group_expand="_group_expand_partner_ids")
+ project_id = fields.Many2one(group_expand="_group_expand_project_ids")
+
+ _sql_constraints = [
+ ('planned_dates_check', "CHECK ((planned_date_begin <= date_deadline))", "The planned start date must be before the planned end date."),
+ ]
+
+ # action_gantt_reschedule utils
+ _WEB_GANTT_RESCHEDULE_WORK_INTERVALS_CACHE_KEY = 'work_intervals'
+ _WEB_GANTT_RESCHEDULE_RESOURCE_VALIDITY_CACHE_KEY = 'resource_validity'
+
+ @property
+ def SELF_WRITABLE_FIELDS(self):
+ return super().SELF_WRITABLE_FIELDS | PROJECT_TASK_WRITABLE_FIELDS
+
+ def default_get(self, fields_list):
+ result = super().default_get(fields_list)
+ if self.env.context.get('scale', False) not in ("month", "year"):
+ return result
+
+ planned_date_begin = result.get('planned_date_begin', self.env.context.get('planned_date_begin', False))
+ date_deadline = result.get('date_deadline', self.env.context.get('date_deadline', False))
+ if planned_date_begin and date_deadline:
+ user_ids = self.env.context.get('user_ids', [])
+ planned_date_begin, date_deadline = self._calculate_planned_dates(planned_date_begin, date_deadline, user_ids)
+ result.update(planned_date_begin=planned_date_begin, date_deadline=date_deadline)
+ return result
+
+ def action_unschedule_task(self):
+ self.write({
+ 'planned_date_begin': False,
+ 'date_deadline': False
+ })
+
+ @api.depends('is_closed')
+ def _compute_display_warning_dependency_in_gantt(self):
+ for task in self:
+ task.display_warning_dependency_in_gantt = not task.is_closed
+
+ @api.onchange('date_deadline', 'planned_date_begin')
+ def _onchange_planned_dates(self):
+ if not self.date_deadline:
+ self.planned_date_begin = False
+
+ @api.depends('date_deadline', 'planned_date_begin', 'user_ids')
+ def _compute_allocated_hours(self):
+ # Only change values when creating a new record
+ if self._origin:
+ return
+ if not self.date_deadline or not self.planned_date_begin:
+ self.allocated_hours = 0
+ return
+ date_begin, date_end = self._calculate_planned_dates(
+ self.planned_date_begin,
+ self.date_deadline,
+ user_id=self.user_ids.ids if len(self.user_ids) == 1 else None,
+ calendar=self.env.company.resource_calendar_id if len(self.user_ids) != 1 else None,
+ )
+ if len(self.user_ids) == 1:
+ tz = self.user_ids.tz or 'UTC'
+ # We need to browse on res.users in order to bypass the new origin id
+ work_intervals, _dummy = self.env["res.users"].browse(self.user_ids.id.origin).sudo()._get_valid_work_intervals(
+ date_begin.astimezone(timezone(tz)),
+ date_end.astimezone(timezone(tz))
+ )
+ work_duration = sum_intervals(work_intervals[self.user_ids.id.origin])
+ else:
+ tz = self.env.company.resource_calendar_id.tz or 'UTC'
+ work_duration = self.env.company.resource_calendar_id.get_work_hours_count(
+ date_begin.astimezone(timezone(tz)),
+ date_end.astimezone(timezone(tz)),
+ compute_leaves=False
+ )
+ self.allocated_hours = round(work_duration, 2)
+
+ def _fetch_planning_overlap(self, additional_domain=None):
+ domain = [
+ ('active', '=', True),
+ ('is_closed', '=', False),
+ ('planned_date_begin', '!=', False),
+ ('date_deadline', '!=', False),
+ ('date_deadline', '>', fields.Datetime.now()),
+ ('project_id', '!=', False),
+ ]
+ if additional_domain:
+ domain = expression.AND([domain, additional_domain])
+ Task = self.env['project.task']
+ planning_overlap_query = Task._where_calc(
+ expression.AND([
+ domain,
+ [('id', 'in', self.ids)]
+ ])
+ )
+ tu1_alias = planning_overlap_query.join(Task._table, 'id', 'project_task_user_rel', 'task_id', 'TU1')
+ task2_alias = planning_overlap_query.make_alias(Task._table, 'T2')
+ task2_expression = expression.expression(domain, Task, task2_alias)
+ task2_query = task2_expression.query
+
+ # add additional condition to join with the main query
+ task2_query.add_where(
+ SQL(
+ "%s != %s",
+ SQL.identifier(task2_alias, 'id'),
+ SQL.identifier(self._table, 'id')
+ )
+ )
+ task2_query.add_where(
+ SQL(
+ "(%s::TIMESTAMP, %s::TIMESTAMP) OVERLAPS (%s::TIMESTAMP, %s::TIMESTAMP)",
+ SQL.identifier(Task._table, 'planned_date_begin'),
+ SQL.identifier(Task._table, 'date_deadline'),
+ SQL.identifier(task2_alias, 'planned_date_begin'),
+ SQL.identifier(task2_alias, 'date_deadline')
+ )
+ )
+
+ # join task2 query with the main query
+ planning_overlap_query.add_join(
+ 'JOIN',
+ task2_alias,
+ Task._table,
+ task2_query.where_clause
+ )
+ tu2_alias = planning_overlap_query.join(task2_alias, 'id', 'project_task_user_rel', 'task_id', 'TU2')
+ planning_overlap_query.add_where(
+ SQL(
+ "%s = %s",
+ SQL.identifier(tu1_alias, 'user_id'),
+ SQL.identifier(tu2_alias, 'user_id')
+ )
+ )
+ user_alias = planning_overlap_query.join(tu1_alias, 'user_id', 'res_users', 'id', 'U')
+ partner_alias = planning_overlap_query.join(user_alias, 'partner_id', 'res_partner', 'id', 'P')
+ query_str = planning_overlap_query.select(
+ SQL.identifier(Task._table, 'id'),
+ SQL.identifier(Task._table, 'planned_date_begin'),
+ SQL.identifier(Task._table, 'date_deadline'),
+ SQL("ARRAY_AGG(%s) AS task_ids", SQL.identifier(task2_alias, 'id')),
+ SQL("MIN(%s)", SQL.identifier(task2_alias, 'planned_date_begin')),
+ SQL("MAX(%s)", SQL.identifier(task2_alias, 'date_deadline')),
+ SQL("%s AS user_id", SQL.identifier(user_alias, 'id')),
+ SQL("%s AS partner_name", SQL.identifier(partner_alias, 'name')),
+ SQL("%s", SQL.identifier(Task._table, 'allocated_hours')),
+ SQL("SUM(%s)", SQL.identifier(task2_alias, 'allocated_hours')),
+ )
+
+ self.env.cr.execute(
+ SQL(
+ """
+ %s
+ GROUP BY %s
+ ORDER BY %s
+ """,
+ query_str,
+ SQL(", ").join([
+ SQL.identifier(Task._table, 'id'),
+ SQL.identifier(user_alias, 'id'),
+ SQL.identifier(partner_alias, 'name'),
+ ]),
+ SQL.identifier(partner_alias, 'name'),
+ )
+ )
+ return self.env.cr.dictfetchall()
+
+ def _get_planning_overlap_per_task(self):
+ if not self.ids:
+ return {}
+ self.flush_model(['active', 'planned_date_begin', 'date_deadline', 'user_ids', 'project_id', 'is_closed'])
+
+ res = defaultdict(lambda: defaultdict(lambda: {
+ 'overlapping_tasks_ids': [],
+ 'sum_allocated_hours': 0,
+ 'min_planned_date_begin': False,
+ 'max_date_deadline': False,
+ }))
+ for row in self._fetch_planning_overlap([('allocated_hours', '>', 0)]):
+ res[row['id']][row['user_id']] = {
+ 'partner_name': row['partner_name'],
+ 'overlapping_tasks_ids': row['task_ids'],
+ 'sum_allocated_hours': row['sum'] + row['allocated_hours'],
+ 'min_planned_date_begin': min(row['min'], row['planned_date_begin']),
+ 'max_date_deadline': max(row['max'], row['date_deadline'])
+ }
+ return res
+
+ @api.depends('planned_date_begin', 'date_deadline', 'user_ids')
+ def _compute_planning_overlap(self):
+ overlap_mapping = self._get_planning_overlap_per_task()
+ if not overlap_mapping:
+ self.planning_overlap = False
+ return overlap_mapping
+ user_ids = set()
+ absolute_min_start = utc.localize(self[0].planned_date_begin or datetime.utcnow())
+ absolute_max_end = utc.localize(self[0].date_deadline or datetime.utcnow())
+ for task in self:
+ for user_id, task_mapping in overlap_mapping.get(task.id, {}).items():
+ absolute_min_start = min(absolute_min_start, utc.localize(task_mapping["min_planned_date_begin"]))
+ absolute_max_end = max(absolute_max_end, utc.localize(task_mapping["max_date_deadline"]))
+ user_ids.add(user_id)
+ users = self.env['res.users'].browse(list(user_ids))
+ users_work_intervals, dummy = users.sudo()._get_valid_work_intervals(absolute_min_start, absolute_max_end)
+ res = {}
+ for task in self:
+ overlap_messages = []
+ for user_id, task_mapping in overlap_mapping.get(task.id, {}).items():
+ task_intervals = Intervals([
+ (utc.localize(task_mapping['min_planned_date_begin']),
+ utc.localize(task_mapping['max_date_deadline']),
+ self.env['resource.calendar.attendance'])
+ ])
+ if task_mapping['sum_allocated_hours'] > sum_intervals((users_work_intervals[user_id] & task_intervals)):
+ overlap_messages.append(_(
+ '%(partner)s has %(amount)s tasks at the same time.',
+ partner=task_mapping["partner_name"],
+ amount=len(task_mapping['overlapping_tasks_ids']),
+ ))
+ if task.id not in res:
+ res[task.id] = {}
+ res[task.id][user_id] = task_mapping
+ task.planning_overlap = ' '.join(overlap_messages) or False
+ return res
+
+ @api.model
+ def _search_planning_overlap(self, operator, value):
+ if operator not in ['=', '!='] or not isinstance(value, bool):
+ raise NotImplementedError(_('Operation not supported, you should always compare planning_overlap to True or False.'))
+
+ sql = SQL("""(
+ SELECT T1.id
+ FROM project_task T1
+ INNER JOIN project_task T2 ON T1.id <> T2.id
+ INNER JOIN project_task_user_rel U1 ON T1.id = U1.task_id
+ INNER JOIN project_task_user_rel U2 ON T2.id = U2.task_id
+ AND U1.user_id = U2.user_id
+ WHERE
+ T1.planned_date_begin < T2.date_deadline
+ AND T1.date_deadline > T2.planned_date_begin
+ AND T1.planned_date_begin IS NOT NULL
+ AND T1.date_deadline IS NOT NULL
+ AND T1.date_deadline > NOW() AT TIME ZONE 'UTC'
+ AND T1.active = 't'
+ AND T1.state IN ('01_in_progress', '02_changes_requested', '03_approved', '04_waiting_normal')
+ AND T1.project_id IS NOT NULL
+ AND T2.planned_date_begin IS NOT NULL
+ AND T2.date_deadline IS NOT NULL
+ AND T2.date_deadline > NOW() AT TIME ZONE 'UTC'
+ AND T2.project_id IS NOT NULL
+ AND T2.active = 't'
+ AND T2.state IN ('01_in_progress', '02_changes_requested', '03_approved', '04_waiting_normal')
+ )""")
+ operator_new = "in" if ((operator == "=" and value) or (operator == "!=" and not value)) else "not in"
+ return [('id', operator_new, sql)]
+
+ def _compute_user_names(self):
+ for task in self:
+ task.user_names = format_list(self.env, task.user_ids.mapped('name'))
+
+ @api.model
+ def _calculate_planned_dates(self, date_start, date_stop, user_id=None, calendar=None):
+ if not (date_start and date_stop):
+ raise UserError(_('One parameter is missing to use this method. You should give a start and end dates.'))
+ start, stop = date_start, date_stop
+ if isinstance(start, str):
+ start = fields.Datetime.from_string(start)
+ if isinstance(stop, str):
+ stop = fields.Datetime.from_string(stop)
+
+ if not calendar:
+ user = self.env['res.users'].sudo().browse(user_id) if user_id and user_id != self.env.user.id else self.env.user
+ calendar = user.resource_calendar_id or self.env.company.resource_calendar_id
+ if not calendar: # Then we stop and return the dates given in parameter.
+ return date_start, date_stop
+
+ if not start.tzinfo:
+ start = start.replace(tzinfo=utc)
+ if not stop.tzinfo:
+ stop = stop.replace(tzinfo=utc)
+
+ intervals = calendar._work_intervals_batch(start, stop)[False]
+ if not intervals: # Then we stop and return the dates given in parameter
+ return date_start, date_stop
+ list_intervals = [(start, stop) for start, stop, records in intervals] # Convert intervals in interval list
+ start = list_intervals[0][0].astimezone(utc).replace(tzinfo=None) # We take the first date in the interval list
+ stop = list_intervals[-1][1].astimezone(utc).replace(tzinfo=None) # We take the last date in the interval list
+ return start, stop
+
+ def _get_tasks_by_resource_calendar_dict(self):
+ """
+ Returns a dict of:
+ key = 'resource.calendar'
+ value = recordset of 'project.task'
+ """
+ default_calendar = self.env.company.resource_calendar_id
+
+ calendar_by_user_dict = { # key: user_id, value: resource.calendar instance
+ user.id:
+ user.resource_calendar_id or default_calendar
+ for user in self.mapped('user_ids')
+ }
+
+ tasks_by_resource_calendar_dict = defaultdict(
+ lambda: self.env[self._name]) # key = resource_calendar instance, value = tasks
+ for task in self:
+ if len(task.user_ids) == 1:
+ tasks_by_resource_calendar_dict[calendar_by_user_dict[task.user_ids.id]] |= task
+ else:
+ tasks_by_resource_calendar_dict[default_calendar] |= task
+
+ return tasks_by_resource_calendar_dict
+
+ @api.depends('planned_date_begin', 'depend_on_ids.date_deadline')
+ def _compute_dependency_warning(self):
+ if not self._origin:
+ self.dependency_warning = False
+ return
+
+ self.flush_model(['planned_date_begin', 'date_deadline'])
+ query = """
+ SELECT t1.id,
+ ARRAY_AGG(t2.name) as depends_on_names
+ FROM project_task t1
+ JOIN task_dependencies_rel d
+ ON d.task_id = t1.id
+ JOIN project_task t2
+ ON d.depends_on_id = t2.id
+ WHERE t1.id IN %s
+ AND t1.planned_date_begin IS NOT NULL
+ AND t2.date_deadline IS NOT NULL
+ AND t2.date_deadline > t1.planned_date_begin
+ GROUP BY t1.id
+ """
+ self._cr.execute(query, (tuple(self.ids),))
+ depends_on_names_for_id = {
+ group['id']: group['depends_on_names']
+ for group in self._cr.dictfetchall()
+ }
+ for task in self:
+ depends_on_names = depends_on_names_for_id.get(task.id)
+ task.dependency_warning = depends_on_names and _(
+ 'This task cannot be planned before the following tasks on which it depends: %(task_list)s',
+ task_list=format_list(self.env, depends_on_names)
+ )
+
+ @api.model
+ def _search_dependency_warning(self, operator, value):
+ if operator not in ['=', '!='] or not isinstance(value, bool):
+ raise NotImplementedError(_('Operation not supported, you should always compare dependency_warning to True or False.'))
+
+ sql = SQL("""
+ SELECT t1.id
+ FROM project_task t1
+ JOIN task_dependencies_rel d
+ ON d.task_id = t1.id
+ JOIN project_task t2
+ ON d.depends_on_id = t2.id
+ WHERE t1.planned_date_begin IS NOT NULL
+ AND t2.date_deadline IS NOT NULL
+ AND t2.date_deadline > t1.planned_date_begin
+ """)
+ operator_new = "in" if ((operator == "=" and value) or (operator == "!=" and not value)) else "not in"
+ return [('id', operator_new, sql)]
+
+ @api.depends('planned_date_begin', 'date_deadline')
+ def _compute_planned_date_start(self):
+ for task in self:
+ task.planned_date_start = task.planned_date_begin or task.date_deadline
+
+ def _inverse_planned_date_start(self):
+ """ Inverse method only used for calendar view to update the date start if the date begin was defined """
+ for task in self:
+ if task.planned_date_begin:
+ task.planned_date_begin = task.planned_date_start
+ else: # to keep the right hour in the date_deadline
+ task.date_deadline = task.planned_date_start
+
+ def _inverse_state(self):
+ super()._inverse_state()
+ self.filtered(
+ lambda t:
+ t.state == '1_canceled'
+ and t.planned_date_begin
+ and t.planned_date_begin > fields.Datetime.now()
+ ).write({
+ 'planned_date_begin': False,
+ 'date_deadline': False,
+ })
+
+ def _search_planned_date_start(self, operator, value):
+ return [
+ '|',
+ '&', ("planned_date_begin", "!=", False), ("planned_date_begin", operator, value),
+ '&', '&', ("planned_date_begin", "=", False), ("date_deadline", "!=", False), ("date_deadline", operator, value),
+ ]
+
+ def write(self, vals):
+ compute_default_planned_dates = None
+ compute_allocated_hours = None
+ date_start_update = 'planned_date_begin' in vals and vals['planned_date_begin'] is not False
+ date_end_update = 'date_deadline' in vals and vals['date_deadline'] is not False
+ # if fsm_mode=True then the processing in industry_fsm module is done for these dates.
+ if date_start_update and date_end_update \
+ and not any(task.planned_date_begin or task.date_deadline for task in self):
+ compute_default_planned_dates = self.filtered(lambda task: not task.planned_date_begin)
+ if not vals.get('allocated_hours') and vals.get('planned_date_begin') and vals.get('date_deadline'):
+ compute_allocated_hours = self.filtered(lambda task: not task.allocated_hours)
+
+ # if date_end was set to False, so we set planned_date_begin to False
+ if not vals.get('date_deadline', True):
+ vals['planned_date_begin'] = False
+
+ res = super().write(vals)
+
+ # Get the tasks which are either not linked to a project or their project has not timesheet tracking
+ tasks_without_timesheets_track = self.filtered(lambda task: (
+ 'allocated_hours' not in vals and
+ (task.planned_date_begin and task.date_deadline) and
+ ("allow_timesheet" in task.project_id and not task.project_id.allow_timesheet)
+ ))
+ if tasks_without_timesheets_track:
+ tasks_without_timesheets_track._set_allocated_hours_for_tasks()
+
+ if compute_default_planned_dates:
+ # Take the default planned dates
+ planned_date_begin = vals.get('planned_date_begin', False)
+ date_deadline = vals.get('date_deadline', False)
+
+ # Then sort the tasks by resource_calendar and finally compute the planned dates
+ tasks_by_resource_calendar_dict = compute_default_planned_dates._get_tasks_by_resource_calendar_dict()
+ for (calendar, tasks) in tasks_by_resource_calendar_dict.items():
+ date_start, date_stop = self._calculate_planned_dates(planned_date_begin, date_deadline, calendar=calendar)
+ super(Task, tasks).write({
+ 'planned_date_begin': date_start,
+ 'date_deadline': date_stop,
+ })
+
+ if compute_allocated_hours:
+ # 1) Calculate capacity for selected period
+ start = fields.Datetime.from_string(vals['planned_date_begin'])
+ stop = fields.Datetime.from_string(vals['date_deadline'])
+ if not start.tzinfo:
+ start = start.replace(tzinfo=utc)
+ if not stop.tzinfo:
+ stop = stop.replace(tzinfo=utc)
+
+ resource = compute_allocated_hours.user_ids._get_project_task_resource()
+ if len(resource) == 1:
+ # First case : trying to plan tasks for a single user that has its own calendar => using user's calendar
+ calendar = resource.calendar_id
+ work_intervals = calendar._work_intervals_batch(start, stop, resources=resource)
+ capacity = sum_intervals(work_intervals[resource.id])
+ else:
+ # Second case : trying to plan tasks for a single user that has no calendar / for multiple users => using company's calendar
+ calendar = self.env.company.resource_calendar_id
+ work_intervals = calendar._work_intervals_batch(start, stop)
+ capacity = sum_intervals(work_intervals[False])
+
+ # 2) Plan tasks without assignees
+ tasks_no_assignees = compute_allocated_hours.filtered(lambda task: not task.user_ids)
+ if tasks_no_assignees:
+ if calendar == self.env.company.resource_calendar_id:
+ hours = capacity # we can avoid recalculating the amount here
+ else:
+ calendar = self.env.company.resource_calendar_id
+ hours = sum_intervals(calendar._work_intervals_batch(start, stop)[False])
+ tasks_no_assignees.write({"allocated_hours": hours})
+ compute_allocated_hours -= tasks_no_assignees
+
+ if compute_allocated_hours: # this recordset could be empty, and we don't want to divide by 0 when checking the length of it
+ # 3) Remove the already set allocated hours from the capacity
+ capacity -= sum(self.filtered(lambda task: task.allocated_hours and task.user_ids).mapped('allocated_hours'))
+
+ # 4) Split capacity for every task and plan them
+ if capacity > 0:
+ compute_allocated_hours.write({"allocated_hours": capacity / len(compute_allocated_hours)})
+
+ return res
+
+ def _set_allocated_hours_for_tasks(self):
+ tasks_by_resource_calendar_dict = self._get_tasks_by_resource_calendar_dict()
+ for (calendar, tasks) in tasks_by_resource_calendar_dict.items():
+ # 1. Get the min start and max end among the tasks
+ absolute_min_start, absolute_max_end = tasks[0].planned_date_begin, tasks[0].date_deadline
+ for task in tasks:
+ absolute_max_end = max(absolute_max_end, task.date_deadline)
+ absolute_min_start = min(absolute_min_start, task.planned_date_begin)
+ start = fields.Datetime.from_string(absolute_min_start)
+ stop = fields.Datetime.from_string(absolute_max_end)
+ if not start.tzinfo:
+ start = start.replace(tzinfo=utc)
+ if not stop.tzinfo:
+ stop = stop.replace(tzinfo=utc)
+ # 2. Fetch the working hours between min start and max end
+ work_intervals = calendar._work_intervals_batch(start, stop)[False]
+ # 3. For each task compute and write the allocated hours corresponding to their planned dates
+ for task in tasks:
+ start = task.planned_date_begin
+ stop = task.date_deadline
+ if not start.tzinfo:
+ start = start.replace(tzinfo=utc)
+ if not stop.tzinfo:
+ stop = stop.replace(tzinfo=utc)
+ allocated_hours = sum_intervals(work_intervals & Intervals([(start, stop, self.env['resource.calendar.attendance'])]))
+ task.allocated_hours = allocated_hours
+
+ def _get_additional_users(self, domain):
+ return self.env['res.users']
+
+ def _group_expand_user_ids(self, users, domain):
+ """ Group expand by user_ids in gantt view :
+ all users which have and open task in this project + the current user if not filtered by assignee
+ """
+ additional_users = self._get_additional_users(domain)
+ if additional_users:
+ return additional_users
+ start_date = self._context.get('gantt_start_date')
+ scale = self._context.get('gantt_scale')
+ if not (start_date and scale) or any(
+ is_leaf(elem) and elem[0] == 'user_ids' for elem in domain):
+ return additional_users
+ domain = filter_domain_leaf(domain, lambda field: field not in ['planned_date_begin', 'date_deadline', 'state'])
+ search_on_comodel = self._search_on_comodel(domain, "user_ids", "res.users")
+ if search_on_comodel:
+ return search_on_comodel | self.env.user
+ start_date = fields.Datetime.from_string(start_date)
+ delta = get_timedelta(1, scale)
+ domain_expand = expression.AND([
+ self._group_expand_user_ids_domain([
+ ('planned_date_begin', '>=', start_date - delta),
+ ('date_deadline', '<', start_date + delta)
+ ]),
+ domain,
+ ])
+ return self.search(domain_expand).user_ids | self.env.user
+
+ def _group_expand_user_ids_domain(self, domain_expand):
+ project_id = self._context.get('default_project_id')
+ if project_id:
+ domain_expand = expression.OR([[
+ ('project_id', '=', project_id),
+ ('is_closed', '=', False),
+ ('planned_date_begin', '=', False),
+ ('date_deadline', '=', False),
+ ], domain_expand])
+ else:
+ domain_expand = expression.AND([[
+ ('project_id', '!=', False),
+ ], domain_expand])
+ return domain_expand
+
+ @api.model
+ def _group_expand_project_ids(self, projects, domain):
+ start_date = self._context.get('gantt_start_date')
+ scale = self._context.get('gantt_scale')
+ default_project_id = self._context.get('default_project_id')
+ is_my_task = not self._context.get('all_task')
+ if not (start_date and scale) or default_project_id:
+ return projects
+ domain = self._expand_domain_dates(domain)
+ # Check on filtered domain is necessary in case we are in the 'All tasks' menu
+ # Indeed, the project_id != False default search would lead in a wrong result when
+ # no other search have been made
+ filtered_domain = filter_domain_leaf(domain, lambda field: field == "project_id")
+ search_on_comodel = self._search_on_comodel(domain, "project_id", "project.project")
+ if search_on_comodel and (default_project_id or is_my_task or len(filtered_domain) > 1):
+ return search_on_comodel
+ return self.search(domain).project_id
+
+ @api.model
+ def _group_expand_partner_ids(self, partners, domain):
+ start_date = self._context.get('gantt_start_date')
+ scale = self._context.get('gantt_scale')
+ if not (start_date and scale):
+ return partners
+ domain = self._expand_domain_dates(domain)
+ search_on_comodel = self._search_on_comodel(domain, "partner_id", "res.partner")
+ if search_on_comodel:
+ return search_on_comodel
+ return self.search(domain).partner_id
+
+ def _expand_domain_dates(self, domain):
+ filters = []
+ for dom in domain:
+ if len(dom) == 3 and dom[0] == 'date_deadline' and dom[1] == '>=':
+ min_date = dom[2] if isinstance(dom[2], datetime) else datetime.strptime(dom[2], '%Y-%m-%d %H:%M:%S')
+ min_date = min_date - get_timedelta(1, self._context.get('gantt_scale'))
+ filters.append((dom[0], dom[1], min_date))
+ else:
+ filters.append(dom)
+ return filters
+
+ # -------------------------------------
+ # Business Methods : Smart Scheduling
+ # -------------------------------------
+ def schedule_tasks(self, vals):
+ """ Compute the start and end planned date for each task in the recordset.
+
+ This computation is made according to the schedule of the employee the tasks
+ are assigned to, as well as the task already planned for the user.
+ The function schedules the tasks order by dependencies, priority.
+ The transitivity of the tasks is respected in the recordset, but is not guaranteed
+ once the tasks are planned for some specific use case. This function ensures that
+ no tasks planned by it are concurrent with another.
+ If this function is used to plan tasks for the company and not an employee,
+ the tasks are planned with the company calendar, and have the same starting date.
+ Their end date is computed based on their timesheet only.
+ Concurrent or dependent tasks are irrelevant.
+
+ :return: empty dict if some data were missing for the computation
+ or if no action and no warning to display.
+ Else, return a dict { 'action': action, 'warnings'; warning_list } where action is
+ the action to launch if some planification need the user confirmation to be applied,
+ and warning_list the warning message to show if needed.
+ """
+ required_written_fields = {'planned_date_begin', 'date_deadline'}
+ if not self.env.context.get('last_date_view') or any(key not in vals for key in required_written_fields):
+ self.write(vals)
+ return {}
+
+ return self.sorted(
+ lambda t: (not t.date_deadline, t.date_deadline, t._get_hours_to_plan() <= 0, -int(t.priority))
+ )._scheduling(vals)
+
+ def _scheduling(self, vals):
+ tasks_to_write = {}
+ warnings = {}
+ old_vals_per_task_id = {}
+
+ company = self.company_id if len(self.company_id) == 1 else self.env.company
+ tz_info = self._context.get('tz') or 'UTC'
+
+ user_to_assign = self.env['res.users']
+
+ users = self.user_ids
+ if vals.get('user_ids') and len(vals['user_ids']) == 1:
+ user_to_assign = self.env['res.users'].browse(vals['user_ids'])
+ if user_to_assign not in users:
+ users |= user_to_assign
+ tz_info = user_to_assign.tz or tz_info
+ else:
+ if (self.env.context.get("default_project_id")):
+ project = self.env['project.project'].browse(self.env.context["default_project_id"])
+ company = project.company_id if project.company_id else company
+ calendar = project.resource_calendar_id
+ else:
+ calendar = company.resource_calendar_id
+ tz_info = calendar.tz or tz_info
+
+ max_date_start = datetime.strptime(self.env.context.get('last_date_view'), '%Y-%m-%d %H:%M:%S').astimezone(timezone(tz_info))
+ date_start = datetime.strptime(vals["planned_date_begin"], '%Y-%m-%d %H:%M:%S').astimezone(timezone(tz_info))
+ fetch_date_end = max_date_start
+ end_loop = date_start + relativedelta(day=31, month=12, years=1) # end_loop will be the end of the next year.
+
+ valid_intervals_per_user = self._web_gantt_get_valid_intervals(date_start, fetch_date_end, users, [], True)
+ dependent_tasks_end_dates = self._fetch_last_date_end_from_dependent_task_for_all_tasks(tz_info)
+
+ scale = self._context.get("gantt_scale", "week")
+ # In week and month scale, the precision set is used. In day scale we force the half day precison.
+ cell_part_from_context = self._context.get("cell_part")
+ cell_part = cell_part_from_context if scale in ["week", "month"] and cell_part_from_context in [1, 2, 4] else 2
+ # In year scale, cells represent a month, a typical full-time work schedule involves around 160 to 176 hours per month
+ delta_hours = 160 if scale == "year" else 24 / cell_part
+
+ dependencies_dict = { # contains a task as key and the list of tasks before this one as values
+ task:
+ [t for t in self if t != task and t in task.depend_on_ids]
+ if task.depend_on_ids
+ else []
+ for task in self
+ }
+ sorted_tasks = topological_sort(dependencies_dict)
+ for task in sorted_tasks:
+ hours_to_plan = task._get_hours_to_plan()
+ if hours_to_plan <= 0:
+ hours_to_plan = delta_hours
+
+ compute_date_start = compute_date_end = False
+ first_possible_start_date = dependent_tasks_end_dates.get(task.id)
+
+ user_ids = False
+ if user_to_assign and user_to_assign not in task.user_ids:
+ user_ids = tuple(user_to_assign.ids)
+ elif task.user_ids:
+ user_ids = tuple(task.user_ids.ids)
+
+ if user_ids not in valid_intervals_per_user:
+ if 'no_intervals' not in warnings:
+ warnings['no_intervals'] = _("Some tasks weren't planned because the closest available starting date was too far ahead in the future")
+ continue
+
+ while not compute_date_end or hours_to_plan > 0:
+ used_intervals = []
+ for start_date, end_date, dummy in valid_intervals_per_user[user_ids]:
+ if first_possible_start_date and end_date <= first_possible_start_date:
+ continue
+
+ hours_to_plan -= (end_date - start_date).total_seconds() / 3600
+ if not compute_date_start:
+ compute_date_start = start_date
+
+ if hours_to_plan <= 0:
+ compute_date_end = end_date + relativedelta(seconds=hours_to_plan * 3600)
+ used_intervals.append((start_date, compute_date_end, task))
+ break
+
+ used_intervals.append((start_date, end_date, task))
+
+ # Get more intervals if the fetched ones are not enough for scheduling
+ if compute_date_end and hours_to_plan <= 0:
+ break
+
+ if fetch_date_end < end_loop:
+ new_fetch_date_end = min(fetch_date_end + relativedelta(months=1), end_loop)
+ valid_intervals_per_user = self._web_gantt_get_valid_intervals(fetch_date_end, new_fetch_date_end, users, [], True, valid_intervals_per_user)
+ fetch_date_end = new_fetch_date_end
+ else:
+ if 'no_intervals' not in warnings:
+ warnings['no_intervals'] = _("Some tasks weren't planned because the closest available starting date was too far ahead in the future")
+ break
+
+ # remove the task from the record to avoid unnecessary write
+ self -= task
+ if not compute_date_end or hours_to_plan > 0:
+ continue
+
+ start_no_utc = compute_date_start.astimezone(utc).replace(tzinfo=None)
+ end_no_utc = compute_date_end.astimezone(utc).replace(tzinfo=None)
+ # if the working interval for the task has overlap with 'invalid_intervals', we set the warning message accordingly
+ tasks_to_write[task] = {'start': start_no_utc, 'end': end_no_utc}
+
+ for next_task in task.dependent_ids:
+ dependent_tasks_end_dates[next_task.id] = max(dependent_tasks_end_dates.get(next_task.id, compute_date_end), compute_date_end)
+
+ used_intervals = Intervals(used_intervals)
+ if not user_ids:
+ valid_intervals_per_user[False] -= used_intervals
+ else:
+ for user_id in valid_intervals_per_user:
+ if not user_id:
+ continue
+
+ if set(user_id) & set(user_ids):
+ valid_intervals_per_user[user_id] -= used_intervals
+
+ for task in tasks_to_write:
+ old_vals_per_task_id[task.id] = {
+ 'planned_date_begin': task.planned_date_begin,
+ 'date_deadline': task.date_deadline,
+ }
+ task_vals = {
+ 'planned_date_begin': tasks_to_write[task]['start'],
+ 'date_deadline': tasks_to_write[task]['end'],
+ }
+ if user_to_assign:
+ old_user_ids = task.user_ids.ids
+ if user_to_assign.id not in old_user_ids:
+ task_vals['user_ids'] = user_to_assign.ids
+ old_vals_per_task_id[task.id]['user_ids'] = old_user_ids or False
+
+ task.write(task_vals)
+
+ return [warnings, old_vals_per_task_id]
+
+ def action_rollback_auto_scheduling(self, old_vals_per_task_id):
+ for task in self:
+ if str(task.id) in old_vals_per_task_id:
+ task.write(old_vals_per_task_id[str(task.id)])
+
+ def _get_hours_to_plan(self):
+ return self.allocated_hours
+
+ @api.model
+ def _compute_schedule(self, user, calendar, date_start, date_end, company=None):
+ """ Compute the working intervals available for the employee
+ fill the empty schedule slot between contract with the company schedule.
+ """
+ if user:
+ employees_work_days_data, dummy = user.sudo()._get_valid_work_intervals(date_start, date_end)
+ schedule = employees_work_days_data.get(user.id) or Intervals([])
+ # We are using this function to get the intervals for which the schedule of the employee is invalid. Those data are needed to check if we must fallback on the
+ # company schedule. The validity_intervals['valid'] does not contain the work intervals needed, it simply contains large intervals with validity time period
+ # ex of return value : ['valid'] = 01-01-2000 00:00:00 to 11-01-2000 23:59:59; ['invalid'] = 11-02-2000 00:00:00 to 12-31-2000 23:59:59
+ dummy, validity_intervals = self._web_gantt_reschedule_get_resource_calendars_validity(
+ date_start, date_end,
+ resource=user._get_project_task_resource(),
+ company=company)
+ for start, stop, dummy in validity_intervals['invalid']:
+ schedule |= calendar._work_intervals_batch(start, stop)[False]
+
+ return validity_intervals['invalid'], schedule
+ else:
+ return Intervals([]), calendar._work_intervals_batch(date_start, date_end)[False]
+
+ def _fetch_last_date_end_from_dependent_task_for_all_tasks(self, tz_info):
+ """
+ return: return a dict with task.id as key, and the latest date end from all the dependent task of that task
+ """
+ query = """
+ SELECT task.id as id,
+ MAX(depends_on.date_deadline) as date
+ FROM project_task task
+ JOIN task_dependencies_rel rel
+ ON rel.task_id = task.id
+ JOIN project_task depends_on
+ ON depends_on.id not in %s
+ AND depends_on.id = rel.depends_on_id
+ AND depends_on.date_deadline is not null
+ WHERE task.id = any(%s)
+ GROUP BY task.id
+ """
+ self.env.cr.execute(query, [tuple(self.ids), self.ids])
+ return {res['id']: res['date'].astimezone(timezone(tz_info)) for res in self.env.cr.dictfetchall()}
+
+ @api.model
+ def _fetch_concurrent_tasks_intervals_for_employee(self, date_begin, date_end, user, tz_info):
+ concurrent_tasks = self.env['project.task']
+ domain = [('user_ids', '=', user.id),
+ ('date_deadline', '>=', date_begin),
+ ('planned_date_begin', '<=', date_end),
+ ]
+
+ if user:
+ concurrent_tasks = self.env['project.task'].search(
+ domain,
+ order='date_deadline',
+ )
+
+ return Intervals([
+ (t.planned_date_begin.astimezone(timezone(tz_info)),
+ t.date_deadline.astimezone(timezone(tz_info)),
+ t)
+ for t in concurrent_tasks
+ ])
+
+ def _check_concurrent_tasks(self, date_begin, date_end, concurrent_tasks):
+ current_date_end = None
+ for start, stop, dummy in concurrent_tasks:
+ if start <= date_end and stop >= date_begin:
+ current_date_end = stop
+ elif start > date_end:
+ break
+ return current_date_end
+
+ def _get_end_interval(self, date, intervals):
+ for start, stop, dummy in intervals:
+ if start <= date <= stop:
+ return stop
+ return date
+
+ # -------------------------------------
+ # Business Methods : Auto-shift
+ # -------------------------------------
+ def _get_tasks_durations(self, users, start_date_field_name, stop_date_field_name):
+ """ task duration is computed as the sum of the durations of the intersections between [task planned_date_begin, task date_deadline]
+ and valid_intervals of the user (if only one user is assigned) else valid_intervals of the company
+ """
+ start_date = min(self.mapped(start_date_field_name))
+ end_date = max(self.mapped(stop_date_field_name))
+ valid_intervals_per_user = self._web_gantt_get_valid_intervals(start_date, end_date, users, [], False)
+
+ duration_per_task = defaultdict(int)
+ for task in self:
+ if task.allocated_hours > 0:
+ duration_per_task[task.id] = task.allocated_hours * 3600
+ continue
+
+ task_start, task_end = task[start_date_field_name].astimezone(utc), task[stop_date_field_name].astimezone(utc)
+ user_id = (task.user_ids.id, ) if len(task.user_ids) == 1 else False
+ work_intervals = valid_intervals_per_user.get(user_id, Intervals())
+ for start, end, dummy in work_intervals:
+ start, end = start.astimezone(utc), end.astimezone(utc)
+ if task_start < end and task_end > start:
+ duration_per_task[task.id] += (min(task_end, end) - max(task_start, start)).total_seconds()
+
+ if task.id not in duration_per_task:
+ duration_per_task[task.id] = (task.date_deadline - task.planned_date_begin).total_seconds()
+
+ return duration_per_task
+
+ def _web_gantt_reschedule_get_resource(self):
+ """ Get the resource linked to the task. """
+ self.ensure_one()
+ return self.user_ids._get_project_task_resource() if len(self.user_ids) == 1 else self.env['resource.resource']
+
+ def _web_gantt_reschedule_get_resource_entity(self):
+ """ Get the resource entity linked to the task.
+ The resource entity is either a company, either a resource to cope with resource invalidity
+ (i.e. not under contract, not yet created...)
+ This is used as key to keep information in the rescheduling business methods.
+ """
+ self.ensure_one()
+ return self._web_gantt_reschedule_get_resource() or self.company_id or self.project_id.company_id
+
+ def _web_gantt_reschedule_get_resource_calendars_validity(
+ self, date_start, date_end, intervals_to_search=None, resource=None, company=None
+ ):
+ """ Get the calendars and resources (for instance to later get the work intervals for the provided date_start
+ and date_end).
+
+ :param date_start: A start date for the search
+ :param date_end: A end date fot the search
+ :param intervals_to_search: If given, the periods for which the calendars validity must be retrieved.
+ :param resource: If given, it overrides the resource in self._get_resource
+ :return: a dict `resource_calendar_validity` with calendars as keys and their validity as values,
+ a dict `resource_validity` with 'valid' and 'invalid' keys, with the intervals where the resource
+ has a valid calendar (resp. no calendar)
+ :rtype: tuple(defaultdict(), dict())
+ """
+ interval = Intervals([(date_start, date_end, self.env['resource.calendar.attendance'])])
+ if intervals_to_search:
+ interval &= intervals_to_search
+ invalid_interval = interval
+ resource = self._web_gantt_reschedule_get_resource() if resource is None else resource
+ default_company = company or self.company_id or self.project_id.company_id
+ resource_calendar_validity = resource.sudo()._get_calendars_validity_within_period(
+ date_start, date_end, default_company=default_company
+ )[resource.id]
+ for calendar in resource_calendar_validity:
+ resource_calendar_validity[calendar] &= interval
+ invalid_interval -= resource_calendar_validity[calendar]
+ resource_validity = {
+ 'valid': interval - invalid_interval,
+ 'invalid': invalid_interval,
+ }
+ return resource_calendar_validity, resource_validity
+
+ def _web_gantt_get_users_unavailable_intervals(self, user_ids, date_begin, date_end, tasks_to_exclude_ids):
+ """ Get the unavailable intervals per user, intervals already occupied by other tasks
+
+ :param user_ids: users ids
+ :param date_begin: date begin
+ :param date_end: date end
+ :param tasks_to_exclude_ids: tasks to exclude ids
+ :return dict = {user_id: List[Interval]}
+ """
+ domain = [('user_ids', 'in', user_ids),
+ ('date_deadline', '>=', date_begin),
+ ('planned_date_begin', '<=', date_end),
+ ]
+
+ if tasks_to_exclude_ids:
+ domain.append(('id', 'not in', tasks_to_exclude_ids))
+
+ already_planned_tasks = self.env['project.task'].search(domain, order='date_deadline')
+ unavailable_intervals_per_user_id = defaultdict(list)
+ for task in already_planned_tasks:
+ interval_vals = (
+ task.planned_date_begin.astimezone(utc),
+ task.date_deadline.astimezone(utc),
+ task
+ )
+ for user_id in task.user_ids.ids:
+ unavailable_intervals_per_user_id[user_id].append(interval_vals)
+
+ return {user_id: Intervals(vals) for user_id, vals in unavailable_intervals_per_user_id.items()}
+
+ def _web_gantt_get_valid_intervals(self, start_date, end_date, users, candidates_ids=[], remove_intervals_with_planned_tasks=True, valid_intervals_per_user=None):
+ """ Get the valid (intervals available for planning)
+
+ :param start_date: start date
+ :param end_date: end date end
+ :param users: users
+ :param candidates_ids: candidates to plan ids
+ :param remove_intervals_with_planned_tasks: True to remove the intervals with already planned tasks
+ :return (valid intervals dict = {user_id: List[Interval]}, invalid intervals dict = {user_id: List[Interval]})
+ """
+ start_date, end_date = start_date.astimezone(utc), end_date.astimezone(utc)
+ users_work_intervals, calendar_work_intervals = users._get_valid_work_intervals(start_date, end_date)
+ unavailable_intervals = self._web_gantt_get_users_unavailable_intervals(users.ids, start_date, end_date, candidates_ids) if remove_intervals_with_planned_tasks else {}
+ baseInterval = Intervals([(start_date, end_date, self.env['resource.calendar.attendance'])])
+ new_valid_intervals_per_user = {}
+ invalid_intervals_per_user = {}
+ for user_id, work_intervals in users_work_intervals.items():
+ _id = (user_id,)
+ new_valid_intervals_per_user[_id] = work_intervals - unavailable_intervals.get(user_id, Intervals())
+ invalid_intervals_per_user[_id] = baseInterval - new_valid_intervals_per_user[_id]
+
+ company_id = users.company_id if len(users.company_id) == 1 else self.env.company
+ company_calendar_id = company_id.resource_calendar_id
+ company_work_intervals = calendar_work_intervals.get(company_calendar_id.id)
+ if not company_work_intervals:
+ new_valid_intervals_per_user[False] = company_calendar_id._work_intervals_batch(start_date, end_date)[False]
+ else:
+ new_valid_intervals_per_user[False] = company_work_intervals
+
+ for task in self:
+ user_ids = tuple(task.user_ids.ids)
+ if len(user_ids) < 2 or user_ids in new_valid_intervals_per_user:
+ continue
+
+ new_valid_intervals_per_user[user_ids] = new_valid_intervals_per_user[False]
+ for user_id in user_ids:
+ # if user is not present in invalid_intervals => he's not present in users_work_intervals
+ # => he's not available at all and the users together don't have any valid interval in commun
+ if (user_id, ) not in invalid_intervals_per_user:
+ new_valid_intervals_per_user[user_ids] = Intervals()
+ break
+
+ new_valid_intervals_per_user[user_ids] -= invalid_intervals_per_user.get((user_id, ))
+
+ if not valid_intervals_per_user:
+ valid_intervals_per_user = new_valid_intervals_per_user
+ else:
+ for user_ids in new_valid_intervals_per_user:
+ if user_ids in valid_intervals_per_user:
+ valid_intervals_per_user[user_ids] |= new_valid_intervals_per_user[user_ids]
+ else:
+ valid_intervals_per_user[user_ids] = new_valid_intervals_per_user[user_ids]
+
+ return valid_intervals_per_user
+
+ def _web_gantt_move_candidates(self, start_date_field_name, stop_date_field_name, dependency_field_name, dependency_inverted_field_name, search_forward, candidates_ids, date_candidate=None, all_candidates_ids=None, move_not_in_conflicts_candidates=False):
+ result = {
+ "errors": [],
+ "warnings": [],
+ }
+ old_vals_per_pill_id = {}
+ candidates = self.browse(candidates_ids)
+ all_candidates = self.browse(all_candidates_ids or candidates_ids)
+ users = candidates.user_ids.sudo()
+ self_dependency_field_name = self[dependency_field_name if search_forward else dependency_inverted_field_name]
+
+ if search_forward:
+ start_date = date_candidate or max((self_dependency_field_name.filtered(stop_date_field_name and start_date_field_name) - candidates).mapped(stop_date_field_name))
+ # 53 weeks = 1 year is estimated enough to plan a project (no valid proof)
+ end_date = start_date + timedelta(weeks=53)
+ else:
+ end_date = date_candidate or min((self_dependency_field_name.filtered(stop_date_field_name and start_date_field_name) - candidates).mapped(start_date_field_name))
+ start_date = max(datetime.now(), end_date - timedelta(weeks=53))
+ if end_date <= start_date:
+ result["errors"].append("past_error")
+ return result, {}
+
+ valid_intervals_per_user = candidates._web_gantt_get_valid_intervals(start_date, end_date, users, all_candidates.ids or candidates.ids)
+ initial_valid_intervals_per_user = dict(valid_intervals_per_user.items())
+ move_in_conflicts_users = set()
+ first_possible_start_date_per_candidate = {}
+ last_possible_end_date_per_candidate = {}
+
+ for candidate in candidates:
+ related_candidates = candidate[dependency_field_name] if search_forward else candidate[dependency_inverted_field_name]
+ replanned_candidates = related_candidates.filtered(lambda x: x in candidates)
+
+ # this line is used when planning without conflicts we do it in 2 steps, so all_candidates contains all the tasks to replan and candidates contains the task to replan in the current step
+ all_replanned_candidates = related_candidates.filtered(lambda x: x in all_candidates)
+ not_replanned_candidates = related_candidates - all_replanned_candidates
+
+ if not not_replanned_candidates:
+ continue
+
+ boundary_date = stop_date_field_name if search_forward else start_date_field_name
+ boundary_dates = not_replanned_candidates.filtered(boundary_date).mapped(boundary_date)
+
+ if not boundary_dates:
+ continue
+
+ if search_forward:
+ first_possible_start_date_per_candidate[candidate.id] = max(boundary_dates).astimezone(utc)
+ else:
+ last_possible_end_date_per_candidate[candidate.id] = min(boundary_dates).astimezone(utc)
+
+ step = 1 if search_forward else -1
+ candidates_moved_with_conflicts = False
+ candidates_passed_initial_deadline = False
+ candidates_durations = candidates._get_tasks_durations(users, start_date_field_name, stop_date_field_name)
+
+ for candidate in candidates:
+ if not move_not_in_conflicts_candidates and not candidate._web_gantt_is_candidate_in_conflict(start_date_field_name, stop_date_field_name, dependency_field_name, dependency_inverted_field_name):
+ continue
+
+ candidate_duration = candidates_durations[candidate.id]
+ users = candidate.user_ids
+ users_ids = tuple(users.ids) if users else False
+
+ if users_ids not in valid_intervals_per_user:
+ result["errors"].append("no_intervals_error")
+ return result, {}
+
+ intervals = valid_intervals_per_user[users_ids]._items
+ intervals_durations = 0
+ index = 0 if search_forward else len(intervals) - 1
+ used_intervals = []
+ compute_start_date, compute_end_date = False, False
+ while users_ids not in move_in_conflicts_users and ((search_forward and index < len(intervals)) or (not search_forward and index >= 0)) and candidate_duration > intervals_durations:
+ start, end, _dummy = intervals[index]
+ index += step
+ start, end = start.astimezone(utc), end.astimezone(utc)
+
+ if search_forward:
+ first_date = first_possible_start_date_per_candidate.get(candidate.id)
+ if first_date and end <= first_date:
+ continue
+
+ if not compute_start_date:
+ if first_date:
+ start = max(start, first_date)
+ compute_start_date = start
+
+ compute_end_date = end
+ else:
+ last_date = last_possible_end_date_per_candidate.get(candidate.id)
+ if last_date and start >= last_date:
+ continue
+
+ if not compute_end_date:
+ if last_date:
+ end = min(end, last_date)
+ compute_end_date = end
+
+ compute_start_date = start
+
+ duration = (end - start).total_seconds()
+ if intervals_durations + duration > candidate_duration:
+ remaining = intervals_durations + duration - candidate_duration
+ duration -= remaining
+ if search_forward:
+ end += timedelta(seconds=-remaining)
+ compute_end_date = end
+ else:
+ start += timedelta(seconds=remaining)
+ compute_start_date = start
+
+ intervals_durations += duration
+ used_intervals.append((start, end, candidate))
+
+ if users_ids not in move_in_conflicts_users and candidate_duration == intervals_durations and compute_start_date and compute_end_date:
+ candidates_passed_initial_deadline = candidates_passed_initial_deadline or (not candidate[start_date_field_name] and compute_end_date > candidate[stop_date_field_name].astimezone(utc))
+ old_planned_date_begin, old_date_deadline = candidate[start_date_field_name], candidate[stop_date_field_name]
+ if candidate._web_gantt_reschedule_write_new_dates(compute_start_date, compute_end_date, start_date_field_name, stop_date_field_name):
+ old_vals_per_pill_id[candidate.id] = {
+ "planned_date_begin": old_planned_date_begin,
+ "date_deadline": old_date_deadline,
+ }
+ else:
+ result["errors"].append("past_error")
+ return result, {}
+ else:
+ """ no more intervals and we haven't reached the duration to plan the candidate (pill)
+ plan in the first interval, this will lead to creating conflicts, so a notif is added
+ to notify the user
+ """
+ if users_ids not in initial_valid_intervals_per_user or len(initial_valid_intervals_per_user[users_ids]._items) == 0:
+ result["errors"].append("no_intervals_error")
+ return result, {}
+
+ candidates_moved_with_conflicts = True
+ move_in_conflicts_users.add(users_ids)
+ final_interval_index = -1 if search_forward else 0
+ ranges = initial_valid_intervals_per_user[users_ids]._items
+ compute_start_date = ranges[final_interval_index][0]
+ compute_end_date = ranges[final_interval_index][1]
+ needed_intervals_duration = 0
+ searching_step = -1 if search_forward else 1
+ searching_index = len(ranges) if search_forward else -1
+ while ((search_forward and searching_index - 1 > 0) or (not search_forward and searching_index + 1 < len(ranges))) and candidate_duration > needed_intervals_duration:
+ searching_index += searching_step
+ start, end, _dummy = ranges[searching_index]
+ start, end = start.astimezone(utc), end.astimezone(utc)
+ if search_forward:
+ compute_start_date = start
+ else:
+ compute_end_date = end
+
+ needed_intervals_duration += (end - start).total_seconds()
+
+ if candidate_duration <= needed_intervals_duration:
+ remaining = needed_intervals_duration - candidate_duration
+
+ if search_forward:
+ compute_start_date += timedelta(seconds=remaining)
+ else:
+ compute_end_date += timedelta(seconds=-remaining)
+ old_planned_date_begin, old_date_deadline = candidate[start_date_field_name], candidate[stop_date_field_name]
+ if candidate._web_gantt_reschedule_write_new_dates(compute_start_date, compute_end_date, start_date_field_name, stop_date_field_name):
+ old_vals_per_pill_id[candidate.id] = {
+ "planned_date_begin": old_planned_date_begin,
+ "date_deadline": old_date_deadline,
+ }
+ else:
+ result["errors"].append("past_error")
+ return result, {}
+
+ next_candidates = candidate[dependency_inverted_field_name if search_forward else dependency_field_name]
+ for task in next_candidates:
+ if not task._web_gantt_reschedule_is_record_candidate(start_date_field_name, stop_date_field_name):
+ continue
+
+ if search_forward:
+ candidate_date = max(first_possible_start_date_per_candidate[task.id], compute_end_date) if first_possible_start_date_per_candidate.get(task.id) else compute_end_date
+ first_possible_start_date_per_candidate[task.id] = candidate_date
+ else:
+ candidate_date = min(last_possible_end_date_per_candidate[task.id], compute_start_date) if last_possible_end_date_per_candidate.get(task.id) else compute_start_date
+ last_possible_end_date_per_candidate[task.id] = candidate_date
+
+ used_intervals = Intervals(used_intervals)
+ if not users_ids:
+ valid_intervals_per_user[False] -= used_intervals
+ else:
+ for user in valid_intervals_per_user:
+ if not user:
+ continue
+
+ if set(user) & set(users_ids):
+ valid_intervals_per_user[user] -= used_intervals
+
+ if candidates_passed_initial_deadline:
+ result["warnings"].append("initial_deadline")
+ if candidates_moved_with_conflicts:
+ result["warnings"].append("conflict")
+ return result, old_vals_per_pill_id
+
+ def _web_gantt_reschedule_is_record_candidate(self, start_date_field_name, stop_date_field_name):
+ """ Get whether the record is a candidate for the rescheduling. This method is meant to be overridden when
+ we need to add a constraint in order to prevent some records to be rescheduled. This method focuses on the
+ record itself (if you need to have information on the relation (master and slave) rather override
+ _web_gantt_reschedule_is_relation_candidate).
+
+ :param start_date_field_name: The start date field used in the gantt view.
+ :param stop_date_field_name: The stop date field used in the gantt view.
+ :return: True if record can be rescheduled, False if not.
+ :rtype: bool
+ """
+ self.ensure_one()
+ return self[start_date_field_name] and self[stop_date_field_name] and self.project_id.allow_task_dependencies and not self.is_closed
+
+ def _web_gantt_get_reschedule_message_per_key(self, key, params=None):
+ message = super()._web_gantt_get_reschedule_message_per_key(key, params)
+ if message:
+ return message
+
+ if key == "no_intervals_error":
+ return _("The tasks could not be rescheduled due to the assignees' lack of availability at this time.")
+ elif key == "initial_deadline":
+ return _("Some tasks were planned after their initial deadline.")
+ elif key == "conflict":
+ return _("Some tasks were scheduled concurrently, resulting in a conflict due to the limited availability of the assignees. The planned dates for these tasks may not align with their allocated hours.")
+ else:
+ return ""
+
+ # ----------------------------------------------------
+ # Overlapping tasks
+ # ----------------------------------------------------
+
+ def action_fsm_view_overlapping_tasks(self):
+ self.ensure_one()
+ action = self.env['ir.actions.act_window']._for_xml_id('project.action_view_all_task')
+ if 'views' in action:
+ gantt_view = self.env.ref("project_gantt.project_task_dependency_view_gantt")
+ map_view = self.env.ref('project_gantt.project_task_map_view_no_title')
+ action['views'] = [(gantt_view.id, 'gantt')] + [(state, view) for state, view in action['views'] if view not in ['gantt']]
+ name = _('Tasks in Conflict')
+ action.update({
+ 'display_name': name,
+ 'name': name,
+ 'domain' : [
+ ('user_ids', 'in', self.user_ids.ids),
+ ],
+ 'context': {
+ 'fsm_mode': False,
+ 'task_nameget_with_hours': False,
+ 'initialDate': self.planned_date_begin,
+ 'search_default_conflict_task': True,
+ }
+ })
+ return action
+
+ # ----------------------------------------------------
+ # Gantt view
+ # ----------------------------------------------------
+
+ @api.model
+ def _gantt_unavailability(self, field, res_ids, start, stop, scale):
+ resources = self.env['resource.resource']
+ if field in ['user_ids', 'user_id']:
+ resources = resources.search([('user_id', 'in', res_ids), ('company_id', '=', self.env.company.id)], order='create_date')
+ # we reverse sort the resources by date to keep the first one created in the dictionary
+ # to anticipate the case of a resource added later for the same employee and company
+ user_resource_mapping = {resource.user_id.id: resource.id for resource in resources}
+ leaves_mapping = resources._get_unavailable_intervals(start, stop)
+ company_leaves = self.env.company.resource_calendar_id._unavailable_intervals(start.replace(tzinfo=utc), stop.replace(tzinfo=utc))
+
+ cell_dt = timedelta(hours=1) if scale in ['day', 'week'] else timedelta(hours=12)
+
+ result = {}
+ for user_id in res_ids + [False]:
+ resource_id = user_resource_mapping.get(user_id)
+ calendar = leaves_mapping.get(resource_id, company_leaves)
+ # remove intervals smaller than a cell, as they will cause half a cell to turn grey
+ # ie: when looking at a week, a employee start everyday at 8, so there is a unavailability
+ # like: 2019-05-22 20:00 -> 2019-05-23 08:00 which will make the first half of the 23's cell grey
+ notable_intervals = filter(lambda interval: interval[1] - interval[0] >= cell_dt, calendar)
+ result[user_id] = [{'start': interval[0], 'stop': interval[1]} for interval in notable_intervals]
+
+ return result
+
+ def web_gantt_write(self, data):
+ res = True
+
+ if any(
+ f_name in self._fields and self._fields[f_name].type == 'many2many' and value and len(value) == 1
+ for f_name, value in data.items()
+ ):
+ record_ids_per_m2m_field_names = defaultdict(list)
+ full_write_record_ids = []
+ for record in self:
+ fields_to_remove = []
+ for f_name, value in data.items():
+ if (
+ value
+ and f_name in record._fields
+ and record._fields[f_name].type == 'many2many'
+ and len(value) == 1
+ and value[0] in record[f_name].ids
+ ):
+ fields_to_remove.append(f_name)
+ if fields_to_remove:
+ record_ids_per_m2m_field_names[tuple(fields_to_remove)].append(record.id)
+ else:
+ full_write_record_ids.append(record.id)
+ if record_ids_per_m2m_field_names:
+ if full_write_record_ids:
+ res &= self.browse(full_write_record_ids).write(data)
+ for fields_to_remove_from_data, record_ids in record_ids_per_m2m_field_names.items():
+ res &= self.browse(record_ids).write({
+ f_name: value
+ for f_name, value in data.items()
+ if f_name not in fields_to_remove_from_data
+ })
+ else:
+ res &= self.write(data)
+ else:
+ res &= self.write(data)
+
+ return res
+
+ def action_dependent_tasks(self):
+ action = super().action_dependent_tasks()
+ action['view_mode'] = 'list,form,kanban,calendar,pivot,graph,gantt,activity,map'
+ return action
+
+ def action_recurring_tasks(self):
+ action = super().action_recurring_tasks()
+ action['view_mode'] = 'list,form,kanban,calendar,pivot,graph,gantt,activity,map'
+ return action
+
+ def _gantt_progress_bar_user_ids(self, res_ids, start, stop):
+ start_naive, stop_naive = start.replace(tzinfo=None), stop.replace(tzinfo=None)
+ users = self.env['res.users'].search([('id', 'in', res_ids)])
+ self.env['project.task'].check_access('read')
+
+ project_tasks = self.env['project.task'].sudo().search([
+ ('user_ids', 'in', res_ids),
+ ('planned_date_begin', '<=', stop_naive),
+ ('date_deadline', '>=', start_naive),
+ ])
+ project_tasks = project_tasks.with_context(prefetch_fields=False)
+ # Prefetch fields from database to avoid doing one query by __get__.
+ project_tasks.fetch(['planned_date_begin', 'date_deadline', 'user_ids'])
+ allocated_hours_mapped = defaultdict(float)
+ # Get the users work intervals between start and end dates of the gantt view
+ users_work_intervals, dummy = users.sudo()._get_valid_work_intervals(start, stop)
+ allocated_hours_mapped = project_tasks._allocated_hours_per_user_for_scale(users, start, stop)
+ # Compute employee work hours based on its work intervals.
+ work_hours = {
+ user_id: sum_intervals(work_intervals)
+ for user_id, work_intervals in users_work_intervals.items()
+ }
+ return {
+ user.id: {
+ 'value': allocated_hours_mapped[user.id],
+ 'max_value': work_hours.get(user.id, 0.0),
+ }
+ for user in users
+ }
+
+ def _allocated_hours_per_user_for_scale(self, users, start, stop):
+ absolute_max_end, absolute_min_start = stop, start
+ allocated_hours_mapped = defaultdict(float)
+ for task in self:
+ absolute_max_end = max(absolute_max_end, utc.localize(task.date_deadline))
+ absolute_min_start = min(absolute_min_start, utc.localize(task.planned_date_begin))
+ users_work_intervals, _dummy = users.sudo()._get_valid_work_intervals(absolute_min_start, absolute_max_end)
+ for task in self:
+ task_date_begin = utc.localize(task.planned_date_begin)
+ task_deadline = utc.localize(task.date_deadline)
+ max_start = max(start, task_date_begin)
+ min_end = min(stop, task_deadline)
+ for user in task.user_ids:
+ work_intervals_for_scale = sum_intervals(users_work_intervals[user.id] & Intervals([(max_start, min_end, self.env['resource.calendar.attendance'])]))
+ work_intervals_for_task = sum_intervals(users_work_intervals[user.id] & Intervals([(task_date_begin, task_deadline, self.env['resource.calendar.attendance'])]))
+ # The ratio between the workable hours in the gantt view scale and the workable hours
+ # between start and end dates of the task allows to determine the allocated hours for the current scale
+ ratio = 1
+ if work_intervals_for_task:
+ ratio = work_intervals_for_scale / work_intervals_for_task
+ allocated_hours_mapped[user.id] += (task.allocated_hours / len(task.user_ids)) * ratio
+
+ return allocated_hours_mapped
+
+ def _gantt_progress_bar(self, field, res_ids, start, stop):
+ if not self.env.user.has_group("project.group_project_user"):
+ return {}
+ if field == 'user_ids':
+ start, stop = utc.localize(start), utc.localize(stop)
+ return dict(
+ self._gantt_progress_bar_user_ids(res_ids, start, stop),
+ warning=_("This user isn't expected to have any tasks assigned during this period because they don't have any running contract."),
+ )
+ raise NotImplementedError(_("This Progress Bar is not implemented."))
+
+ @api.model
+ @api.readonly
+ def get_all_deadlines(self, date_start, date_end):
+ """ Get all deadlines (milestones and projects) between date_start and date_end.
+
+ :param date_start: The start date.
+ :param date_end: The end date.
+
+ :return: A dictionary with the field_name of tasks as key and list of records.
+ """
+ results = {}
+ project_id = self._context.get('default_project_id', False)
+ project_domain = [
+ ('date', '>=', date_start),
+ ('date_start', '<=', date_end),
+ ]
+ milestone_domain = [
+ ('deadline', '>=', date_start),
+ ('deadline', '<=', date_end),
+ ]
+ if project_id:
+ project_domain = expression.AND([project_domain, [('id', '=', project_id)]])
+ milestone_domain = expression.AND([milestone_domain, [('project_id', '=', project_id)]])
+ results['project_id'] = self.env['project.project'].search_read(
+ project_domain,
+ ['id', 'name', 'date', 'date_start']
+ )
+ results['milestone_id'] = self.env['project.milestone'].search_read(
+ milestone_domain,
+ ['name', 'deadline', 'is_deadline_exceeded', 'is_reached', 'project_id'],
+ )
+ return results
diff --git a/addons_extensions/project_gantt/models/project_task_recurrence.py b/addons_extensions/project_gantt/models/project_task_recurrence.py
new file mode 100644
index 000000000..7ec1a0f9a
--- /dev/null
+++ b/addons_extensions/project_gantt/models/project_task_recurrence.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, api
+
+
+class ProjectTaskRecurrence(models.Model):
+ _inherit = 'project.task.recurrence'
+
+ @api.model
+ def _get_recurring_fields_to_postpone(self):
+ return super()._get_recurring_fields_to_postpone() + [
+ 'planned_date_begin',
+ ]
diff --git a/addons_extensions/project_gantt/models/res_users.py b/addons_extensions/project_gantt/models/res_users.py
new file mode 100644
index 000000000..ab8ec46e1
--- /dev/null
+++ b/addons_extensions/project_gantt/models/res_users.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from collections import defaultdict
+
+from odoo import models
+from odoo.addons.resource.models.utils import Intervals
+
+class User(models.Model):
+ _inherit = 'res.users'
+
+ # -----------------------------------------
+ # Business Methods
+ # -----------------------------------------
+ def _get_calendars_validity_within_period(self, start, end):
+ """ Gets a dict of dict with user's id as first key and user's calendar as secondary key
+ The value is the validity interval of the calendar for the given user.
+
+ Here the validity interval for each calendar is the whole interval but it's meant to be overriden in further modules
+ handling user's employee contracts.
+ """
+ assert start.tzinfo and end.tzinfo
+ user_resources = {user: user._get_project_task_resource() for user in self}
+ user_calendars_within_period = defaultdict(lambda: defaultdict(Intervals)) # keys are [user id:integer][calendar:self.env['resource.calendar']]
+ resource_calendars_within_period = self._get_project_task_resource()._get_calendars_validity_within_period(start, end)
+ if not self:
+ # if no user, add the company resource calendar.
+ user_calendars_within_period[False] = resource_calendars_within_period[False]
+ for user, resource in user_resources.items():
+ if resource:
+ user_calendars_within_period[user.id] = resource_calendars_within_period[resource.id]
+ else:
+ calendar = user.resource_calendar_id or user.company_id.resource_calendar_id or self.env.company.resource_calendar_id
+ user_calendars_within_period[user.id][calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
+ return user_calendars_within_period
+
+ def _get_valid_work_intervals(self, start, end, calendars=None):
+ """ Gets the valid work intervals of the user following their calendars between ``start`` and ``end``
+
+ This methods handle the eventuality of a user's resource having multiple resource calendars,
+ see _get_calendars_validity_within_period method for further explanation.
+ """
+ assert start.tzinfo and end.tzinfo
+ user_calendar_validity_intervals = {}
+ calendar_users = defaultdict(lambda: self.env['res.users'])
+ user_work_intervals = defaultdict(Intervals)
+ calendar_work_intervals = dict()
+ user_resources = {user: user._get_project_task_resource() for user in self}
+
+ user_calendar_validity_intervals = self._get_calendars_validity_within_period(start, end)
+ for user in self:
+ # For each user, retrieve its calendar and their validity intervals
+ for calendar in user_calendar_validity_intervals[user.id]:
+ calendar_users[calendar] |= user
+ for calendar in (calendars or []):
+ calendar_users[calendar] |= self.env['res.users']
+ for calendar, users in calendar_users.items():
+ # For each calendar used by the users, retrieve the work intervals for every users using it
+ work_intervals_batch = calendar._work_intervals_batch(start, end, resources=users._get_project_task_resource())
+ for user in users:
+ # Make the conjunction between work intervals and calendar validity
+ user_work_intervals[user.id] |= work_intervals_batch[user_resources[user].id] & user_calendar_validity_intervals[user.id][calendar]
+ calendar_work_intervals[calendar.id] = work_intervals_batch[False]
+
+ return user_work_intervals, calendar_work_intervals
+
+ def _get_project_task_resource(self):
+ return self.env['resource.resource']
diff --git a/addons_extensions/project_gantt/report/__init__.py b/addons_extensions/project_gantt/report/__init__.py
new file mode 100644
index 000000000..f79ee643c
--- /dev/null
+++ b/addons_extensions/project_gantt/report/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details
+
+from . import project_report
\ No newline at end of file
diff --git a/addons_extensions/project_gantt/report/project_report.py b/addons_extensions/project_gantt/report/project_report.py
new file mode 100644
index 000000000..0ed5a352f
--- /dev/null
+++ b/addons_extensions/project_gantt/report/project_report.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details
+
+from odoo import api, fields, models
+
+
+class ReportProjectTaskUser(models.Model):
+ _inherit = 'report.project.task.user'
+
+ planned_date_begin = fields.Datetime("Start date", readonly=True)
+
+ def _select(self):
+ return super()._select() + """,
+ t.planned_date_begin
+ """
+
+ def _group_by(self):
+ return super()._group_by() + """,
+ t.planned_date_begin
+ """
diff --git a/addons_extensions/project_gantt/security/ir.model.access.csv b/addons_extensions/project_gantt/security/ir.model.access.csv
new file mode 100644
index 000000000..97dd8b917
--- /dev/null
+++ b/addons_extensions/project_gantt/security/ir.model.access.csv
@@ -0,0 +1 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
diff --git a/addons_extensions/project_gantt/static/src/components/project_right_side_panel/components/project_right_side_panel_section.js b/addons_extensions/project_gantt/static/src/components/project_right_side_panel/components/project_right_side_panel_section.js
new file mode 100644
index 000000000..797e35016
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/components/project_right_side_panel/components/project_right_side_panel_section.js
@@ -0,0 +1,30 @@
+/** @odoo-module */
+
+import { useBus, useService } from '@web/core/utils/hooks';
+import { patch } from "@web/core/utils/patch";
+import { ProjectRightSidePanelSection } from '@project/components/project_right_side_panel/components/project_right_side_panel_section';
+import { useState } from "@odoo/owl";
+
+patch(ProjectRightSidePanelSection.prototype, {
+ setup() {
+ this.state = useState({ isClosed: !!this.env.isSmall && this.props.canBeClosed });
+ this.ui = useService('ui');
+
+ useBus(this.ui.bus, "resize", this.setDefaultIsClosed);
+ },
+
+ setDefaultIsClosed() {
+ this.state.isClosed = this.ui.isSmall && this.props.canBeClosed;
+ },
+
+ toggleSection() {
+ if (!this.env.isSmall || !this.props.canBeClosed) { // then no need to change the value.
+ this.state.isClosed = false;
+ } else {
+ this.state.isClosed = !this.state.isClosed;
+ }
+ }
+});
+
+ProjectRightSidePanelSection.props.canBeClosed = { type: Boolean, optional: true };
+ProjectRightSidePanelSection.defaultProps.canBeClosed = true;
diff --git a/addons_extensions/project_gantt/static/src/components/project_right_side_panel/components/project_right_side_panel_section.xml b/addons_extensions/project_gantt/static/src/components/project_right_side_panel/components/project_right_side_panel_section.xml
new file mode 100644
index 000000000..f80d82021
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/components/project_right_side_panel/components/project_right_side_panel_section.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+ toggleSection
+
+
+
+
+
+
+
diff --git a/addons_extensions/project_gantt/static/src/components/project_right_side_panel/project_right_side_panel.scss b/addons_extensions/project_gantt/static/src/components/project_right_side_panel/project_right_side_panel.scss
new file mode 100644
index 000000000..a4468cbd8
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/components/project_right_side_panel/project_right_side_panel.scss
@@ -0,0 +1,32 @@
+@include media-breakpoint-down(lg) {
+ .o_controller_with_rightpanel .o_content {
+ display: flex;
+ flex-direction: column;
+ overflow: initial;
+
+ .o_kanban_renderer {
+ width: 100%;
+ overflow: inherit;
+
+ &.o_kanban_ungrouped {
+ min-height: auto;
+ }
+ }
+ }
+
+ .o_rightpanel {
+ border: 1px solid lightgray;
+ flex-basis: auto;
+ height: auto;
+ border: 0;
+ padding: 0 $o-rightpanel-p;
+ width: 100%;
+ min-width: auto;
+ max-width: none;
+
+ .o_rightpanel_section {
+ padding-top: $o-rightpanel-p-tiny;
+ padding-bottom: $o-rightpanel-p-tiny;
+ }
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/components/project_right_side_panel/project_right_side_panel.xml b/addons_extensions/project_gantt/static/src/components/project_right_side_panel/project_right_side_panel.xml
new file mode 100644
index 000000000..d86d002f9
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/components/project_right_side_panel/project_right_side_panel.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+ false
+
+
+
+
diff --git a/addons_extensions/project_gantt/static/src/scss/project_update_view_mobile.scss b/addons_extensions/project_gantt/static/src/scss/project_update_view_mobile.scss
new file mode 100644
index 000000000..e10a9f437
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/scss/project_update_view_mobile.scss
@@ -0,0 +1,5 @@
+@include media-breakpoint-down(md) {
+ .o_kanban_detail_ungrouped > div:not(:last-child) {
+ padding-bottom: 10px;
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_renderer.js b/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_renderer.js
new file mode 100644
index 000000000..206d036f9
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_renderer.js
@@ -0,0 +1,33 @@
+import { Avatar } from "@mail/views/web/fields/avatar/avatar";
+import { GanttRenderer } from "@web_gantt/gantt_renderer";
+
+export class ProjectGanttRenderer extends GanttRenderer {
+ static components = {
+ ...GanttRenderer.components,
+ Avatar,
+ };
+ static rowHeaderTemplate = "project_gantt.ProjectGanttRenderer.RowHeader";
+
+ computeDerivedParams() {
+ this.rowsWithAvatar = {};
+ super.computeDerivedParams();
+ }
+
+ processRow(row) {
+ const { groupedByField, name, resId } = row;
+ if (groupedByField === "user_id" && Boolean(resId)) {
+ const { fields } = this.model.metaData;
+ const resModel = fields.user_id.relation;
+ this.rowsWithAvatar[row.id] = { resModel, resId, displayName: name };
+ }
+ return super.processRow(...arguments);
+ }
+
+ getAvatarProps(row) {
+ return this.rowsWithAvatar[row.id];
+ }
+
+ hasAvatar(row) {
+ return row.id in this.rowsWithAvatar;
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_renderer.xml b/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_renderer.xml
new file mode 100644
index 000000000..8121aa411
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_renderer.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_view.js b/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_view.js
new file mode 100644
index 000000000..96a805e41
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/project_gantt/project_gantt_view.js
@@ -0,0 +1,10 @@
+import { ganttView } from "@web_gantt/gantt_view";
+import { registry } from "@web/core/registry";
+import { ProjectGanttRenderer } from "./project_gantt_renderer";
+
+export const projectGanttView = {
+ ...ganttView,
+ Renderer: ProjectGanttRenderer,
+};
+
+registry.category("views").add("project_gantt", projectGanttView);
diff --git a/addons_extensions/project_gantt/static/src/views/project_highlight_tasks.js b/addons_extensions/project_gantt/static/src/views/project_highlight_tasks.js
new file mode 100644
index 000000000..e2a9046a5
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/project_highlight_tasks.js
@@ -0,0 +1,27 @@
+import { useService } from "@web/core/utils/hooks";
+
+export function useProjectModelActions({ getContext, getHighlightPlannedIds }) {
+ const orm = useService("orm");
+ return {
+ async getHighlightIds() {
+ const context = getContext();
+ if (!context || (!context.highlight_conflicting_task && !context.highlight_planned)) {
+ return;
+ }
+
+ if (context.highlight_conflicting_task) {
+ const highlightConflictingIds = await orm.search("project.task", [
+ ["planning_overlap", "!=", false],
+ ]);
+
+ if (context.highlight_planned) {
+ return Array.from(
+ new Set([...highlightConflictingIds, ...getHighlightPlannedIds()])
+ );
+ }
+ return highlightConflictingIds;
+ }
+ return getHighlightPlannedIds() || [];
+ },
+ };
+}
diff --git a/addons_extensions/project_gantt/static/src/views/project_task_search_model.js b/addons_extensions/project_gantt/static/src/views/project_task_search_model.js
new file mode 100644
index 000000000..9c145acd3
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/project_task_search_model.js
@@ -0,0 +1,46 @@
+import { SearchModel } from "@web/search/search_model";
+
+export class ProjectTaskSearchModel extends SearchModel {
+ exportState() {
+ return {
+ ...super.exportState(),
+ highlightPlannedIds: this.highlightPlannedIds,
+ };
+ }
+
+ _importState(state) {
+ this.highlightPlannedIds = state.highlightPlannedIds;
+ super._importState(state);
+ }
+
+ deactivateGroup(groupId) {
+ if (this._getHighlightPlannedSearchItems()?.groupId === groupId) {
+ this.highlightPlannedIds = null;
+ }
+ super.deactivateGroup(groupId);
+ }
+
+ toggleHighlightPlannedFilter(highlightPlannedIds) {
+ const highlightPlannedSearchItems = this._getHighlightPlannedSearchItems();
+ if (highlightPlannedIds) {
+ this.highlightPlannedIds = highlightPlannedIds;
+ if (highlightPlannedSearchItems) {
+ if (
+ this.query.find(
+ (queryElem) => queryElem.searchItemId === highlightPlannedSearchItems.id
+ )
+ ) {
+ this._notify();
+ } else {
+ this.toggleSearchItem(highlightPlannedSearchItems.id);
+ }
+ }
+ } else if (highlightPlannedSearchItems) {
+ this.deactivateGroup(highlightPlannedSearchItems.groupId);
+ }
+ }
+
+ _getHighlightPlannedSearchItems() {
+ return Object.values(this.searchItems).find((v) => v.name === "tasks_scheduled");
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/views/task_gantt/milestones_popover.js b/addons_extensions/project_gantt/static/src/views/task_gantt/milestones_popover.js
new file mode 100644
index 000000000..810b4bca8
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/task_gantt/milestones_popover.js
@@ -0,0 +1,16 @@
+/** @odoo-module **/
+
+import { Component } from "@odoo/owl";
+import { formatDate } from "@web/core/l10n/dates";
+
+export class MilestonesPopover extends Component {
+ static template = "project_gantt.MilestonesPopover";
+ static props = ["close", "displayMilestoneDates", "displayProjectName", "projects"];
+
+ getDeadline(milestone) {
+ if (!milestone.deadline) {
+ return;
+ }
+ return formatDate(milestone.deadline);
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/views/task_gantt/milestones_popover.xml b/addons_extensions/project_gantt/static/src/views/task_gantt/milestones_popover.xml
new file mode 100644
index 000000000..109c2c619
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/task_gantt/milestones_popover.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+ Project start
+ Project due
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_arch_parser.js b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_arch_parser.js
new file mode 100644
index 000000000..793716cef
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_arch_parser.js
@@ -0,0 +1,16 @@
+import { GanttArchParser } from "@web_gantt/gantt_arch_parser";
+
+export class TaskGanttArchParser extends GanttArchParser {
+ parse() {
+ const archInfo = super.parse(...arguments);
+ const decorationFields = new Set([...archInfo.decorationFields, "project_id"]);
+ if (archInfo.dependencyEnabled) {
+ decorationFields.add("allow_task_dependencies");
+ decorationFields.add("display_warning_dependency_in_gantt");
+ }
+ return {
+ ...archInfo,
+ decorationFields: [...decorationFields],
+ };
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_controller.js b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_controller.js
new file mode 100644
index 000000000..27b97ca9d
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_controller.js
@@ -0,0 +1,3 @@
+import { GanttController } from "@web_gantt/gantt_controller";
+
+export class TaskGanttController extends GanttController {}
diff --git a/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_milestones.scss b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_milestones.scss
new file mode 100644
index 000000000..544e091a2
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_milestones.scss
@@ -0,0 +1,128 @@
+:root {
+ --o-project-milestone-diamond-center: calc(var(--o-project-milestone-diamond-size) / 2);
+ --o-project-milestone-diamond-size: 20px;
+ // 0.707106781186548 being cos(45°), 1.414213562373095 is the length of a diagonal of a square of side length 1
+ // As such the border height needed to construct a triangle is the half of it, so 0.707106781186548.
+ --o-project-milestone-half-diamond-border-border-size: calc(0.707106781186548 * var(--o-project-milestone-diamond-size));
+ --o-project-milestone-half-diamond-border-size: calc(var(--o-project-milestone-half-diamond-border-border-size) - 2px);
+ --o-project-deadline-circle-center: calc(var(--o-project-deadline-circle-size) / 2);
+ --o-project-deadline-circle-size: 10px;
+}
+@mixin o_project_milestone {
+ position: absolute;
+ z-index: 1;
+}
+.o_milestones_reached {
+ color: #00a09d;
+}
+.o_unreached_milestones {
+ color: #d3413b;
+}
+.o_project_milestone_diamond {
+ @include o_project_milestone;
+ .o_milestones_reached {
+ font-size: 10px;
+ }
+ &:not(.edge_slot) {
+ background-color: mix(#00a09d, $o-view-background-color, 10%);
+ border: solid #00a09d 1px;
+ bottom: calc( -1 * var(--o-project-milestone-diamond-center));
+ height: var(--o-project-milestone-diamond-size);
+ right: calc( -1 * var(--o-project-milestone-diamond-center));
+ transform: rotate(45deg);
+ transform-origin: center;
+ width: var(--o-project-milestone-diamond-size);
+ &.o_unreached_milestones {
+ background-color: mix(#d3413b, $o-view-background-color, 10%);
+ border: solid #d3413b 1px;
+ }
+ .o_milestones_reached {
+ position: absolute;
+ margin: 0;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) rotate(-45deg);
+ transform-origin: center;
+ }
+ &.o_project_deadline_milestone, &.o_project_startdate_milestone {
+ border-radius: 50%;
+ }
+ }
+ &.edge_slot {
+ border-bottom: var(--o-project-milestone-half-diamond-border-border-size) solid transparent;
+ border-right: var(--o-project-milestone-half-diamond-border-border-size) solid #00a09d;
+ border-top: var(--o-project-milestone-half-diamond-border-border-size) solid transparent;
+ bottom: calc(-1 * var(--o-project-milestone-half-diamond-border-border-size));
+ height: 0;
+ right: 0;
+ width: 0;
+ &.o_unreached_milestones {
+ border-right-color: #d3413b;
+ &:after {
+ border-right-color: #fbeceb;
+ }
+ }
+ &:after{
+ @include o_project_milestone;
+ border-bottom: var(--o-project-milestone-half-diamond-border-size) solid transparent;
+ border-right: var(--o-project-milestone-half-diamond-border-size) solid #e6f6f5;
+ border-top: var(--o-project-milestone-half-diamond-border-size) solid transparent;
+ content: '';
+ height: 0;
+ left: 1px;
+ top: calc(-1 * var(--o-project-milestone-half-diamond-border-size));
+ width: 0;
+ }
+ .o_milestones_reached {
+ position: absolute;
+ z-index: 2;
+ top: calc(-0.5 * var(--o-project-milestone-half-diamond-border-size));
+ left: 3px;
+ }
+ }
+}
+
+.o_project_deadline_circle, .o_project_startdate_circle, .o_project_edge_startdate_circle {
+ @include o_project_milestone;
+ bottom: calc( -1 * var(--o-project-deadline-circle-center));
+ height: var(--o-project-deadline-circle-size);
+ width: var(--o-project-deadline-circle-size);
+ border-radius: 50%;
+
+ &.o_project_deadline_circle {
+ right: calc( -1 * var(--o-project-deadline-circle-center));
+ background-color: $o-danger;
+ }
+
+ &.o_project_startdate_circle {
+ right: calc( -1 * var(--o-project-deadline-circle-center));
+ background-color: $o-success;
+ }
+
+ &.o_project_edge_startdate_circle {
+ left: calc( -1 * var(--o-project-deadline-circle-center));
+ background-color: $o-success;
+ }
+}
+
+.o_gantt_row_total,.o_gantt_cells {
+ .o_project_milestone {
+ pointer-events: none;
+ position: relative;
+ @include o-gantt-zindex(interact);
+ border-right: 2px #00a09d solid;
+ &.o_unreached_milestones {
+ border-right: 2px #d3413b solid;
+ }
+ &.o_startdate_pin {
+ border-right: 2px $o-success solid;
+ }
+ }
+
+ .o_edge_startdate_pin {
+ pointer-events: none;
+ position: relative;
+ @include o-gantt-zindex(interact);
+ border-left: 2px $o-success solid;
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_model.js b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_model.js
new file mode 100644
index 000000000..a7d6aedbe
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_model.js
@@ -0,0 +1,260 @@
+import { _t } from "@web/core/l10n/translation";
+import { deserializeDate, deserializeDateTime, serializeDateTime } from "@web/core/l10n/dates";
+import { GanttModel } from "@web_gantt/gantt_model";
+import { sortBy } from "@web/core/utils/arrays";
+import { Domain } from "@web/core/domain";
+import { useProjectModelActions } from "../project_highlight_tasks";
+
+const MAP_MANY_2_MANY_FIELDS = [
+ {
+ many2many_field: "personal_stage_type_ids",
+ many2one_field: "personal_stage_type_id",
+ },
+];
+
+export class TaskGanttModel extends GanttModel {
+ //-------------------------------------------------------------------------
+ // Public
+ //-------------------------------------------------------------------------
+
+ setup() {
+ super.setup(...arguments);
+ this.getHighlightIds = useProjectModelActions({
+ getContext: () => this.env.searchModel._context,
+ getHighlightPlannedIds: () => this.env.searchModel.highlightPlannedIds,
+ }).getHighlightIds;
+ }
+
+ getDialogContext() {
+ const context = super.getDialogContext(...arguments);
+ this._replaceSpecialMany2manyKeys(context);
+ if ("user_ids" in context && !context.user_ids) {
+ delete context.user_ids;
+ }
+ return context;
+ }
+
+ toggleHighlightPlannedFilter(ids) {
+ super.toggleHighlightPlannedFilter(...arguments);
+ this.env.searchModel.toggleHighlightPlannedFilter(ids);
+ }
+
+ /**
+ * @override
+ */
+ reschedule(ids, schedule, callback) {
+ if (!schedule.smart_task_scheduling) {
+ return super.reschedule(...arguments);
+ }
+ if (!Array.isArray(ids)) {
+ ids = [ids];
+ }
+
+ const allData = this._scheduleToData(schedule);
+ const endDateTime = deserializeDateTime(allData.date_deadline).endOf(
+ this.metaData.scale.id
+ );
+
+ const data = this.removeRedundantData(allData, ids);
+ delete data.name;
+ return this.mutex.exec(async () => {
+ try {
+ const result = await this.orm.call(
+ this.metaData.resModel,
+ "schedule_tasks",
+ [ids, data],
+ {
+ context: {
+ ...this.searchParams.context,
+ last_date_view: serializeDateTime(endDateTime),
+ cell_part: this.metaData.scale.cellPart,
+ },
+ }
+ );
+ if (result && Array.isArray(result) && result.length > 1) {
+ this.toggleHighlightPlannedFilter(Object.keys(result[1]).map(Number));
+ }
+ if (callback) {
+ callback(result);
+ }
+ } finally {
+ this.fetchData();
+ }
+ });
+ }
+
+ _reschedule(ids, data, context) {
+ return this.orm.call(this.metaData.resModel, "web_gantt_write", [ids, data], {
+ context,
+ });
+ }
+
+ async unscheduleTask(id) {
+ await this.orm.call("project.task", "action_unschedule_task", [id]);
+ this.fetchData();
+ }
+
+ //-------------------------------------------------------------------------
+ // Protected
+ //-------------------------------------------------------------------------
+
+ /**
+ * Retrieve the milestone data based on the task domain and the project deadline if applicable.
+ * @override
+ */
+ async _fetchData(metaData, additionalContext) {
+ const globalStart = metaData.globalStart.toISODate();
+ const globalStop = metaData.globalStop.toISODate();
+ const scale = metaData.scale.unit;
+ additionalContext = {
+ ...(additionalContext || {}),
+ gantt_start_date: globalStart,
+ gantt_scale: scale,
+ };
+ const proms = [this.getHighlightIds(), super._fetchData(metaData, additionalContext)];
+ let milestones = [];
+ const projectDeadlines = [];
+ const projectStartDates = [];
+ if (!this.orm.isSample && !this.env.isSmall) {
+ const prom = this.orm
+ .call("project.task", "get_all_deadlines", [globalStart, globalStop], {
+ context: this.searchParams.context,
+ })
+ .then(({ milestone_id, project_id }) => {
+ milestones = milestone_id.map((m) => ({
+ ...m,
+ deadline: deserializeDate(m.deadline),
+ }));
+ for (const project of project_id) {
+ const dateEnd = project.date;
+ const dateStart = project.date_start;
+ if (dateEnd >= globalStart && dateEnd <= globalStop) {
+ projectDeadlines.push({
+ ...project,
+ date: deserializeDate(dateEnd),
+ });
+ }
+ if (dateStart >= globalStart && dateStart <= globalStop) {
+ projectStartDates.push({
+ ...project,
+ date: deserializeDate(dateStart),
+ });
+ }
+ }
+ });
+ proms.push(prom);
+ }
+ this.highlightIds = (await Promise.all(proms))[0];
+ this.data.milestones = sortBy(milestones, (m) => m.deadline);
+ this.data.projectDeadlines = sortBy(projectDeadlines, (d) => d.date);
+ this.data.projectStartDates = sortBy(projectStartDates, (d) => d.date);
+ }
+
+ /**
+ * @override
+ */
+ _generateRows(metaData, params) {
+ const { groupedBy, groups, parentGroup } = params;
+ if (groupedBy.length) {
+ const groupedByField = groupedBy[0];
+ if (groupedByField === "user_ids") {
+ // Here we are generating some rows under a common "parent" (if any).
+ // We make sure that a row with resId = false for "user_id"
+ // ('Unassigned Tasks') and same "parent" will be added by adding
+ // a suitable fake group to groups (a subset of the groups returned
+ // by read_group).
+ const fakeGroup = Object.assign({}, ...parentGroup);
+ groups.push(fakeGroup);
+ }
+ }
+ const rows = super._generateRows(...arguments);
+
+ // keep empty row to the head and sort the other rows alphabetically
+ // except when grouping by stage or personal stage
+ if (!["stage_id", "personal_stage_type_ids"].includes(groupedBy[0])) {
+ rows.sort((a, b) => {
+ if (a.resId && !b.resId) {
+ return 1;
+ } else if (!a.resId && b.resId) {
+ return -1;
+ } else {
+ return a.name.localeCompare(b.name);
+ }
+ });
+ }
+ return rows;
+ }
+
+ /**
+ * @override
+ */
+ _getRowName(_, groupedByField, value) {
+ if (!value) {
+ if (groupedByField === "user_ids") {
+ return _t("👤 Unassigned");
+ } else if (groupedByField === "project_id") {
+ return _t("🔒 Private");
+ }
+ }
+ return super._getRowName(...arguments);
+ }
+
+ /**
+ * In the case of special Many2many Fields, like personal_stage_type_ids in project.task
+ * model, we don't want to write the many2many field but use the inverse method of the
+ * linked Many2one field, in this case the personal_stage_type_id, to create or update the
+ * record - here set the stage_id - in the personal_stage_type_ids.
+ *
+ * This is mandatory since the python ORM doesn't support the creation of
+ * a personnal stage from scratch. If this method is not overriden, then an entry
+ * will be inserted in the project_task_user_rel.
+ * One for the faked Many2many user_ids field (1), and a second one for the other faked
+ * Many2many personal_stage_type_ids field (2).
+ *
+ * While the first one meets the constraint on the project_task_user_rel, the second one
+ * fails because it specifies no user_id; It tries to insert (task_id, stage_id) into the
+ * relation.
+ *
+ * If we don't remove those key from the context, the ORM will face two problems :
+ * - It will try to insert 2 entries in the project_task_user_rel
+ * - It will try to insert an incorrect entry in the project_task_user_rel
+ *
+ * @param {Object} object
+ */
+ _replaceSpecialMany2manyKeys(object) {
+ for (const { many2many_field, many2one_field } of MAP_MANY_2_MANY_FIELDS) {
+ if (many2many_field in object) {
+ object[many2one_field] = object[many2many_field][0];
+ delete object[many2many_field];
+ }
+ }
+ }
+
+ /**
+ * @override
+ */
+ _scheduleToData() {
+ const data = super._scheduleToData(...arguments);
+ this._replaceSpecialMany2manyKeys(data);
+ return data;
+ }
+
+ /**
+ * @override
+ */
+ load(searchParams) {
+ const { context, domain, groupBy } = searchParams;
+ let displayUnassigned = false;
+ if (groupBy.length === 0 || groupBy[groupBy.length - 1] === "user_ids") {
+ for (const node of domain) {
+ if (node.length === 3 && node[0] === "user_ids.name" && node[1] === "ilike") {
+ displayUnassigned = true;
+ }
+ }
+ }
+ if (displayUnassigned) {
+ searchParams.domain = Domain.or([domain, "[('user_ids', '=', false)]"]).toList();
+ }
+ return super.load({ ...searchParams, context: { ...context }, displayUnassigned });
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_renderer.js b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_renderer.js
new file mode 100644
index 000000000..dced5db27
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_renderer.js
@@ -0,0 +1,320 @@
+import { SelectCreateAutoPlanDialog } from "@project_gantt/views/view_dialogs/select_auto_plan_create_dialog";
+import { _t } from "@web/core/l10n/translation";
+import { Avatar } from "@mail/views/web/fields/avatar/avatar";
+import { markup, onWillUnmount, useEffect } from "@odoo/owl";
+import { localization } from "@web/core/l10n/localization";
+import { usePopover } from "@web/core/popover/popover_hook";
+import { useService } from "@web/core/utils/hooks";
+import { GanttRenderer } from "@web_gantt/gantt_renderer";
+import { escape } from "@web/core/utils/strings";
+import { MilestonesPopover } from "./milestones_popover";
+import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
+import { formatFloatTime } from "@web/views/fields/formatters";
+
+export class TaskGanttRenderer extends GanttRenderer {
+ static components = {
+ ...GanttRenderer.components,
+ Avatar,
+ };
+ static headerTemplate = "project_gantt.TaskGanttRenderer.Header";
+ static rowHeaderTemplate = "project_gantt.TaskGanttRenderer.RowHeader";
+ static rowContentTemplate = "project_gantt.TaskGanttRenderer.RowContent";
+ static totalRowTemplate = "project_gantt.TaskGanttRenderer.TotalRow";
+ static pillTemplate = "project_gantt.TaskGanttRenderer.Pill";
+ setup() {
+ super.setup(...arguments);
+ this.notificationService = useService("notification");
+ this.orm = useService("orm");
+ useEffect(
+ (el) => el.classList.add("o_project_gantt"),
+ () => [this.gridRef.el]
+ );
+ const position = localization.direction === "rtl" ? "bottom" : "right";
+ this.milestonePopover = usePopover(MilestonesPopover, { position });
+ onWillUnmount(() => {
+ this.notificationFn?.();
+ });
+ }
+
+ /**
+ * @override
+ */
+ enrichPill(pill) {
+ const enrichedPill = super.enrichPill(pill);
+ if (enrichedPill?.record) {
+ if (
+ this.props.model.highlightIds &&
+ !this.props.model.highlightIds.includes(enrichedPill.record.id)
+ ) {
+ pill.className += " opacity-25";
+ }
+ }
+ return enrichedPill;
+ }
+
+ computeVisibleColumns() {
+ super.computeVisibleColumns();
+ this.columnMilestones = {}; // deadlines and milestones by project
+ for (const column of this.columns) {
+ this.columnMilestones[column.id] = {
+ hasDeadLineExceeded: false,
+ allReached: true,
+ projects: {},
+ hasMilestone: false,
+ hasDeadline: false,
+ hasStartDate: false,
+ };
+ }
+ // Handle start date at the beginning of the current period
+ this.columnMilestones[this.columns[0].id].edge = {
+ projects: {},
+ hasStartDate: false,
+ };
+ const projectStartDates = [...this.model.data.projectStartDates];
+ const projectDeadlines = [...this.model.data.projectDeadlines];
+ const milestones = [...this.model.data.milestones];
+
+ let project = projectStartDates.shift();
+ let projectDeadline = projectDeadlines.shift();
+ let milestone = milestones.shift();
+ let i = 0;
+ while (i < this.columns.length && (project || projectDeadline || milestone)) {
+ const column = this.columns[i];
+ const nextColumn = this.columns[i + 1];
+ const info = this.columnMilestones[column.id];
+
+ if (i == 0 && project && column && column.stop > project.date) {
+ // For the first column, start dates have to be displayed at the start of the period
+ if (!info.edge.projects[project.id]) {
+ info.edge.projects[project.id] = {
+ milestones: [],
+ id: project.id,
+ name: project.name,
+ };
+ }
+ info.edge.projects[project.id].isStartDate = true;
+ info.edge.hasStartDate = true;
+ project = projectStartDates.shift();
+ } else if (project && nextColumn?.stop > project.date) {
+ if (!info.projects[project.id]) {
+ info.projects[project.id] = {
+ milestones: [],
+ id: project.id,
+ name: project.name,
+ };
+ }
+ info.projects[project.id].isStartDate = true;
+ info.hasStartDate = true;
+ project = projectStartDates.shift();
+ }
+
+ if (projectDeadline && column.stop > projectDeadline.date) {
+ if (!info.projects[projectDeadline.id]) {
+ info.projects[projectDeadline.id] = {
+ milestones: [],
+ id: projectDeadline.id,
+ name: projectDeadline.name,
+ };
+ }
+ info.projects[projectDeadline.id].isDeadline = true;
+ info.hasDeadline = true;
+ projectDeadline = projectDeadlines.shift();
+ }
+
+ if (milestone && column.stop > milestone.deadline) {
+ const [projectId, projectName] = milestone.project_id;
+ if (!info.projects[projectId]) {
+ info.projects[projectId] = {
+ milestones: [],
+ id: projectId,
+ name: projectName,
+ };
+ }
+ const { is_deadline_exceeded, is_reached } = milestone;
+ info.projects[projectId].milestones.push(milestone);
+ info.hasMilestone = true;
+ milestone = milestones.shift();
+ if (is_deadline_exceeded) {
+ info.hasDeadLineExceeded = true;
+ }
+ if (!is_reached) {
+ info.allReached = false;
+ }
+ }
+ if (
+ (!project || !nextColumn || nextColumn?.stop < project.date) &&
+ (!projectDeadline || column.stop < projectDeadline.date) &&
+ (!milestone || column.stop < milestone.deadline)
+ ) {
+ i++;
+ }
+ }
+ }
+
+ computeDerivedParams() {
+ this.rowsWithAvatar = {};
+ super.computeDerivedParams();
+ }
+
+ getConnectorAlert(masterRecord, slaveRecord) {
+ if (
+ masterRecord.display_warning_dependency_in_gantt &&
+ slaveRecord.display_warning_dependency_in_gantt
+ ) {
+ return super.getConnectorAlert(...arguments);
+ }
+ }
+
+ getPopoverProps(pill) {
+ const props = super.getPopoverProps(...arguments);
+ const { record } = pill;
+ if (record.planning_overlap) {
+ props.context.planningOverlapHtml = markup(record.planning_overlap);
+ }
+ props.context.allocated_hours = formatFloatTime(props.context.allocated_hours);
+ return props;
+ }
+
+ getAvatarProps(row) {
+ return this.rowsWithAvatar[row.id];
+ }
+
+ getSelectCreateDialogProps() {
+ const props = super.getSelectCreateDialogProps(...arguments);
+ const onCreateEdit = () => {
+ this.dialogService.add(FormViewDialog, {
+ context: props.context,
+ resModel: props.resModel,
+ onRecordSaved: async (record) => {
+ await record.save({ reload: false });
+ await this.model.fetchData();
+ },
+ });
+ };
+ const onSelectedAutoPlan = (resIds) => {
+ props.context.smart_task_scheduling = true;
+ if (resIds.length) {
+ this.model.reschedule(
+ resIds,
+ props.context,
+ this.openPlanDialogCallback.bind(this)
+ );
+ }
+ };
+ props.onSelectedNoSmartSchedule = props.onSelected;
+ props.onSelected = onSelectedAutoPlan;
+ props.onCreateEdit = onCreateEdit;
+ return props;
+ }
+
+ hasAvatar(row) {
+ return row.id in this.rowsWithAvatar;
+ }
+
+ getNotificationOnSmartSchedule(warningString, old_vals_per_task_id) {
+ this.notificationFn?.();
+ this.notificationFn = this.notificationService.add(
+ markup(
+ `${escape(
+ warningString
+ )}`
+ ),
+ {
+ type: "success",
+ sticky: true,
+ buttons: [
+ {
+ name: "Undo",
+ icon: "fa-undo",
+ onClick: async () => {
+ const ids = Object.keys(old_vals_per_task_id).map(Number);
+ await this.orm.call("project.task", "action_rollback_auto_scheduling", [
+ ids,
+ old_vals_per_task_id,
+ ]);
+ this.model.toggleHighlightPlannedFilter(false);
+ this.notificationFn();
+ await this.model.fetchData();
+ },
+ },
+ ],
+ }
+ );
+ }
+
+ openPlanDialogCallback(res) {
+ if (res && Array.isArray(res)) {
+ const warnings = Object.entries(res[0]);
+ const old_vals_per_task_id = res[1];
+ for (const warning of warnings) {
+ this.notificationService.add(warning[1], {
+ title: _t("Warning"),
+ type: "warning",
+ sticky: true,
+ });
+ }
+ if (warnings.length === 0) {
+ this.getNotificationOnSmartSchedule(
+ _t("Tasks have been successfully scheduled for the upcoming periods."),
+ old_vals_per_task_id
+ );
+ }
+ }
+ }
+
+ processRow(row) {
+ const { groupedByField, name, resId } = row;
+ if (groupedByField === "user_ids" && Boolean(resId)) {
+ const { fields } = this.model.metaData;
+ const resModel = fields.user_ids.relation;
+ this.rowsWithAvatar[row.id] = { resModel, resId, displayName: name };
+ }
+ return super.processRow(...arguments);
+ }
+
+ shouldRenderRecordConnectors(record) {
+ if (record.allow_task_dependencies) {
+ return super.shouldRenderRecordConnectors(...arguments);
+ }
+ return false;
+ }
+
+ highlightPill(pillId, highlighted) {
+ if (!this.connectorDragState.dragging) {
+ return super.highlightPill(pillId, highlighted);
+ }
+ const pill = this.pills[pillId];
+ if (!pill) {
+ return;
+ }
+ const { record } = pill;
+ if (!this.shouldRenderRecordConnectors(record)) {
+ return super.highlightPill(pillId, false);
+ }
+ return super.highlightPill(pillId, highlighted);
+ }
+
+ onPlan(rowId, columnStart, columnStop) {
+ const { start, stop } = this.getColumnStartStop(columnStart, columnStop);
+ this.dialogService.add(
+ SelectCreateAutoPlanDialog,
+ this.getSelectCreateDialogProps({ rowId, start, stop, withDefault: true })
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ onMilestoneMouseEnter(ev, projects) {
+ this.milestonePopover.open(ev.target, {
+ displayMilestoneDates: this.model.metaData.scale.id === "year",
+ displayProjectName: !this.model.searchParams.context.default_project_id,
+ projects,
+ });
+ }
+
+ onMilestoneMouseLeave() {
+ this.milestonePopover.close();
+ }
+}
diff --git a/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_renderer.xml b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_renderer.xml
new file mode 100644
index 000000000..2097950be
--- /dev/null
+++ b/addons_extensions/project_gantt/static/src/views/task_gantt/task_gantt_renderer.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+