Files
enterprise-addons/project_enterprise/models/project_task.py
T

1815 lines
93 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from pytz import utc, timezone
from collections import defaultdict
from datetime import timedelta, datetime, time
from dateutil.relativedelta import relativedelta
import pytz
from odoo import api, fields, models
from odoo.fields import Domain
from odoo.exceptions import UserError
from odoo.tools import _, format_list, topological_sort, get_lang, babel_locale_parse
from odoo.tools.intervals import Intervals
from odoo.tools.date_utils import sum_intervals, get_timedelta, weeknumber, weekstart, weekend, parse_date
from odoo.tools.sql import SQL
from odoo.addons.resource.models.utils import filter_domain_leaf
PROJECT_TASK_WRITABLE_FIELDS = {
'planned_date_begin',
}
class ProjectTask(models.Model):
_inherit = "project.task"
planned_date_begin = fields.Datetime("Start date", tracking=True, copy=False)
# 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")
_planned_dates_check = models.Constraint(
'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 TASK_PORTAL_WRITABLE_FIELDS(self):
return super().TASK_PORTAL_WRITABLE_FIELDS | PROJECT_TASK_WRITABLE_FIELDS
@api.model
def default_get(self, fields):
result = super().default_get(fields)
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):
for task in self:
if not task.date_deadline or not task.planned_date_begin:
task.allocated_hours = 0
continue
date_begin, date_end = task._calculate_planned_dates(
task.planned_date_begin,
task.date_deadline,
user_id=task.user_ids.ids if len(task.user_ids) == 1 else None,
calendar=task.env.company.resource_calendar_id if len(task.user_ids) != 1 else None,
)
if len(task.user_ids) == 1:
tz = task.user_ids.tz or 'UTC'
# We need to browse on res.users in order to bypass the new origin id
work_intervals, _dummy = task.user_ids.sudo()._get_valid_work_intervals(
date_begin.astimezone(timezone(tz)),
date_end.astimezone(timezone(tz))
)
work_duration = sum_intervals(work_intervals[task.user_ids._ids[0]])
else:
tz = task.env.company.resource_calendar_id.tz or 'UTC'
work_duration = task.env.company.resource_calendar_id.get_work_hours_count(
date_begin.astimezone(timezone(tz)),
date_end.astimezone(timezone(tz)),
compute_leaves=False
)
task.allocated_hours = round(work_duration, 2)
def _fetch_planning_overlap(self, additional_domain=None):
domain = 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 &= Domain(additional_domain)
domain = domain.optimize_full(self)
task1 = self._table
query = self._search(domain & Domain('id', 'in', self.ids), bypass_access=True)
# join for overlapping tasks (task2)
task2 = query.make_alias(task1, 'T2')
query.add_join('JOIN', task2, self._table_sql, SQL(
"%s != %s AND (%s::TIMESTAMP, %s::TIMESTAMP) OVERLAPS (%s::TIMESTAMP, %s::TIMESTAMP)",
SQL.identifier(task1, 'id'),
SQL.identifier(task2, 'id'),
SQL.identifier(task1, 'planned_date_begin'),
SQL.identifier(task1, 'date_deadline'),
SQL.identifier(task2, 'planned_date_begin'),
SQL.identifier(task2, 'date_deadline'),
))
query.add_where(domain._to_sql(self, task2, query))
# overlapping tasks must be for the same user
task1_user_rel = query.join(task1, 'id', 'project_task_user_rel', 'task_id', 'TU1')
task2_user_rel = query.join(task2, 'id', 'project_task_user_rel', 'task_id', 'TU2')
query.add_where(SQL(
"%s = %s",
SQL.identifier(task1_user_rel, 'user_id'),
SQL.identifier(task2_user_rel, 'user_id'),
))
# group by task, user, partner name, and order by partner name
task1_user = query.join(task1_user_rel, 'user_id', 'res_users', 'id', 'U')
task1_partner = query.join(task1_user, 'partner_id', 'res_partner', 'id', 'P')
task1_partner_name = self.env['res.partner']._field_to_sql(task1_partner, 'name', query)
query.groupby = SQL(", ").join([
SQL.identifier(task1, 'id'),
SQL.identifier(task1_user, 'id'),
task1_partner_name,
])
query.order = task1_partner_name
sql = query.select(
SQL.identifier(task1, 'id'),
SQL.identifier(task1, 'planned_date_begin'),
SQL.identifier(task1, 'date_deadline'),
SQL("ARRAY_AGG(%s) AS task_ids", SQL.identifier(task2, 'id')),
SQL("MIN(%s)", SQL.identifier(task2, 'planned_date_begin')),
SQL("MAX(%s)", SQL.identifier(task2, 'date_deadline')),
SQL("%s AS user_id", SQL.identifier(task1_user, 'id')),
SQL("%s AS partner_name", task1_partner_name),
SQL("%s", self._field_to_sql(task1, 'allocated_hours', query)),
SQL("SUM(%s)", self._field_to_sql(task2, 'allocated_hours', query)),
)
return self.env.execute_query_dict(sql)
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', 'allocated_hours')
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))
regular_users_ids = []
flexible_resources_ids = []
flex_user_resource = {}
flex_resource_user_id = {}
for user in users:
resource = user._get_project_task_resource()
if resource and resource._is_flexible():
flexible_resources_ids.append(resource.id)
flex_user_resource[user.id] = resource
flex_resource_user_id[resource.id] = user.id
else:
regular_users_ids.append(user.id)
users_work_intervals, _dummy = self.env['res.users'].browse(regular_users_ids).sudo()._get_valid_work_intervals(absolute_min_start, absolute_max_end)
flex_resources_work_intervals, flex_user_work_hours_per_day, flex_user_work_hours_per_week = self.env["resource.resource"].browse(flexible_resources_ids)._get_flexible_resource_valid_work_intervals(absolute_min_start, absolute_max_end)
for resource_id, intervals in flex_resources_work_intervals.items():
users_work_intervals[flex_resource_user_id[resource_id]] = intervals
res = {}
for task in self:
overlap_messages = []
for user_id, task_mapping in overlap_mapping.get(task.id, {}).items():
task_intervals_start = utc.localize(task_mapping['min_planned_date_begin'])
task_intervals_end = utc.localize(task_mapping['max_date_deadline'])
task_intervals = Intervals([(task_intervals_start, task_intervals_end, self.env['resource.calendar.attendance'])])
work_intervals = users_work_intervals[user_id] & task_intervals
if resource := flex_user_resource.get(user_id):
work_hours = resource._get_flexible_resource_work_hours(work_intervals, flex_user_work_hours_per_day[resource.id], flex_user_work_hours_per_week[resource.id])
else:
work_hours = sum_intervals(work_intervals)
if task_mapping['sum_allocated_hours'] > work_hours:
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):
# search implemented only for 'in' {bool}
if operator != 'in':
return NotImplemented
if not all(isinstance(v, bool) for v in value):
return NotImplemented
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 any(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
and (tasks_with_task_dependencies := self.filtered('allow_task_dependencies'))
):
self.dependency_warning = False
return
(self - tasks_with_task_dependencies).dependency_warning = False
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.env.cr.execute(query, (tuple(tasks_with_task_dependencies.ids),))
depends_on_names_for_id = {
group['id']: group['depends_on_names']
for group in self.env.cr.dictfetchall()
}
for task in tasks_with_task_dependencies:
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=depends_on_names,
)
@api.model
def _search_dependency_warning(self, operator, value):
# search implemented only for 'in' {bool}
if operator != 'in':
return NotImplemented
if not all(isinstance(v, bool) for v in value):
return NotImplemented
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 any(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 copy_data(self, default=None):
vals_list = super().copy_data(default)
if self.env.context.get('convert_to_template'):
for task, vals in zip(self, vals_list):
vals['planned_date_begin'] = task.planned_date_begin
return vals_list
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):
if len(self) > 1:
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
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.sudo()._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)
vals['planned_date_begin'] = date_start
vals['date_deadline'] = date_stop
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_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.sudo().user_ids._get_project_task_resource()
if len(resource) == 1 and resource.calendar_id:
# 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 - compute_allocated_hours).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.sudo().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.env.context.get('gantt_start_date')
scale = self.env.context.get('gantt_scale')
if not (start_date and scale):
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
start_date = fields.Datetime.from_string(start_date)
delta = get_timedelta(1, scale)
domain_expand = (
Domain(self._group_expand_user_ids_domain([
('planned_date_begin', '>=', start_date - delta),
('date_deadline', '<', start_date + delta)
])) & domain
)
users = self.env['res.users'].search([('task_ids', 'any', domain_expand), ('share', '=', False), ('active', '=', True)])
return users | self.env.user
def _group_expand_user_ids_domain(self, domain_expand):
project_id = self.env.context.get('default_project_id')
if project_id:
domain_expand = Domain.OR([[
('project_id', '=', project_id),
('is_closed', '=', False),
('planned_date_begin', '=', False),
('date_deadline', '=', False),
], domain_expand])
else:
domain_expand = Domain.AND([[
('project_id', '!=', False),
], domain_expand])
return domain_expand
@api.model
def _group_expand_project_ids(self, projects, domain):
start_date = self.env.context.get('gantt_start_date')
scale = self.env.context.get('gantt_scale')
default_project_id = self.env.context.get('default_project_id')
is_my_task = self.env.context.get('my_tasks')
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 = list(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.env.context.get('gantt_start_date')
scale = self.env.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 parse_date(dom[2], self.env)
min_date = min_date - get_timedelta(1, self.env.context.get('gantt_scale'))
filters.append((dom[0], dom[1], min_date))
else:
filters.append(dom)
return filters
def _get_users_available_work_intervals(self, start_datetime, end_datetime):
users_work_intervals, calendar_work_intervals = self.user_ids._get_valid_work_intervals(start_datetime, end_datetime)
company = self.user_ids.company_id if self.user_ids.company_id.id else self.env.company
company_work_intervals = calendar_work_intervals.get(company.resource_calendar_id.id, company.resource_calendar_id._work_intervals_batch(start_datetime, end_datetime)[False])
available_work_intervals = None
for user in self.user_ids:
work_intervals = users_work_intervals.get(user.id)
if not work_intervals:
continue
if available_work_intervals is None:
available_work_intervals = work_intervals
else:
available_work_intervals &= work_intervals
if not available_work_intervals:
available_work_intervals = company_work_intervals
return available_work_intervals
def plan_task_in_calendar(self, vals):
self.ensure_one()
if planned_date_begin := vals.get("planned_date_begin"):
tz_info = self.env.context.get('tz') or self.env.user.tz or 'UTC'
planned_date_begin = datetime.strptime(planned_date_begin, '%Y-%m-%d %H:%M:%S').astimezone(timezone(tz_info))
if self.allocated_hours:
# expected days + one month in case the current user took some day offs in the future
max_date_end = planned_date_begin + relativedelta(days=self.allocated_hours / 8, months=1)
available_work_intervals = self._get_users_available_work_intervals(planned_date_begin, max_date_end)
hours_to_plan = self.allocated_hours
compute_date_end = None
for start_date, end_date, _dummy in available_work_intervals:
hours_to_plan -= (end_date - start_date).total_seconds() / 3600
if hours_to_plan <= 0:
compute_date_end = end_date + relativedelta(seconds=hours_to_plan * 3600)
break
if available_work_intervals:
if not compute_date_end:
compute_date_end = available_work_intervals._items[-1][1]
if self.env.context.get('task_calendar_plan_full_day'):
vals['planned_date_begin'] = available_work_intervals._items[0][0].astimezone(utc).replace(tzinfo=None)
if compute_date_end:
vals['date_deadline'] = compute_date_end.astimezone(utc).replace(tzinfo=None)
elif self.env.context.get('task_calendar_plan_full_day'):
planned_date_begin += relativedelta(hour=0, minute=0, second=0, microsecond=0)
planned_date_end = datetime.strptime(vals['date_deadline'], '%Y-%m-%d %H:%M:%S').astimezone(timezone(tz_info))
planned_date_end += relativedelta(hour=23, minute=59, second=59, microsecond=59)
available_work_intervals = self._get_users_available_work_intervals(planned_date_begin, planned_date_end)
if available_work_intervals:
vals['planned_date_begin'] = available_work_intervals._items[0][0].astimezone(utc).replace(tzinfo=None)
vals['date_deadline'] = available_work_intervals._items[-1][1].astimezone(utc).replace(tzinfo=None)
return super().plan_task_in_calendar(vals)
# -------------------------------------
# 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 {}
max_date_start = datetime.strptime(self.env.context.get('last_date_view'), '%Y-%m-%d %H:%M:%S')
return self.sorted(
lambda t: (not t.date_deadline, t.date_deadline, t._get_hours_to_plan() <= 0, -int(t.priority))
)._scheduling(vals, max_date_start)
def _get_dependencies_dict(self):
# contains a task as key and the list of tasks before this one as values
return {
task:
[t for t in task.depend_on_ids if t != task and t in self]
if task.depend_on_ids
else []
for task in self
}
def _scheduling(self, vals, max_date_start, first_possible_date_per_task=None):
if first_possible_date_per_task is None:
first_possible_date_per_task = {}
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.env.context.get('tz') or 'UTC'
locale = babel_locale_parse(get_lang(self.env).code)
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
date_start = datetime.strptime(vals["planned_date_begin"], '%Y-%m-%d %H:%M:%S').astimezone(timezone(tz_info))
fetch_date_end = max_date_start.astimezone(timezone(tz_info))
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, flex_user_work_hours_per_day, flex_user_work_hours_per_week = 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()
first_possible_date_per_task = {
key: max(
first_possible_date_per_task.get(key, datetime.min),
dependent_tasks_end_dates.get(key, datetime.min),
).astimezone(timezone(tz_info))
for key in first_possible_date_per_task.keys() | dependent_tasks_end_dates.keys()
}
scale = self.env.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.env.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
sorted_tasks = topological_sort(self._get_dependencies_dict())
def update_used_intervals(valid_intervals_per_user, intervals, user_ids):
used_intervals = Intervals(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 sorted_tasks:
hours_to_plan = task._get_hours_to_plan()
compute_date_start = compute_date_end = False
first_possible_start_date = first_possible_date_per_task.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
if hours_to_plan <= 0:
hours_to_plan = delta_hours
if user_ids:
hours_to_plan /= len(user_ids)
temp_valid_intervals_per_user = valid_intervals_per_user.copy()
used_intervals = []
while not compute_date_end or hours_to_plan > 0:
for start_date, end_date, _dummy in temp_valid_intervals_per_user[user_ids]:
if first_possible_start_date:
if end_date <= first_possible_start_date:
continue
if first_possible_start_date > start_date:
start_date = first_possible_start_date
# for flexible resources, work intervals are divided (min time of the day, max time of the day)
# a microsecond is lost in the total duration and the end of the interval
# it's the only way to have many intervals, as if end date of a range = start date of the next range,
# both will be merged in one range
if end_date.time() == time.max:
end_date += relativedelta(microseconds=1)
day = start_date.date()
year_and_week = weeknumber(locale, day)
real_interval_duration = (end_date - start_date).total_seconds() / 3600
interval_duration = real_interval_duration
# start_date and end_date are the same day
# we check duration doesn't exceed work hours for flexible resources
for user_id in user_ids or ():
if user_id in flex_user_work_hours_per_day:
interval_duration = min(interval_duration, flex_user_work_hours_per_day[user_id].get(day, 0.0), flex_user_work_hours_per_week[user_id].get(year_and_week, 0.0))
if interval_duration <= 0.0:
continue
consumed_hours = interval_duration if hours_to_plan >= interval_duration else hours_to_plan
for user_id in user_ids or ():
if user_id in flex_user_work_hours_per_day:
flex_user_work_hours_per_day[user_id][day] -= consumed_hours
flex_user_work_hours_per_week[user_id][year_and_week] -= consumed_hours
hours_to_plan -= consumed_hours
if not compute_date_start:
compute_date_start = start_date
diff = real_interval_duration - consumed_hours
if diff > 0:
end_date -= relativedelta(hours=diff)
used_intervals.append((start_date, end_date, task))
if hours_to_plan == 0.0:
compute_date_end = end_date
break
# 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, flex_user_work_hours_per_day, flex_user_work_hours_per_week = self._web_gantt_get_valid_intervals(fetch_date_end, new_fetch_date_end, users, [], True, valid_intervals_per_user)
temp_valid_intervals_per_user = valid_intervals_per_user.copy()
fetch_date_end = new_fetch_date_end
update_used_intervals(temp_valid_intervals_per_user, used_intervals, user_ids)
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:
first_possible_date_per_task[next_task.id] = max(first_possible_date_per_task.get(next_task.id, compute_date_end), compute_date_end)
update_used_intervals(valid_intervals_per_user, used_intervals, user_ids)
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):
"""
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 != all(%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, [self.ids, self.ids])
return {res['id']: res['date'] 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
"""
if not self:
return {}
start_date = min(self.mapped(start_date_field_name))
end_date = max(self.mapped(stop_date_field_name))
valid_intervals_per_user, _dummy, _dummy = 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: A list of user IDs for whom the unavailable intervals are being calculated.
:param date_begin: The beginning date of the intervals.
:param date_end: The end date of the intervals.
:param tasks_to_exclude_ids: A list of task IDs to exclude from the already planned tasks.
:return: A dictionary where the keys are user IDs and the values are the unavailable intervals.
:rtype: dict[int, Intervals]
"""
domain = [
('user_ids', 'in', user_ids),
('date_deadline', '>=', date_begin.replace(tzinfo=None)),
('planned_date_begin', '<=', date_end.replace(tzinfo=None)),
]
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: The start date for the intervals.
:param end_date: The end date for the intervals.
:param users: A list of users for whom the intervals are being calculated.
:param candidates_ids: A list of candidate IDs to plan.
:param remove_intervals_with_planned_tasks: Whether to remove intervals with already planned tasks.
:return: A tuple containing:
- valid_intervals_per_user: A dictionary where keys are user IDs and values are lists of valid intervals.
- flex_user_work_hours_per_day: A dictionary where keys are flexible resources users IDs and values are dicts,
keys are days and values are number of available hours per day.
:rtype: tuple(dict[int, List[Interval]], dict[int, dict[date, float]])
"""
if not self:
return {}, {}, {}
flex_resource_user = {}
flex_resources_ids = set()
regular_resources_users_ids = set()
for user in users:
resource = user._get_project_task_resource()
if resource and resource._is_flexible():
flex_resource_user[resource.id] = user.id
flex_resources_ids.add(resource.id)
else:
regular_resources_users_ids.add(user.id)
regular_resources_users = self.env["res.users"].browse(regular_resources_users_ids)
original_start_date, original_end_date = start_date.astimezone(utc), end_date.astimezone(utc)
start_date, end_date = original_start_date, original_end_date
flex_resources = self.env["resource.resource"].browse(flex_resources_ids)
flex_resources_work_intervals, hours_per_day, hours_per_week = flex_resources._get_flexible_resource_valid_work_intervals(start_date, end_date)
users_work_intervals, calendar_work_intervals = regular_resources_users.sudo()._get_valid_work_intervals(start_date, end_date)
locale = babel_locale_parse(get_lang(self.env).code)
if flex_resources:
start_date = weekstart(locale, start_date)
end_date = weekend(locale, 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 {}
flex_user_work_hours_per_day = {}
flex_user_work_hours_per_week = {}
for resource in flex_resources:
user_id = flex_resource_user[resource.id]
users_work_intervals[user_id] = flex_resources_work_intervals[resource.id]
if not resource._is_fully_flexible():
flex_user_work_hours_per_day[user_id] = hours_per_day[resource.id]
flex_user_work_hours_per_week[user_id] = hours_per_week[resource.id]
if user_id not in unavailable_intervals:
continue
unavailable_intervals_day_formatted = unavailable_intervals[user_id] & flex_resources_work_intervals[resource.id]
for interval in unavailable_intervals_day_formatted:
tasks = interval[2]
# start and end of intervals are on the same day thanks to flex_resources_work_intervals format
day = interval[0].date()
interval_allocated_hours = 0.0
for task in tasks:
interval_as_Interval = Intervals([(interval[0].astimezone(pytz.utc).replace(tzinfo=None), interval[1].astimezone(pytz.utc).replace(tzinfo=None), set())])
interval_task_intersection = interval_as_Interval & Intervals([(task.planned_date_begin, task.date_deadline, set())])
interval_duration = sum_intervals(interval_task_intersection)
task_total_duration = (task.date_deadline - task.planned_date_begin).total_seconds() / 3600
rate = interval_duration / task_total_duration
interval_allocated_hours = rate * task.allocated_hours if task.allocated_hours else interval_duration / 3600
interval_allocated_hours_per_user = interval_allocated_hours / len(task.user_ids)
if day in flex_user_work_hours_per_day[user_id]:
flex_user_work_hours_per_day[user_id][day] -= interval_allocated_hours_per_user
year_and_week = weeknumber(locale, day)
flex_user_work_hours_per_week[user_id][year_and_week] -= interval_allocated_hours_per_user
baseInterval = Intervals([(original_start_date, original_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.sudo()._work_intervals_batch(original_start_date, original_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, flex_user_work_hours_per_day, flex_user_work_hours_per_week
def _get_new_dates(self,
valid_intervals_per_user,
users_ids,
search_forward,
first_possible_start_date_per_candidate,
last_possible_end_date_per_candidate,
candidate_duration,
move_in_conflicts_users=None
):
""" this method is used for 2 goals:
- compute the new dates for a task to plan, users_ids is the task users
- compute the start and date dates of the buffer for maintain buffer strategy, users_ids is False
if the 2 tasks have differents users (we follow the company calendar) else the assigned users
"""
if move_in_conflicts_users is None:
move_in_conflicts_users = set()
intervals = valid_intervals_per_user[users_ids]._items
intervals_durations = 0
step = 1 if search_forward else -1
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
if search_forward:
first_date = first_possible_start_date_per_candidate.get(self.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(self.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, self))
return (used_intervals, intervals_durations, compute_start_date, compute_end_date)
def _web_gantt_update_next_candidates_dates(self,
dependency_field_name,
dependency_inverted_field_name,
search_forward,
consume_buffer,
start_date_field_name,
stop_date_field_name,
first_possible_start_date_per_candidate,
last_possible_end_date_per_candidate,
old_planned_date_begin,
old_date_deadline,
compute_start_date,
compute_end_date,
valid_intervals_per_user,
valid_intervals_per_user_for_buffer_computes
):
next_candidates = self[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:
compute_end_date = compute_end_date.astimezone(utc)
first_possible_start_date_per_candidate[task.id] = max(first_possible_start_date_per_candidate.get(task.id, compute_end_date), compute_end_date)
if not consume_buffer and task[start_date_field_name] > old_date_deadline:
# follow users calendar if both taks belong to same users or follow company calendar
calendar_owner = tuple(self.user_ids.ids) if self.user_ids and self.user_ids == task.user_ids else False
seconds_between_tasks = sum_intervals(Intervals([(old_date_deadline.astimezone(utc), task[start_date_field_name].astimezone(utc), self.env['resource.calendar.attendance'])]) & valid_intervals_per_user_for_buffer_computes.get(calendar_owner, Intervals())) * 3600
if seconds_between_tasks > 0:
base_date = {task.id: compute_end_date}
_dummy, buffer_duration, _dummy, buffer_end_date = task._get_new_dates(valid_intervals_per_user, calendar_owner, search_forward, base_date, last_possible_end_date_per_candidate, seconds_between_tasks)
if not buffer_end_date or buffer_duration < seconds_between_tasks:
return False
first_possible_start_date_per_candidate[task.id] = max(first_possible_start_date_per_candidate[task.id], buffer_end_date)
else:
compute_start_date = compute_start_date.astimezone(utc)
last_possible_end_date_per_candidate[task.id] = min(last_possible_end_date_per_candidate.get(task.id, compute_start_date), compute_start_date)
if not consume_buffer and task[stop_date_field_name] < old_planned_date_begin:
# follow users calendar if both taks belong to same users or follow company calendar
calendar_owner = tuple(self.user_ids.ids) if self.user_ids and self.user_ids == task.user_ids else False
seconds_between_tasks = sum_intervals(Intervals([(task[stop_date_field_name].astimezone(utc), old_planned_date_begin.astimezone(utc), self.env['resource.calendar.attendance'])]) & valid_intervals_per_user_for_buffer_computes.get(calendar_owner, Intervals())) * 3600
if seconds_between_tasks > 0:
base_date = {task.id: compute_start_date}
_dummy, buffer_duration, buffer_start_date, _dummy = task._get_new_dates(valid_intervals_per_user, calendar_owner, search_forward, first_possible_start_date_per_candidate, base_date, seconds_between_tasks)
if not buffer_start_date or buffer_duration < seconds_between_tasks:
return False
last_possible_end_date_per_candidate[task.id] = min(last_possible_end_date_per_candidate[task.id], buffer_start_date)
return True
def _web_gantt_get_valid_intervals_for_buffer(self, candidates_ids, start_date_field_name, stop_date_field_name, users, consume_buffer):
if consume_buffer:
return {}
all_candidates = self.browse(candidates_ids)
buffer_start_date = min(all_candidates.filtered(start_date_field_name).mapped(start_date_field_name)).astimezone(utc)
buffer_end_date = max(all_candidates.filtered(stop_date_field_name).mapped(stop_date_field_name)).astimezone(utc)
return all_candidates._web_gantt_get_valid_intervals(buffer_start_date, buffer_end_date, users)[0]
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, consume_buffer, vals):
self.ensure_one()
tz_info = self.env.context.get('tz') or 'UTC'
old_vals_per_pill_id = self.web_gantt_init_old_vals_per_pill_id(vals)
if 'user_ids' in vals:
new_user = vals['user_ids']
old_user_ids = self.user_ids.ids
if not new_user:
vals['user_ids'] = False
else:
user_to_assign = self.env['res.users'].browse(new_user)
if user_to_assign.id not in old_user_ids:
vals["user_ids"] = user_to_assign.ids
tz_info = user_to_assign.tz or tz_info
old_vals_per_pill_id[self.id]['user_ids'] = old_user_ids or False
if stop_date_field_name in vals and start_date_field_name not in vals:
old_vals_per_pill_id[self.id][start_date_field_name] = self[start_date_field_name]
result = {
"errors": [],
"warnings": [],
}
candidates = self.browse([id for id in candidates_ids if id != self.id])
users = candidates.user_ids.sudo()
valid_intervals_per_user_for_buffer_computes = self._web_gantt_get_valid_intervals_for_buffer(candidates_ids, start_date_field_name, stop_date_field_name, users, consume_buffer)
self.write(vals)
if not candidates:
return result, old_vals_per_pill_id
if search_forward:
start_date = self[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:
# In the case where we extend the pill thus only change the deadline, the start date of the task before and
# after changes will be the same. In that case, the end date should be limited to the latest dependent task.
if old_vals_per_pill_id[self.id][start_date_field_name] == self[start_date_field_name]:
end_date = max(candidates.mapped(stop_date_field_name))
else:
end_date = self[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, _dummy, _dummy = candidates._web_gantt_get_valid_intervals(start_date, end_date, users, 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 = candidates._web_gantt_get_first_and_last_possible_dates(dependency_field_name, dependency_inverted_field_name, search_forward, stop_date_field_name, start_date_field_name)
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)
update_next_candidates_dates_response = self._web_gantt_update_next_candidates_dates(dependency_field_name, dependency_inverted_field_name, search_forward, consume_buffer, start_date_field_name, stop_date_field_name,
first_possible_start_date_per_candidate, last_possible_end_date_per_candidate, old_vals_per_pill_id[self.id][start_date_field_name], old_vals_per_pill_id[self.id][stop_date_field_name],
self[start_date_field_name], self[stop_date_field_name], valid_intervals_per_user, valid_intervals_per_user_for_buffer_computes
)
if not update_next_candidates_dates_response:
result["errors"].append("no_intervals_error")
return result, {}
for candidate in candidates:
if consume_buffer 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, {}
# When we set the deadline of a main task backwards, we have to determine the end date of its dependent
# tasks (candidates). This end date corresponds to the end date of the main task, where we add
# the duration of the candidate task and the gap separating it from the main task.
if not search_forward and not last_possible_end_date_per_candidate.get(candidate.id):
if self.env.company.resource_calendar_id:
gap = self.env.company.resource_calendar_id.get_work_hours_count(old_vals_per_pill_id[self.id][stop_date_field_name], candidate.planned_date_begin)
last_possible_end_date_per_candidate[candidate.id] = (
self.env.company.resource_calendar_id.plan_hours((gap + candidate_duration / 3600),
self[stop_date_field_name]).astimezone(timezone(tz_info)))
else:
gap = (candidate.planned_date_begin - old_vals_per_pill_id[self.id][stop_date_field_name]).total_seconds() / 3600
last_possible_end_date_per_candidate[candidate.id] = (self[stop_date_field_name] + relativedelta(hours=gap) + relativedelta(seconds=candidate_duration)).astimezone(timezone(tz_info))
used_intervals, intervals_durations, compute_start_date, compute_end_date = candidate._get_new_dates(valid_intervals_per_user, users_ids, search_forward, first_possible_start_date_per_candidate, last_possible_end_date_per_candidate, candidate_duration, move_in_conflicts_users)
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)
elif candidate_duration > needed_intervals_duration:
needed = candidate_duration - needed_intervals_duration
if search_forward:
compute_start_date += timedelta(seconds=-needed)
else:
compute_end_date += timedelta(seconds=needed)
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, {}
update_next_candidates_dates_response = candidate._web_gantt_update_next_candidates_dates(dependency_field_name, dependency_inverted_field_name, search_forward, consume_buffer, start_date_field_name, stop_date_field_name,
first_possible_start_date_per_candidate, last_possible_end_date_per_candidate, old_planned_date_begin, old_date_deadline, compute_start_date, compute_end_date, valid_intervals_per_user, valid_intervals_per_user_for_buffer_computes
)
if not update_next_candidates_dates_response:
result["errors"].append("no_intervals_error")
return result, {}
used_intervals = Intervals(used_intervals)
if not users_ids:
valid_intervals_per_user[False] -= used_intervals
else:
users_ids_set = set(users_ids)
for user in valid_intervals_per_user:
if not user:
continue
if not users_ids_set.isdisjoint(user):
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_record_has_dependencies(self):
self.ensure_one()
return self.project_id.allow_task_dependencies
def _web_gantt_reschedule_can_record_be_rescheduled(self, start_date_field_name, stop_date_field_name):
self.ensure_one()
return super()._web_gantt_reschedule_can_record_be_rescheduled(start_date_field_name, stop_date_field_name) and not self.is_closed
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
: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 super()._web_gantt_reschedule_is_record_candidate(start_date_field_name, stop_date_field_name) and self._web_gantt_record_has_dependencies()
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_enterprise.project_task_dependency_view_gantt")
map_view = self.env.ref('project_enterprise.project_task_map_view_no_title')
action['views'] = [(gantt_view.id, 'gantt'), (map_view.id, 'map')] + [(state, view) for state, view in action['views'] if view not in ['gantt', 'map']]
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 = self.env['resource.resource'].search([('user_id', 'in', res_ids), ('company_id', 'in', self.env.companies.ids)], 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_leaves_mapping = {}
leaves_mapping = resources._get_unavailable_intervals(start, stop)
for resource in resources:
if resource.user_id.id not in user_leaves_mapping:
user_leaves_mapping[resource.user_id.id] = []
user_leaves_mapping[resource.user_id.id] += leaves_mapping.get(resource.id, [])
company_calendar = self.env.company.resource_calendar_id
company_leaves = [] if company_calendar.flexible_hours else company_calendar._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]:
calendar = user_leaves_mapping.get(user_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."))
def _prepare_domains_for_all_deadlines(self, date_start, date_end):
return {
'project': [
('date', '>=', date_start),
('date_start', '<=', date_end),
],
'milestone': [
('deadline', '>=', date_start),
('deadline', '<=', date_end),
('project_allow_milestones', '=', True),
],
}
@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.env.context.get('default_project_id', False)
# get domains
result_domains = self._prepare_domains_for_all_deadlines(date_start, date_end)
project_domain = Domain(result_domains['project'])
milestone_domain = Domain(result_domains['milestone'])
if project_id:
project_domain &= Domain('id', '=', project_id)
milestone_domain &= 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
def _get_template_default_context_whitelist(self):
return [
*super()._get_template_default_context_whitelist(),
"planned_date_begin",
"date_deadline",
]