# -*- 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': [ { '': , ..., '__record_ids': [] } ], 'records': [] '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: , stop: }, { start: , stop: }, ...] 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