Odoo18-Base/enterprise-17.0/web_gantt/models/models.py
2025-01-06 10:57:38 +07:00

678 lines
32 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
from collections import defaultdict
from datetime import datetime, timezone
from lxml.builder import E
from odoo import _, api, models
from odoo.exceptions import UserError
from odoo.tools.misc import OrderedSet, unique
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_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,
):
"""
Returns the result of a 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``
:return: {
'groups': [
{
'<groupby_1>': <value_groupby_1>,
...,
'__record_ids': [<ids>]
}
],
'records': [<record data>]
'length': total number of groups
}
"""
# TODO: group_expand doesn't currently respect the limit/offset
lazy = not limit and not offset and len(groupby) == 1
# Because there is no limit by group, we can fetch record_ids as aggregate
final_result = self.web_read_group(
domain, ['__record_ids:array_agg(id)'], groupby,
limit=limit, offset=offset, lazy=lazy,
)
all_record_ids = tuple(unique(
record_id
for one_group in final_result['groups']
for record_id in one_group['__record_ids']
))
# Do search_fetch to order records (model order can be no-trivial)
all_records = self.search_fetch([('id', 'in', all_record_ids)], read_specification.keys())
final_result['records'] = all_records.web_read(read_specification)
ordered_set_ids = OrderedSet(all_records._ids)
for group in final_result['groups']:
# Reorder __record_ids
group['__record_ids'] = list(ordered_set_ids & OrderedSet(group['__record_ids']))
# We don't need these in the gantt view
del group['__domain']
del group[f'{groupby[0]}_count' if lazy else '__count']
group.pop('__fold', None)
return final_result
@api.model
def web_gantt_reschedule(
self,
direction,
master_record_id, slave_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 direction: The direction of the rescheduling 'forward' or 'backward'
:param master_record_id: The record that the other one is depending on.
:param slave_record_id: The record that is depending on the other one.
:param dependency_field_name: The field name of the relation between the master and slave records.
:param dependency_inverted_field_name: The field name of the relation between the slave and the 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: True if Successful, a client action of notification type if not.
"""
if direction not in (self._WEB_GANTT_RESCHEDULE_FORWARD, self._WEB_GANTT_RESCHEDULE_BACKWARD):
raise ValueError("Invalid direction %r" % direction)
master_record, slave_record = self.env[self._name].browse([master_record_id, slave_record_id])
search_domain = [(dependency_field_name, 'in', master_record.id), ('id', '=', slave_record.id)]
if not self.env[self._name].search(search_domain, limit=1):
raise ValueError("Record '%r' is not a parent record of '%r'" % (master_record.name, slave_record.name))
if not self._web_web_gantt_reschedule_is_relation_candidate(
master_record, slave_record, start_date_field_name, stop_date_field_name):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'warning',
'message': _('You cannot reschedule %s towards %s.',
master_record.name, slave_record.name),
}
}
is_master_prior_to_slave = master_record[stop_date_field_name] <= slave_record[start_date_field_name]
# When records are in conflict, record that is moved is the other one than when there is no conflict.
# This might seem strange at first sight but has been decided during first implementation as when in conflict,
# and especially when the distance between the pills is big, the arrow is interpreted differently as it comes
# from the right to the left (instead of from the left to the right).
if is_master_prior_to_slave ^ (direction == self._WEB_GANTT_RESCHEDULE_BACKWARD):
trigger_record = master_record
related_record = slave_record
else:
trigger_record = slave_record
related_record = master_record
cache = self._web_gantt_reschedule_get_empty_cache()
new_start_date, new_stop_date = trigger_record._web_gantt_reschedule_record(
related_record, related_record == master_record,
start_date_field_name, stop_date_field_name,
cache
)
result = trigger_record._web_gantt_reschedule_write_new_dates(
new_start_date, new_stop_date, start_date_field_name, stop_date_field_name,
)
sp = self.env.cr.savepoint()
record_ids_to_exclude = defaultdict(list)
result = result is True and trigger_record._web_gantt_action_reschedule_related_records(
dependency_field_name, dependency_inverted_field_name,
start_date_field_name, stop_date_field_name,
direction,
record_ids_to_exclude,
cache
)
if result is not True:
if result is False:
notification_type = 'warning'
message = _('Records that are in the past cannot be automatically rescheduled. They should be manually rescheduled instead.')
elif result == self._WEB_GANTT_LOOP_ERROR:
notification_type = 'info'
message = _('You cannot reschedule tasks that do not follow a direct dependency path. '
'Only the first task has been automatically rescheduled.')
else:
raise ValueError('Unsupported result value')
result = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': notification_type,
'message': message,
}
}
sp.close(rollback=result is not True)
return result
@api.model
def gantt_progress_bar(self, fields, res_ids, date_start_str, date_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:
fields = ['foo', 'bar'],
res_ids = {'foo': [1, 2], 'bar':[2, 3]}
start_date = 01/01/2000, end_date = 01/07/2000,
self = base()
Result:
{
'foo': {
1: {'value': 50, 'max_value': 100},
2: {'value': 25, 'max_value': 200},
},
'bar': {
2: {'value': 65, 'max_value': 85},
3: {'value': 30, 'max_value': 95},
}
}
:param list fields: fields on which there are progressbars
:param dict res_ids: res_ids of related records for which we need to compute progress bar
:param string date_start_str: start date
:param string date_stop_str: stop date
:returns: dict of value and max_value per record
"""
return {}
@api.model
def gantt_unavailability(self, start_date, end_date, scale, group_bys=None, rows=None):
""" Get unavailabilities data to display in the Gantt view.
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:
* start_date = 01/01/2000, end_date = 01/07/2000, scale = 'week',
rows = [{
groupedBy: ["project_id", "user_id", "stage_id"],
resId: 8,
rows: [{
groupedBy: ["user_id", "stage_id"],
resId: 18,
rows: [{
groupedBy: ["stage_id"],
resId: 3,
rows: []
}, {
groupedBy: ["stage_id"],
resId: 9,
rows: []
}]
}, {
groupedBy: ["user_id", "stage_id"],
resId: 22,
rows: [{
groupedBy: ["stage_id"],
resId: 9,
rows: []
}]
}]
}, {
groupedBy: ["project_id", "user_id", "stage_id"],
resId: 9,
rows: [{
groupedBy: ["user_id", "stage_id"],
resId: None,
rows: [{
groupedBy: ["stage_id"],
resId: 3,
rows: []
}]
}, {
groupedBy: ["project_id", "user_id", "stage_id"],
resId: 27,
rows: []
}]
* The expected return value of this function is the rows dict with
a new 'unavailabilities' key in each row for which you want to
display unavailabilities. Unavailablitities is a list
(naturally ordered and pairwise disjoint) in the form:
[{
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>
}, ...]
To display that Marcel is unavailable January 2 afternoon and
January 4 the whole day in his To Do row, this particular row in
the rows dict should look like this when returning the dict at the
end of this function :
{ ...
{
groupedBy: ["stage_id"],
resId: 3,
rows: []
unavailabilities: [{
'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'
}]
}
...
}
:param datetime start_date: start date
:param datetime stop_date: stop date
:param string scale: among "day", "week", "month" and "year"
:param None | list[str] group_bys: group_by fields
:param dict rows: dict describing the current rows of the gantt view
:returns: dict of unavailability
"""
return rows
def _web_gantt_action_reschedule_related_records(
self,
dependency_field_name, dependency_inverted_field_name,
start_date_field_name, stop_date_field_name,
direction,
record_ids_to_exclude,
cache
):
""" Reschedule the related records, that is the records available in both fields dependency_field_name and
dependency_inverted_field_name and which satisfies some conditions which are tested in
_web_gantt_get_rescheduling_candidates
:param dependency_field_name: The field name of the relation between the master and slave records.
:param dependency_inverted_field_name: The field name of the relation between the slave and the 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.
:param direction: The direction of the rescheduling 'forward' or 'backward'
:param record_ids_to_exclude: The record Ids that have to be excluded from the return candidates.
:param cache: An object that contains reusable information in the context of gantt record rescheduling.
:return: True if successful, False if not.
:rtype: bool
"""
rescheduling_candidates = self._web_gantt_get_rescheduling_candidates(
dependency_field_name, dependency_inverted_field_name,
start_date_field_name, stop_date_field_name,
direction,
record_ids_to_exclude
)
if rescheduling_candidates is False:
return self._WEB_GANTT_LOOP_ERROR
if not rescheduling_candidates:
return True
result = True
records_to_propagate = self.env[self._name]
for rescheduling_candidate in rescheduling_candidates:
record, related_record, is_related_record_master = rescheduling_candidate
new_start_date, new_stop_date = record._web_gantt_reschedule_record(
related_record, is_related_record_master,
start_date_field_name, stop_date_field_name,
cache
)
record_write_result = record._web_gantt_reschedule_write_new_dates(
new_start_date, new_stop_date,
start_date_field_name, stop_date_field_name,
)
if record_write_result:
records_to_propagate |= record
record_ids_to_exclude[record.id] = record_ids_to_exclude[related_record.id] + [related_record.id]
result &= record_write_result
for record in self:
record_ids_to_exclude.pop(record.id, None)
related_records_result = records_to_propagate._web_gantt_action_reschedule_related_records(
dependency_field_name, dependency_inverted_field_name,
start_date_field_name, stop_date_field_name,
direction,
record_ids_to_exclude,
cache
)
if isinstance(related_records_result, bool):
result &= related_records_result
else:
result = related_records_result
return result
def _web_gantt_get_rescheduling_candidates(
self,
dependency_field_name, dependency_inverted_field_name,
start_date_field_name, stop_date_field_name,
direction,
record_ids_to_exclude
):
""" Get the current records' related records rescheduling candidates (the records that depend on them as well
as the records they depend on) for the rescheduling process as well as their reference records (the
furthest record that depends on it, as well as the furthest record it depends on).
:param dependency_field_name: The field name of the relation between the master and slave records.
:param dependency_inverted_field_name: The field name of the relation between the slave and the 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.
:param direction: The direction of the rescheduling 'forward' or 'backward'
:param record_ids_to_exclude: The record Ids that have to be excluded from the return candidates.
:return: a list of tuples (record, related_record, is_related_record_master)
where: - record is the record to be rescheduled
- related_record is the record that is the target of the rescheduling
- is_related_record_master informs whether the related_record is a record that the current
record depends on (so-called master) or a record that depends on the current record
(so-called slave)
:rtype: tuple(AbstractModel, AbstractModel, bool)
"""
rescheduling_forward = direction == self._WEB_GANTT_RESCHEDULE_FORWARD
rescheduling_backward = direction == self._WEB_GANTT_RESCHEDULE_BACKWARD
slave_per_record = defaultdict(lambda: self.env[self._name])
master_per_record = defaultdict(lambda: self.env[self._name])
records_to_reschedule = self.env[self._name]
# The goal is to automatically exclude ids from the `dependency_field_name` and `dependency_inverted_field_name`
# fields but not the self.ids. And the later call on _web_gantt_reschedule_is_record_candidate will ensure that
# the self.ids are good candidates.
for record in self:
if record.id in record_ids_to_exclude[record.id] \
or not record._web_gantt_reschedule_is_record_candidate(start_date_field_name, stop_date_field_name):
continue
for master_record in record[dependency_field_name]:
#
# A B C D
# \ \ / /
# ------ F ------
# / \
# G --- --- H
#
# So if we are considering we are rescheduling F towards G then, once F is moved, B will be
# added to the candidates as it will be assessed as being in conflict with F, but A won't.
#
# So if we are considering we are rescheduling F towards H then, once F is moved, A, B and G
# will be added to the candidates as we are rescheduling forward.
if master_record.id in record_ids_to_exclude[record.id] \
or not master_record._web_gantt_reschedule_is_record_candidate(
start_date_field_name, stop_date_field_name) \
or not self._web_web_gantt_reschedule_is_relation_candidate(
master_record, record, start_date_field_name, stop_date_field_name) \
or not self._web_gantt_reschedule_is_in_conflict_or_force(
master_record, record, start_date_field_name, stop_date_field_name, rescheduling_forward):
continue
# If we have two same candidates, it means that we are resolving a `loop`
# with an even number of members.
if master_record in slave_per_record:
return False
slave_per_record[master_record] = record
records_to_reschedule |= master_record
for slave_record in record[dependency_inverted_field_name]:
#
# A B C D
# \ \ / /
# ------ F ------
# / \
# G --- --- H
#
# So if we are considering we are rescheduling F towards H then, once F is moved, C will be
# added to the candidates as it will be assessed as being in conflict with F, but D won't.
#
# So if we are considering we are rescheduling F towards G then C, once F is moved, D and H
# will be added to the candidates as we are rescheduling backward.
if slave_record.id in record_ids_to_exclude[record.id] \
or not slave_record._web_gantt_reschedule_is_record_candidate(
start_date_field_name, stop_date_field_name) \
or not self._web_web_gantt_reschedule_is_relation_candidate(
record, slave_record, start_date_field_name, stop_date_field_name) \
or not self._web_gantt_reschedule_is_in_conflict_or_force(
record, slave_record, start_date_field_name, stop_date_field_name, rescheduling_backward):
continue
# If we have two same candidates, it means that we are resolving a `loop`
# with an even number of members.
if slave_record in master_per_record:
return False
master_per_record[slave_record] = record
records_to_reschedule |= slave_record
# If we have a record that is both a slave and a master candidate, it means that we are resolving a `loop`
# with an even number of members.
if set.intersection(set(slave_per_record.keys()), set(master_per_record.keys())):
if set.intersection(*map(set, [record_ids_to_exclude[rec.id]for rec in self])):
return False
# If we have a record from self that is a slave candidate and a record from self that is a master candidate,
# it means that we are resolving a loop with an odd number of members.
if any(record in slave_per_record.keys() for record in self) and \
any(record in master_per_record.keys() for record in self):
return False
return [
(record_to_reschedule,
slave_per_record[record_to_reschedule] or master_per_record[record_to_reschedule],
bool(master_per_record[record_to_reschedule])
) for record_to_reschedule in records_to_reschedule
]
def _web_gantt_reschedule_compute_dates(
self, date_candidate, search_forward, start_date_field_name, stop_date_field_name, cache
):
""" 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.
:param cache: An object that contains reusable information in the context of gantt record rescheduling.
: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])
@api.model
def _web_gantt_reschedule_get_empty_cache(self):
""" Get an empty object that would be used in order to prevent successive database calls during the
rescheduling process.
:return: An object that contains reusable information in the context of gantt record rescheduling.
:rtype: dict
"""
return {}
@api.model
def _web_gantt_reschedule_is_in_conflict(self, master, slave, start_date_field_name, stop_date_field_name):
""" Get whether the dependency relation between a master and a slave record is in conflict.
This check is By-passed for slave records if moving records forwards and the for
master records if moving records backwards (see _web_gantt_get_rescheduling_candidates and
_web_gantt_reschedule_is_in_conflict_or_force). In order to add condition that would not be
by-passed, rather consider _web_gantt_reschedule_is_relation_candidate.
:param master: The master record.
:param slave: The slave record.
: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 there is a conflict, False if not.
:rtype: bool
"""
return master[stop_date_field_name] > slave[start_date_field_name]
@api.model
def _web_gantt_reschedule_is_in_conflict_or_force(
self, master, slave, start_date_field_name, stop_date_field_name, force
):
""" Get whether the dependency relation between a master and a slave record is in conflict.
This check is By-passed for slave records if moving records forwards and the for
master records if moving records backwards. In order to add condition that would not be
by-passed, rather consider _web_gantt_reschedule_is_relation_candidate.
This def purpose is to be able to prevent the default behavior in some modules by overriding
the def and forcing / preventing the rescheduling il all circumstances if needed.
See _web_gantt_get_rescheduling_candidates.
:param master: The master record.
:param slave: The slave record.
: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 force: Force returning True
:return: True if there is a conflict, False if not.
:rtype: bool
"""
return force or self._web_gantt_reschedule_is_in_conflict(
master, slave, start_date_field_name, stop_date_field_name
)
def _web_gantt_reschedule_is_record_candidate(self, start_date_field_name, stop_date_field_name):
""" Get whether the record is a candidate for the rescheduling. This method is meant to be overridden when
we need to add a constraint in order to prevent some records to be rescheduled. This method focuses on the
record itself (if you need to have information on the relation (master and slave) rather override
_web_gantt_reschedule_is_relation_candidate).
:param start_date_field_name: The start date field used in the gantt view.
:param stop_date_field_name: The stop date field used in the gantt view.
:return: True if record can be rescheduled, False if not.
:rtype: bool
"""
self.ensure_one()
return self[start_date_field_name] and self[stop_date_field_name] \
and self[start_date_field_name].replace(tzinfo=timezone.utc) > datetime.now(timezone.utc)
@api.model
def _web_web_gantt_reschedule_is_relation_candidate(self, master, slave, start_date_field_name, stop_date_field_name):
""" Get whether the relation between master and slave 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 relation between records (if your logic is rather on one record, rather override
_web_gantt_reschedule_is_record_candidate).
:param master: The master record we need to evaluate whether it is a candidate for rescheduling or not.
:param slave: The slave record.
: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 True
def _web_gantt_reschedule_record(
self, related_record, is_related_record_master, start_date_field_name, stop_date_field_name, cache
):
""" Shift the record in the future or the past according to the passed arguments.
:param related_record: The related record (either the master or slave record).
:param is_related_record_master: Tells whether the related record is the master or slave in the dependency.
: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 cache: An object that contains reusable information in the context of gantt record rescheduling.
:return: a tuple of (start_date, end_date)
:rtype: tuple(datetime, datetime)
"""
self.ensure_one()
# If the related_record is the master, then we look for a date after the value of its stop_date_field_name.
# If the related_record is the slave, then we look for a date prior to the value of its start_date_field_name.
search_forward = is_related_record_master
if search_forward:
date_candidate = related_record[stop_date_field_name].replace(tzinfo=timezone.utc)
else:
date_candidate = related_record[start_date_field_name].replace(tzinfo=timezone.utc)
return self.sudo()._web_gantt_reschedule_compute_dates(
date_candidate,
search_forward,
start_date_field_name, stop_date_field_name,
cache
)
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
"""
if new_start_date < datetime.now(timezone.utc):
return False
self.write({
start_date_field_name: new_start_date.astimezone(timezone.utc).replace(tzinfo=None),
stop_date_field_name: new_stop_date.astimezone(timezone.utc).replace(tzinfo=None)
})
return True