641 lines
30 KiB
Python
641 lines
30 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone, timedelta
|
|
from lxml.builder import E
|
|
from pytz import utc
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import _, unique, OrderedSet
|
|
|
|
DATE_FIELD_ADAPTERS = {
|
|
'date': {
|
|
'parse': fields.Date.to_date,
|
|
'now': fields.Date.today,
|
|
},
|
|
'datetime': {
|
|
'parse': fields.Datetime.to_datetime,
|
|
'now': fields.Datetime.now,
|
|
},
|
|
}
|
|
|
|
|
|
class Base(models.AbstractModel):
|
|
_inherit = 'base'
|
|
|
|
_start_name = 'date_start' # start field to use for default gantt view
|
|
_stop_name = 'date_stop' # stop field to use for default gantt view
|
|
|
|
# action_gantt_reschedule utils
|
|
_WEB_GANTT_RESCHEDULE_FORWARD = 'forward'
|
|
_WEB_GANTT_RESCHEDULE_BACKWARD = 'backward'
|
|
_WEB_GANTT_RESCHEDULE_MAINTAIN_BUFFER = 'maintainBuffer'
|
|
_WEB_GANTT_RESCHEDULE_CONSUME_BUFFER = 'consumeBuffer'
|
|
_WEB_GANTT_LOOP_ERROR = 'loop_error'
|
|
|
|
@api.model
|
|
def _get_default_gantt_view(self):
|
|
""" Generates a default gantt view by trying to infer
|
|
time-based fields from a number of pre-set attribute names
|
|
|
|
:returns: a gantt view
|
|
:rtype: etree._Element
|
|
"""
|
|
view = E.gantt(string=self._description)
|
|
|
|
gantt_field_names = {
|
|
'_start_name': ['date_start', 'start_date', 'x_date_start', 'x_start_date'],
|
|
'_stop_name': ['date_stop', 'stop_date', 'date_end', 'end_date', 'x_date_stop', 'x_stop_date', 'x_date_end', 'x_end_date'],
|
|
}
|
|
for name in gantt_field_names.keys():
|
|
if getattr(self, name) not in self._fields:
|
|
for dt in gantt_field_names[name]:
|
|
if dt in self._fields:
|
|
setattr(self, name, dt)
|
|
break
|
|
else:
|
|
raise UserError(_("Insufficient fields for Gantt View!"))
|
|
view.set('date_start', self._start_name)
|
|
view.set('date_stop', self._stop_name)
|
|
|
|
return view
|
|
|
|
@api.model
|
|
def get_gantt_data(self, domain, groupby, read_specification, limit=None, offset=0, unavailability_fields=None, progress_bar_fields=None, start_date=None, stop_date=None, scale=None):
|
|
"""
|
|
Returns the result of a web_read_group (and optionally search for and read records inside each
|
|
group), and the total number of groups matching the search domain.
|
|
|
|
:param domain: search domain
|
|
:param groupby: list of field to group on (see ``groupby``` param of ``_read_group``)
|
|
:param read_specification: web_read specification to read records within the groups
|
|
:param limit: see ``limit`` param of ``_read_group``
|
|
:param offset: see ``offset`` param of ``_read_group``
|
|
:param boolean unavailability_fields:
|
|
:param string start_date: start datetime in utc, e.g. "2024-06-22 23:00:00"
|
|
:param string stop_date: stop datetime in utc
|
|
:param string scale: among "day", "week", "month" and "year"
|
|
:return:
|
|
example::
|
|
|
|
{
|
|
'groups': [
|
|
{
|
|
'<groupby_1>': <value_groupby_1>,
|
|
...,
|
|
'__record_ids': [<ids>]
|
|
}
|
|
],
|
|
'records': [<record data>]
|
|
'length': total number of groups
|
|
'unavailabilities': {
|
|
'<unavailability_fields_1>': <value_unavailability_fields_1>,
|
|
...
|
|
}
|
|
'progress_bars': {
|
|
'<progress_bar_fields_1>': <value_progress_bar_fields_1>,
|
|
...
|
|
}
|
|
}
|
|
"""
|
|
if groupby:
|
|
# Because there is no limit by group, we can fetch record_ids as aggregate
|
|
groups, length = self.with_context(read_group_expand=True)._formatted_read_group_with_length(
|
|
domain, groupby, ['id:array_agg'],
|
|
offset=offset, limit=limit,
|
|
)
|
|
|
|
final_result = {
|
|
'groups': groups,
|
|
'length': length,
|
|
}
|
|
|
|
all_record_ids = tuple(unique(
|
|
record_id
|
|
for one_group in groups
|
|
for record_id in one_group['id:array_agg']
|
|
))
|
|
|
|
# Do search_fetch to order records (model order can be no-trivial)
|
|
all_records = self.with_context(active_test=False).search_fetch([('id', 'in', all_record_ids)], read_specification.keys())
|
|
else:
|
|
# Not groupby => search records directly and create one group to respect the API
|
|
all_records = self.search_fetch(domain, read_specification.keys())
|
|
final_result = {
|
|
'groups': [{
|
|
'id:array_agg': all_records._ids,
|
|
'__extra_domain': [],
|
|
}],
|
|
'length': 1,
|
|
}
|
|
|
|
final_result['records'] = all_records.with_env(self.env).web_read(read_specification)
|
|
|
|
if unavailability_fields is None:
|
|
unavailability_fields = []
|
|
if progress_bar_fields is None:
|
|
progress_bar_fields = []
|
|
|
|
ordered_set_ids = OrderedSet(all_records._ids)
|
|
res_ids_for_unavailabilities = defaultdict(set)
|
|
res_ids_for_progress_bars = defaultdict(set)
|
|
for group in final_result['groups']:
|
|
for field in unavailability_fields:
|
|
res_id = group[field][0] if group[field] else False
|
|
if res_id:
|
|
res_ids_for_unavailabilities[field].add(res_id)
|
|
for field in progress_bar_fields:
|
|
res_id = group[field][0] if group[field] else False
|
|
if res_id:
|
|
res_ids_for_progress_bars[field].add(res_id)
|
|
# Reorder __record_ids
|
|
group['__record_ids'] = list(ordered_set_ids & OrderedSet(group.pop('id:array_agg')))
|
|
# We don't need these in the gantt view
|
|
del group['__extra_domain']
|
|
group.pop('__fold', None)
|
|
|
|
if unavailability_fields or progress_bar_fields:
|
|
start, stop = fields.Datetime.from_string(start_date), fields.Datetime.from_string(stop_date)
|
|
|
|
unavailabilities = {}
|
|
for field in unavailability_fields:
|
|
unavailabilities[field] = self._gantt_unavailability(field, list(res_ids_for_unavailabilities[field]), start, stop, scale)
|
|
final_result['unavailabilities'] = unavailabilities
|
|
|
|
progress_bars = {}
|
|
for field in progress_bar_fields:
|
|
progress_bars[field] = self._gantt_progress_bar(field, list(res_ids_for_progress_bars[field]), start, stop)
|
|
final_result['progress_bars'] = progress_bars
|
|
|
|
return final_result
|
|
|
|
def web_gantt_init_old_vals_per_pill_id(self, vals):
|
|
old_vals_per_pill_id = defaultdict(dict)
|
|
for field in vals:
|
|
field_type = self.fields_get(field)[field]['type']
|
|
if field_type in ['many2many', 'one2many']:
|
|
old_vals_per_pill_id[self.id][field] = self[field].ids or False
|
|
elif field_type == 'many2one':
|
|
old_vals_per_pill_id[self.id][field] = self[field].id or False
|
|
else:
|
|
old_vals_per_pill_id[self.id][field] = self[field]
|
|
|
|
return old_vals_per_pill_id
|
|
|
|
@api.model
|
|
def web_gantt_reschedule(
|
|
self,
|
|
vals,
|
|
reschedule_method,
|
|
record_id,
|
|
dependency_field_name,
|
|
dependency_inverted_field_name,
|
|
start_date_field_name,
|
|
stop_date_field_name,
|
|
):
|
|
"""
|
|
Reschedule a record according to the provided parameters.
|
|
|
|
:param vals: dict containing the new vals for the moved pill
|
|
:param reschedule_method: The method of the rescheduling, either 'maintainBuffer' or 'consumeBuffer'.
|
|
:param record_id: The moved record.
|
|
:param dependency_field_name: The field name representing the relation between the master and slave records.
|
|
:param dependency_inverted_field_name: The field name representing the relation between the slave and parent records.
|
|
: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: A dictionary with the following structure:
|
|
|
|
- type: Notification type.
|
|
- message: Notification message.
|
|
- old_vals_per_pill_id: A dictionary where the key is the pill ID, and the value is
|
|
another dictionary containing the start and stop dates before rescheduling.
|
|
|
|
:rtype: dict
|
|
"""
|
|
|
|
if reschedule_method not in (self._WEB_GANTT_RESCHEDULE_CONSUME_BUFFER, self._WEB_GANTT_RESCHEDULE_MAINTAIN_BUFFER):
|
|
raise ValueError(self.env._("Invalid reschedule method %s", reschedule_method))
|
|
|
|
record = self.env[self._name].browse(record_id)
|
|
|
|
message = self.env._("Tasks rescheduled")
|
|
# if the pill is moved without changing dates, or there are no dependencies between pills, or the pill is moved to the past => only write the moved pill (like Manual rescheduling)
|
|
start_date_field = self.env[self._name]._fields[start_date_field_name]
|
|
start_date = now_value = None
|
|
if start_date_field_name in vals:
|
|
start_date = DATE_FIELD_ADAPTERS[start_date_field.type]['parse'](vals[start_date_field_name])
|
|
now_value = DATE_FIELD_ADAPTERS[start_date_field.type]['now']()
|
|
if (
|
|
not (
|
|
stop_date_field_name in vals
|
|
and dependency_field_name
|
|
and dependency_inverted_field_name
|
|
)
|
|
or (start_date and start_date < now_value)
|
|
):
|
|
old_vals_per_pill_id = record.web_gantt_init_old_vals_per_pill_id(vals)
|
|
record.write(vals)
|
|
return {
|
|
"type": "success",
|
|
"message": message,
|
|
"old_vals_per_pill_id": old_vals_per_pill_id,
|
|
}
|
|
|
|
with self.env.cr.savepoint() as sp:
|
|
log_messages, old_vals_per_pill_id = record._web_gantt_action_reschedule_candidates(dependency_field_name, dependency_inverted_field_name, start_date_field_name, stop_date_field_name, reschedule_method, vals)
|
|
has_errors = bool(log_messages.get("errors"))
|
|
sp.close(rollback=has_errors)
|
|
notification_type = "success"
|
|
if has_errors or log_messages.get("warnings"):
|
|
message = self._web_gantt_get_reschedule_message(log_messages)
|
|
notification_type = "warning" if has_errors else "info"
|
|
return {
|
|
"type": notification_type,
|
|
"message": message,
|
|
"old_vals_per_pill_id": old_vals_per_pill_id,
|
|
}
|
|
|
|
def action_rollback_scheduling(self, old_vals_per_pill_id):
|
|
for record in self:
|
|
vals = old_vals_per_pill_id.get(str(record.id), old_vals_per_pill_id.get(record.id))
|
|
if vals:
|
|
record.write(vals)
|
|
|
|
def gantt_undo_drag_drop(self, drag_action, data=None):
|
|
if not self.exists():
|
|
return False
|
|
if drag_action == "copy":
|
|
return self.unlink()
|
|
elif drag_action == "reschedule" and data:
|
|
return self.write(data)
|
|
return False
|
|
|
|
@api.model
|
|
def _gantt_progress_bar(self, field: str, res_ids: list[int], start: str, stop: str):
|
|
""" Get progress bar value per record.
|
|
|
|
This method is meant to be overriden by each related model that want to
|
|
implement this feature on Gantt groups. The progressbar is composed
|
|
of a value and a max_value given for each groupedby field.
|
|
|
|
Example::
|
|
|
|
field = 'foo',
|
|
res_ids = [1, 2]
|
|
start_date = 01/01/2000, end_date = 01/07/2000,
|
|
self = base()
|
|
|
|
Result::
|
|
|
|
{
|
|
1: {'value': 50, 'max_value': 100},
|
|
2: {'value': 25, 'max_value': 200},
|
|
}
|
|
|
|
:param field: field on which there are progressbars
|
|
:param res_ids: res_ids of related records for which we need to compute progress bar
|
|
:param start: start date in utc
|
|
:param stop: end date in utc
|
|
:returns: dict of value and max_value per record
|
|
"""
|
|
return {}
|
|
|
|
@api.model
|
|
def _gantt_unavailability(self, field, res_ids, start, stop, scale):
|
|
""" Get unavailabilities data for a given set of resources.
|
|
|
|
This method is meant to be overriden by each model that want to
|
|
implement this feature on a Gantt view. A subslot is considered
|
|
unavailable (and greyed) when totally covered by an unavailability.
|
|
|
|
Example::
|
|
|
|
>>> _gantt_unavailability(
|
|
... field="employee_id",
|
|
... res_ids=[3, 9],
|
|
... start='01/01/2000' # UTC,
|
|
... stop='01/07/2000' # UTC,
|
|
... scale='week',
|
|
... )
|
|
{
|
|
value: [{
|
|
start: <start date of first unavailabity in UTC format>,
|
|
stop: <stop date of first unavailabity in UTC format>
|
|
}, {
|
|
start: <start date of second unavailabity in UTC format>,
|
|
stop: <stop date of second unavailabity in UTC format>
|
|
}, ...]
|
|
...
|
|
}
|
|
|
|
For example Marcel (3) is unavailable January 2 afternoon and
|
|
January 4 the whole day, the dict should look like this::
|
|
|
|
{
|
|
3: [{
|
|
'start': '2018-01-02 14:00:00',
|
|
'stop': '2018-01-02 18:00:00'
|
|
}, {
|
|
'start': '2018-01-04 08:00:00',
|
|
'stop': '2018-01-04 18:00:00'
|
|
}]
|
|
}
|
|
|
|
Note that John (9) has no unavailabilies and thus 9 is not in
|
|
returned dict
|
|
|
|
:param string field: name of a many2X field
|
|
:param list res_ids: list of values for field for which we want unavailabilities (a value is either False or an id)
|
|
:param datetime start: start datetime
|
|
:param datetime stop: stop datetime
|
|
:param string scale: among "day", "week", "month" and "year"
|
|
:returns: dict of unavailabilities
|
|
"""
|
|
return {}
|
|
|
|
def _web_gantt_get_reschedule_message_per_key(self, key, params=None):
|
|
if key == self._WEB_GANTT_LOOP_ERROR:
|
|
return _("The dependencies are not valid, there is a cycle.")
|
|
elif key == "past_error":
|
|
if params: # params is the record that is in the past
|
|
return _("%s cannot be scheduled in the past", params.display_name)
|
|
else:
|
|
return _("Impossible to schedule in the past.")
|
|
else:
|
|
return ""
|
|
|
|
def _web_gantt_get_reschedule_message(self, log_messages):
|
|
def get_messages(logs):
|
|
messages = []
|
|
for key in logs:
|
|
message = self._web_gantt_get_reschedule_message_per_key(key, log_messages.get(key))
|
|
if message:
|
|
messages.append(message)
|
|
return messages
|
|
|
|
messages = []
|
|
errors = log_messages.get("errors")
|
|
if errors:
|
|
messages = get_messages(log_messages.get("errors"))
|
|
else:
|
|
messages = get_messages(log_messages.get("warnings", []))
|
|
return "\n".join(messages)
|
|
|
|
def _web_gantt_get_direction(self, bound_date_field_name, vals):
|
|
bound_date_field = self.env[self._name]._fields[bound_date_field_name]
|
|
date_bound = DATE_FIELD_ADAPTERS[bound_date_field.type]['parse'](vals[bound_date_field_name])
|
|
return self._WEB_GANTT_RESCHEDULE_FORWARD if date_bound > self[bound_date_field_name] else self._WEB_GANTT_RESCHEDULE_BACKWARD
|
|
|
|
def _web_gantt_action_reschedule_candidates(
|
|
self,
|
|
dependency_field_name, dependency_inverted_field_name,
|
|
start_date_field_name, stop_date_field_name,
|
|
reschedule_method,
|
|
vals
|
|
):
|
|
""" Prepare the candidates according to the provided parameters and move them.
|
|
|
|
:param dependency_field_name: The field name of the relation between the pill and its parents.
|
|
:param dependency_inverted_field_name: The field name of the relation between the pill and its children.
|
|
: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.
|
|
:param reschedule_method: The method of the rescheduling 'maintainBuffer' or 'consumBuffer'
|
|
:param vals: dict containing the new vals for the moved pill
|
|
:return: tuple(valid, message) (valid = True if Successful, message = None or contains the notification text if
|
|
text if valid = True or the error text if valid = False.
|
|
"""
|
|
|
|
is_only_deadline_changed = start_date_field_name not in vals and stop_date_field_name in vals
|
|
direction_date = stop_date_field_name if is_only_deadline_changed else start_date_field_name
|
|
search_forward = self._web_gantt_get_direction(direction_date, vals) == self._WEB_GANTT_RESCHEDULE_FORWARD
|
|
candidates_ids = []
|
|
# If we only move the deadline of the main task, even backward, no dependent task will block the change. However,
|
|
# if we move the deadline of a dependent task, this should have no effect on the main one. Therefore, the relation
|
|
# between the pill and candidates will always be expressed with the field dependency_inverted_field_name.
|
|
if self._web_gantt_check_cycle_existance_and_get_rescheduling_candidates(
|
|
candidates_ids, dependency_inverted_field_name if search_forward or start_date_field_name not in vals else dependency_field_name,
|
|
start_date_field_name, stop_date_field_name,
|
|
):
|
|
return {'errors': self._WEB_GANTT_LOOP_ERROR}, {}
|
|
|
|
return self._web_gantt_move_candidates(
|
|
start_date_field_name, stop_date_field_name,
|
|
dependency_field_name, dependency_inverted_field_name,
|
|
search_forward, candidates_ids,
|
|
reschedule_method == self._WEB_GANTT_RESCHEDULE_CONSUME_BUFFER, vals
|
|
)
|
|
|
|
def _web_gantt_is_candidate_in_conflict(self, start_date_field_name, stop_date_field_name, dependency_field_name, dependency_inverted_field_name):
|
|
return (
|
|
any(r[start_date_field_name] and r[stop_date_field_name] and self[start_date_field_name] < r[stop_date_field_name] for r in self[dependency_field_name])
|
|
or any(r[start_date_field_name] and r[stop_date_field_name] and self[stop_date_field_name] > r[start_date_field_name] for r in self[dependency_inverted_field_name])
|
|
)
|
|
|
|
def _web_gantt_update_next_pills_first_possible_date(self,
|
|
dependency_field_name,
|
|
dependency_inverted_field_name,
|
|
search_forward,
|
|
first_possible_start_date_per_candidate,
|
|
last_possible_end_date_per_candidate,
|
|
consume_buffer,
|
|
start_date_field_name,
|
|
stop_date_field_name,
|
|
start_date,
|
|
end_date,
|
|
old_start_date,
|
|
old_end_date
|
|
):
|
|
for next in self[dependency_inverted_field_name if search_forward else dependency_field_name]:
|
|
if search_forward:
|
|
end_date = end_date.astimezone(utc)
|
|
first_possible_start_date_per_candidate[next.id] = max(first_possible_start_date_per_candidate.get(next.id, end_date), end_date)
|
|
if not consume_buffer and next[start_date_field_name] > old_end_date:
|
|
buffer_duration = (next[start_date_field_name] - old_end_date).total_seconds()
|
|
start_date_after_buffer = end_date + timedelta(seconds=buffer_duration)
|
|
first_possible_start_date_per_candidate[next.id] = max(first_possible_start_date_per_candidate.get(next.id, start_date_after_buffer), start_date_after_buffer)
|
|
else:
|
|
start_date = start_date.astimezone(utc)
|
|
last_possible_end_date_per_candidate[next.id] = min(last_possible_end_date_per_candidate.get(next.id, start_date), start_date)
|
|
if not consume_buffer and next[stop_date_field_name] < old_start_date:
|
|
buffer_duration = (old_start_date - next[stop_date_field_name]).total_seconds()
|
|
end_date_after_buffer = start_date - timedelta(seconds=buffer_duration)
|
|
last_possible_end_date_per_candidate[next.id] = min(last_possible_end_date_per_candidate.get(next.id, end_date_after_buffer), end_date_after_buffer)
|
|
|
|
def _web_gantt_get_first_and_last_possible_dates(self, dependency_field_name, dependency_inverted_field_name, search_forward, stop_date_field_name, start_date_field_name):
|
|
first_possible_start_date_per_candidate = {}
|
|
last_possible_end_date_per_candidate = {}
|
|
|
|
for candidate in self:
|
|
related_candidates = (candidate[dependency_field_name] if search_forward else candidate[dependency_inverted_field_name]).filtered(lambda pill: pill[start_date_field_name] and pill[stop_date_field_name])
|
|
not_replanned_candidates = related_candidates - self
|
|
|
|
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.mapped(boundary_date)
|
|
|
|
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)
|
|
|
|
return first_possible_start_date_per_candidate, last_possible_end_date_per_candidate
|
|
|
|
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):
|
|
""" Move candidates according to the provided parameters.
|
|
|
|
: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.
|
|
:param dependency_field_name: The field name of the relation between the pill and pills blocked by it.
|
|
:param dependency_inverted_field_name: The field name of the relation between the pill and pills blocking it.
|
|
:param search_forward: True if the direction = 'forward'
|
|
:param candidates_ids: The candidates to reschedule
|
|
:param consume_buffer: True if reschedule_method = 'consumeBuffer' else False
|
|
:param vals: dict containing the new vals for the moved pill
|
|
:return: dict of list containing 2 keys, errors and warnings
|
|
"""
|
|
result = {
|
|
"errors": [],
|
|
"warnings": [],
|
|
}
|
|
|
|
old_vals_per_pill_id = self.web_gantt_init_old_vals_per_pill_id(vals)
|
|
|
|
candidates = self.browse([id for id in candidates_ids if id != self.id])
|
|
self.write(vals)
|
|
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)
|
|
|
|
self._web_gantt_update_next_pills_first_possible_date(dependency_field_name, dependency_inverted_field_name, search_forward, first_possible_start_date_per_candidate, last_possible_end_date_per_candidate, consume_buffer, start_date_field_name,
|
|
stop_date_field_name, self[start_date_field_name], self[stop_date_field_name], old_vals_per_pill_id[self.id][start_date_field_name], old_vals_per_pill_id[self.id][stop_date_field_name],
|
|
)
|
|
|
|
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
|
|
|
|
start_date, end_date = candidate._web_gantt_reschedule_compute_dates(
|
|
(first_possible_start_date_per_candidate if search_forward else last_possible_end_date_per_candidate)[candidate.id],
|
|
search_forward,
|
|
start_date_field_name, stop_date_field_name
|
|
)
|
|
start_date, end_date = start_date.astimezone(timezone.utc), end_date.astimezone(timezone.utc)
|
|
old_start_date, old_end_date = candidate[start_date_field_name], candidate[stop_date_field_name]
|
|
if not candidate._web_gantt_reschedule_write_new_dates(
|
|
start_date, end_date,
|
|
start_date_field_name, stop_date_field_name
|
|
):
|
|
result["errors"].append("past_error")
|
|
result["past_error"] = candidate
|
|
return result, {}
|
|
|
|
old_vals_per_pill_id[candidate.id] = {
|
|
start_date_field_name: old_start_date,
|
|
stop_date_field_name: old_end_date,
|
|
}
|
|
|
|
candidate._web_gantt_update_next_pills_first_possible_date(dependency_field_name, dependency_inverted_field_name, search_forward, first_possible_start_date_per_candidate, last_possible_end_date_per_candidate, consume_buffer, start_date_field_name,
|
|
stop_date_field_name, candidate[start_date_field_name], candidate[stop_date_field_name], old_start_date, old_end_date,
|
|
)
|
|
|
|
return result, old_vals_per_pill_id
|
|
|
|
def _web_gantt_record_has_dependencies(self):
|
|
return True
|
|
|
|
def _web_gantt_check_cycle_existance_and_get_rescheduling_candidates(self,
|
|
candidates_ids, dependency_field_name,
|
|
start_date_field_name, stop_date_field_name,
|
|
visited=None, ancestors=None,
|
|
):
|
|
""" Get the current records' related records rescheduling candidates
|
|
|
|
:param candidates_ids: empty list that will contain the candidates at the end
|
|
:param dependency_field_name: The field name of the relation between the pill and candidates
|
|
(pills blocked by the pill if move forward or blocking the pill if move backward)
|
|
: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.
|
|
:param visited: set containing all the visited pills
|
|
:param ancestors: set containing the visited ancestors for the current pill
|
|
:return: bool, True if there is a cycle, else False.
|
|
candidates_id will also contain the pills to plan in a valid topological order
|
|
"""
|
|
if visited is None:
|
|
visited = set()
|
|
if ancestors is None:
|
|
ancestors = []
|
|
visited.add(self.id)
|
|
ancestors.append(self.id)
|
|
|
|
for child in (self[dependency_field_name] if self._web_gantt_record_has_dependencies() else []):
|
|
if child.id in ancestors:
|
|
return True
|
|
|
|
if child.id not in visited and child._web_gantt_reschedule_is_record_candidate(start_date_field_name, stop_date_field_name) and child._web_gantt_check_cycle_existance_and_get_rescheduling_candidates(candidates_ids, dependency_field_name, start_date_field_name, stop_date_field_name, visited, ancestors):
|
|
return True
|
|
|
|
ancestors.pop()
|
|
candidates_ids.insert(0, self.id)
|
|
|
|
return False
|
|
|
|
def _web_gantt_reschedule_compute_dates(
|
|
self, date_candidate, search_forward, start_date_field_name, stop_date_field_name
|
|
):
|
|
""" Compute start_date and end_date according to the provided arguments.
|
|
This method is meant to be overridden when we need to add constraints that have to be taken into account
|
|
in the computing of the start_date and end_date.
|
|
|
|
:param date_candidate: The optimal date, which does not take any constraint into account.
|
|
: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: a tuple of (start_date, end_date)
|
|
:rtype: tuple(datetime, datetime)
|
|
"""
|
|
search_factor = (1 if search_forward else -1)
|
|
duration = search_factor * (self[stop_date_field_name] - self[start_date_field_name])
|
|
return sorted([date_candidate, date_candidate + duration])
|
|
|
|
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
|
|
"""
|
|
return self._web_gantt_reschedule_can_record_be_rescheduled(start_date_field_name, stop_date_field_name)
|
|
|
|
def _web_gantt_reschedule_can_record_be_rescheduled(self, start_date_field_name, stop_date_field_name):
|
|
self.ensure_one()
|
|
return self[start_date_field_name] and self[stop_date_field_name] \
|
|
and self[start_date_field_name].replace(tzinfo=timezone.utc) > datetime.now(timezone.utc)
|
|
|
|
def _web_gantt_reschedule_write_new_dates(
|
|
self, new_start_date, new_stop_date, start_date_field_name, stop_date_field_name
|
|
):
|
|
""" Write the dates values if new_start_date is in the future.
|
|
|
|
:param new_start_date: The start_date to write.
|
|
:param new_stop_date: The stop_date to write.
|
|
: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 successful, False if not.
|
|
:rtype: bool
|
|
|
|
epsilon = 30 seconds was added because the first valid interval can be now and because of some seconds, it will become < now() at the comparaison moment
|
|
it's a matter of some seconds
|
|
"""
|
|
new_start_date = new_start_date.astimezone(timezone.utc).replace(tzinfo=None)
|
|
if new_start_date < datetime.now() + timedelta(seconds=-30):
|
|
return False
|
|
|
|
self.write({
|
|
start_date_field_name: new_start_date,
|
|
stop_date_field_name: new_stop_date.astimezone(timezone.utc).replace(tzinfo=None)
|
|
})
|
|
return True
|