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