From 0fa84c6d43c69187b81ea610415c145f099fe6be Mon Sep 17 00:00:00 2001 From: pranay Date: Thu, 20 Nov 2025 10:06:10 +0530 Subject: [PATCH] PMS Updates --- .../__manifest__.py | 5 + .../data/data.xml | 14 + .../models/__init__.py | 5 +- .../models/project.py | 17 +- .../models/project_task.py | 910 ++++++++++++++++-- .../models/project_task_gantt.py | 279 ++++++ .../models/timesheets.py | 19 + .../models/user_availability.py | 202 ++++ .../security/ir.model.access.csv | 4 +- .../security/security.xml | 13 + .../view/pro_task_gantt.xml | 63 ++ .../view/project.xml | 14 + .../view/project_task.xml | 78 +- .../view/project_task_gantt.xml | 367 +++++++ .../view/timesheets.xml | 29 + .../view/user_availability.xml | 344 +++++++ 16 files changed, 2286 insertions(+), 77 deletions(-) create mode 100644 addons_extensions/project_task_timesheet_extended/models/project_task_gantt.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/timesheets.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/user_availability.py create mode 100644 addons_extensions/project_task_timesheet_extended/view/pro_task_gantt.xml create mode 100644 addons_extensions/project_task_timesheet_extended/view/project_task_gantt.xml create mode 100644 addons_extensions/project_task_timesheet_extended/view/timesheets.xml create mode 100644 addons_extensions/project_task_timesheet_extended/view/user_availability.xml diff --git a/addons_extensions/project_task_timesheet_extended/__manifest__.py b/addons_extensions/project_task_timesheet_extended/__manifest__.py index c1d253a94..57c964663 100644 --- a/addons_extensions/project_task_timesheet_extended/__manifest__.py +++ b/addons_extensions/project_task_timesheet_extended/__manifest__.py @@ -24,6 +24,7 @@ Key Features: 'hr_timesheet', 'base', 'analytic', + 'project_gantt', ], 'data': [ 'security/security.xml', @@ -37,6 +38,10 @@ Key Features: 'view/task_stages.xml', 'view/project.xml', 'view/project_task.xml', + 'view/timesheets.xml', + 'view/pro_task_gantt.xml', + 'view/user_availability.xml', + # 'view/project_task_gantt.xml', ], 'assets': { }, diff --git a/addons_extensions/project_task_timesheet_extended/data/data.xml b/addons_extensions/project_task_timesheet_extended/data/data.xml index ed6782d2e..9bcc53817 100644 --- a/addons_extensions/project_task_timesheet_extended/data/data.xml +++ b/addons_extensions/project_task_timesheet_extended/data/data.xml @@ -11,6 +11,20 @@ action = records.action_toggle_pause() + + + + + + + + + + + + + + Projects Channel diff --git a/addons_extensions/project_task_timesheet_extended/models/__init__.py b/addons_extensions/project_task_timesheet_extended/models/__init__.py index 000e815e7..7f9bdc21e 100644 --- a/addons_extensions/project_task_timesheet_extended/models/__init__.py +++ b/addons_extensions/project_task_timesheet_extended/models/__init__.py @@ -1,4 +1,7 @@ from . import teams from . import task_stages from . import project -from . import project_task \ No newline at end of file +from . import project_task +from . import timesheets +# from . import project_task_gantt +from . import user_availability \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/project.py b/addons_extensions/project_task_timesheet_extended/models/project.py index fd20d56cd..9d7b1a893 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project.py +++ b/addons_extensions/project_task_timesheet_extended/models/project.py @@ -15,7 +15,7 @@ class ProjectProject(models.Model): ) discuss_channel_id = fields.Many2one( 'discuss.channel', - string="Project Channel", + string="Channel", domain="[('parent_channel_id', '=', default_projects_channel_id)]", help="Select a channel for project communications. Channels must be sub-channels of the main Projects Channel." ) @@ -159,6 +159,21 @@ class ProjectProject(models.Model): type_ids = fields.Many2many(default=lambda self: self._default_type_ids()) + + estimated_hours = fields.Float(string="Estimated Hours") + task_estimated_hours = fields.Float(string="Task Estimated Hours", compute="_compute_task_estimated_hours", store=True) + actual_hours = fields.Float(string="Actual Hours", compute="_compute_actual_hours", store=True) + + @api.depends('task_ids.estimated_hours') + def _compute_task_estimated_hours(self): + for project in self: + project.task_estimated_hours = sum(project.task_ids.mapped('estimated_hours')) + + @api.depends('task_ids.timesheet_ids.unit_amount') + def _compute_actual_hours(self): + for project in self: + project.actual_hours = sum(project.task_ids.timesheet_ids.mapped('unit_amount')) + def add_users(self): return { 'type': 'ir.actions.act_window', diff --git a/addons_extensions/project_task_timesheet_extended/models/project_task.py b/addons_extensions/project_task_timesheet_extended/models/project_task.py index 1aa8594d0..586820f73 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project_task.py +++ b/addons_extensions/project_task_timesheet_extended/models/project_task.py @@ -1,7 +1,19 @@ from odoo import api, fields, models, _ from markupsafe import Markup -from datetime import datetime +from datetime import datetime, timedelta from odoo.exceptions import UserError, ValidationError +import pytz +from pytz import utc, timezone +from odoo.addons.resource.models.utils import Intervals, sum_intervals +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.osv import expression +from collections import defaultdict + + + CLOSED_STATES = { '1_done': 'Done', @@ -27,8 +39,277 @@ class projectTask(models.Model): show_refuse_button = fields.Boolean(compute="_compute_access_check") show_back_button = fields.Boolean(compute="_compute_access_check") - show_approval_flow = fields.Boolean(compute="_compute_show_approval_flow") + show_approval_flow = fields.Boolean(compute="_compute_show_approval_flow",store=True) record_paused = fields.Boolean(default=False, tracking=True) + estimated_hours = fields.Float( + string="Estimated Hours", + compute="_compute_estimated_hours", + inverse="_inverse_estimated_hours", + store=True + ) + has_supervisor_access = fields.Boolean(compute="_compute_has_supervisor_access") + actual_hours = fields.Float( + string="Actual Hours", + compute="_compute_actual_hours", + store=True + ) + can_edit_approval_flow_stages = fields.Boolean(default=False) + + suggested_deadline = fields.Datetime(string="Suggested Deadline", compute="_compute_suggested_deadline", store=True) + is_suggested_deadline_warning = fields.Boolean( + compute="_compute_deadline_warning", + string="Deadline Warning" + ) + + @api.depends('suggested_deadline', 'date_deadline','timelines_requested','show_approval_flow') + def _compute_deadline_warning(self): + for rec in self: + if rec.suggested_deadline and rec.date_deadline and rec.timelines_requested and rec.show_approval_flow and rec.estimated_hours > 0: + rec.is_suggested_deadline_warning = rec.suggested_deadline > rec.date_deadline + else: + rec.is_suggested_deadline_warning = False + + @api.depends('assignees_timelines.estimated_end_datetime') + def _compute_suggested_deadline(self): + """Compute the suggested deadline based on the latest timeline end date""" + for task in self: + if task.assignees_timelines: + end_dates = [timeline.estimated_end_datetime for timeline in task.assignees_timelines + if timeline.estimated_end_datetime] + if end_dates: + task.suggested_deadline = max(end_dates) + else: + task.suggested_deadline = False + else: + task.suggested_deadline = False + + def _is_within_working_hours(self, dt, calendar, resource_id=None): + """Check if the given datetime is within working hours""" + if not calendar: + return True + + # Get the calendar's timezone + tz = pytz.timezone(calendar.tz) if calendar.tz else pytz.UTC + + # Convert dt to the calendar's timezone and keep it timezone-aware + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + dt_tz = dt.astimezone(tz) + + # Create a resource if resource_id is provided + resource = None + if resource_id: + resource = self.env['resource.resource'].browse(resource_id) + + # Get work intervals for a short period around the datetime + # Keep the datetimes timezone-aware + start_dt_tz = dt_tz + end_dt_tz = dt_tz + timedelta(minutes=1) + + # Use _work_intervals_batch to get work intervals + work_intervals_dict = calendar._work_intervals_batch( + start_dt_tz, + end_dt_tz, + resources=resource, + domain=None, + tz=tz, + compute_leaves=False + ) + + # If no resource is provided, use the default resource (empty resource) + if not resource: + resource = self.env['resource.resource'] + + # Get the work intervals for this resource + work_intervals = work_intervals_dict.get(resource.id) + + # Check if any work interval covers the given datetime + if work_intervals: + # WorkIntervals object can be iterated to get intervals + for interval in work_intervals: + interval_start, interval_end = interval[0], interval[1] + if interval_start <= dt_tz < interval_end: + return True + + return False + + def _get_next_working_datetime(self, start_dt, calendar, resource_id=None): + """Get the next working datetime after the given start datetime""" + if not calendar: + return start_dt + + # Get the calendar's timezone + tz = pytz.timezone(calendar.tz) if calendar.tz else pytz.UTC + + # Convert start_dt to the calendar's timezone + if start_dt.tzinfo is None: + start_dt = pytz.UTC.localize(start_dt) + start_dt_tz = start_dt.astimezone(tz) + + # Check if the current datetime is already within working hours + if self._is_within_working_hours(start_dt, calendar, resource_id): + return start_dt + + # If not, find the next working datetime + # plan_hours returns a naive datetime in the calendar's timezone + next_working_dt_tz_naive = calendar.plan_hours( + 0, + start_dt_tz.replace(tzinfo=None), + compute_leaves=False, + resource=resource_id + ) + + # Localize the result to the calendar's timezone + next_working_dt_tz = tz.localize(next_working_dt_tz_naive) + + # Convert back to UTC + return next_working_dt_tz.astimezone(pytz.UTC).replace(tzinfo=None) + + def action_assign_approx_deadlines(self): + """Calculate and assign approximate start/end datetimes for timelines based on estimated time and user's existing assignments""" + for task in self: + # Sort timelines by stage sequence + timelines = task.assignees_timelines.sorted(lambda t: t.stage_sequence) + + # Get company calendar + calendar = task.company_id.resource_calendar_id or self.env.company.resource_calendar_id + + # Start from current datetime or task planned date + current_start = fields.Datetime.now() + if task.planned_date_begin: + current_start = task.planned_date_begin + + for timeline in timelines: + if not timeline.estimated_time or not timeline.assigned_to: + # Skip if no estimated time or no assigned user + timeline.estimated_start_datetime = False + timeline.estimated_end_datetime = False + continue + + # Get resource ID if available + resource_id = None + if timeline.assigned_to and timeline.assigned_to.employee_id: + resource_id = timeline.assigned_to.employee_id.resource_id.id + + # Get all existing assignments for this user across all projects + user_assignments = self.env['project.task.time.lines'].search([ + ('assigned_to', '=', timeline.assigned_to.id), + ('estimated_end_datetime', '>=', current_start), + ('id', '!=', timeline.id) # Exclude current timeline + ]).sorted('estimated_start_datetime') + + # Find next available slot for the user + start_dt = current_start + + # Adjust start_dt to the next working time if outside working hours + if calendar: + start_dt = self._get_next_working_datetime(start_dt, calendar, resource_id) + + while True: + # Calculate end datetime based on work schedule + if calendar: + # Get the calendar's timezone + tz = pytz.timezone(calendar.tz) if calendar.tz else pytz.UTC + + # Convert start_dt to the calendar's timezone + # Convert start dt to calendar tz, but only once + if start_dt.tzinfo is None: + start_dt = pytz.UTC.localize(start_dt) + + # Convert UTC → calendar timezone + start_dt_tz = start_dt.astimezone(tz) + + # Call plan_hours + end_dt_local_naive = calendar.plan_hours( + timeline.estimated_time, + start_dt_tz, + compute_leaves=False, + resource=resource_id + ) + + # Now end_dt_local_naive is naive LOCAL calendar-time + end_dt_local = end_dt_local_naive + + # Convert to UTC for storage + end_dt = end_dt_local.astimezone(pytz.UTC).replace(tzinfo=None) + start_dt = start_dt_tz.astimezone(pytz.UTC).replace(tzinfo=None) + + else: + # Fallback: just add hours as if 24/7 + end_dt = fields.Datetime.from_string(start_dt) + timedelta(hours=timeline.estimated_time) + + # Check if this slot conflicts with existing assignments + conflict = False + for assignment in user_assignments: + if (start_dt < assignment.estimated_end_datetime and + end_dt > assignment.estimated_start_datetime): + # Conflict found, move start to after this assignment + start_dt = assignment.estimated_end_datetime + + # Adjust to next working time if outside working hours + if calendar: + start_dt = self._get_next_working_datetime(start_dt, calendar, resource_id) + + conflict = True + break + + if not conflict: + # No conflict, use this slot + break + + # Set the timeline dates + if start_dt.tzinfo: + start_dt = start_dt.astimezone(pytz.UTC).replace(tzinfo=None) + + if end_dt.tzinfo: + end_dt = end_dt.astimezone(pytz.UTC).replace(tzinfo=None) + + timeline.estimated_start_datetime = start_dt + timeline.estimated_end_datetime = end_dt + + # Update current_start for next timeline + current_start = end_dt + + # Post to project channel about deadline assignment + channel_message = _("Approximate deadlines assigned for task %s. Suggested deadline: %s") % ( + task.sequence_name or task.name, + task.suggested_deadline.strftime('%Y-%m-%d %H:%M') if task.suggested_deadline else _('Not available') + ) + task._post_to_project_channel(channel_message) + + # Add to activity log + task._add_activity_log("Approximate deadlines assigned by %s. Suggested deadline: %s" % ( + self.env.user.name, + task.suggested_deadline.strftime('%Y-%m-%d %H:%M') if task.suggested_deadline else _('Not available') + )) + + @api.depends("project_id") + def _compute_has_supervisor_access(self): + for task in self: + current_user = self.env.user + task.has_supervisor_access = False + if current_user.has_group("project.group_project_manager") or current_user == task.project_id.user_id or current_user == task.project_id.project_lead: + task.has_supervisor_access = True + + @api.depends('assignees_timelines.estimated_time', 'show_approval_flow') + def _compute_estimated_hours(self): + for task in self: + if task.show_approval_flow: + task.estimated_hours = sum(task.assignees_timelines.mapped('estimated_time')) + + def _inverse_estimated_hours(self): + """Allow editing only if approval flow is disabled.""" + for task in self: + # Only check after record is created + if not task.id: + continue + + if not task.show_approval_flow: + task.write({'estimated_hours': task.estimated_hours}) + @api.depends('timesheet_ids.unit_amount') + def _compute_actual_hours(self): + for task in self: + task.actual_hours = sum(task.timesheet_ids.mapped('unit_amount')) def _post_to_project_channel(self, message_body, mention_partners=None): """Post message to project's discuss channel with proper Odoo mention format""" @@ -94,6 +375,7 @@ class projectTask(models.Model): def action_toggle_pause(self): """Toggle pause state for the record""" for record in self: + record.can_edit_approval_flow_stages = True current_user = self.env.user if record.project_id: if record.project_id.user_id != current_user and record.project_id.project_lead != current_user and not current_user.has_group( @@ -111,6 +393,8 @@ class projectTask(models.Model): record._post_to_project_channel(channel_message) # Add to activity log + + record.can_edit_approval_flow_stages = False record._add_activity_log(f"Task {action} by {self.env.user.name}") @api.depends("assignees_timelines", "stage_id", "project_id", "approval_status") @@ -120,68 +404,69 @@ class projectTask(models.Model): task.show_approval_button = False task.show_refuse_button = False task.show_back_button = False + if task.project_id: + user = self.env.user + project_manager = task.project_id.user_id + project_lead = task.project_id.project_lead - user = self.env.user - project_manager = task.project_id.user_id - project_lead = task.project_id.project_lead + # Get current timeline for this stage + current_timeline = task.assignees_timelines.filtered(lambda s: s.stage_id == task.stage_id) + # Get next stage (if exists) + next_stage = task.project_id.type_ids.filtered(lambda s: s.sequence > task.stage_id.sequence).sorted( + key=lambda s: s.sequence)[:1] - # Get current timeline for this stage - current_timeline = task.assignees_timelines.filtered(lambda s: s.stage_id == task.stage_id) - # Get next stage (if exists) - next_stage = task.project_id.type_ids.filtered(lambda s: s.sequence > task.stage_id.sequence).sorted( - key=lambda s: s.sequence)[:1] + # Compute buttons visibility + if current_timeline: + line = current_timeline[0] + assigned_to = line.assigned_to + responsible_lead = line.responsible_lead - # Compute buttons visibility - if current_timeline: - line = current_timeline[0] - assigned_to = line.assigned_to - responsible_lead = line.responsible_lead + if ( + assigned_to + and assigned_to == user + and task.approval_status != "submitted" + and assigned_to != responsible_lead + ): + task.show_submission_button = True - if ( - assigned_to - and assigned_to == user - and task.approval_status != "submitted" - and assigned_to != responsible_lead - ): - task.show_submission_button = True + # a) Submitted + current user is responsible lead / project manager + if ( + task.approval_status == "submitted" + and (responsible_lead == user or project_manager == user) + ): + task.show_approval_button = True + task.show_refuse_button = True # both approve & refuse in review state - # a) Submitted + current user is responsible lead / project manager - if ( - task.approval_status == "submitted" - and (responsible_lead == user or project_manager == user) - ): + # b) No assigned user → directly approvable + elif not assigned_to and (responsible_lead == user or project_manager == user): + task.show_approval_button = True + + # c) Assigned_to == responsible_lead → no submission needed, direct approve + elif ( + assigned_to + and assigned_to == responsible_lead + and (user == assigned_to or user == project_manager) + ): + task.show_approval_button = True + + else: + # Allow project lead or project manager to approve directly + if user in [project_lead, project_manager]: + task.show_approval_button = True + + if user in [project_manager] or user.has_group("project.group_project_manager"): task.show_approval_button = True - task.show_refuse_button = True # both approve & refuse in review state + task.show_back_button = True - # b) No assigned user → directly approvable - elif not assigned_to and (responsible_lead == user or project_manager == user): - task.show_approval_button = True - - # c) Assigned_to == responsible_lead → no submission needed, direct approve - elif ( - assigned_to - and assigned_to == responsible_lead - and (user == assigned_to or user == project_manager) - ): - task.show_approval_button = True - - else: - # Allow project lead or project manager to approve directly - if user in [project_lead, project_manager]: - task.show_approval_button = True - - if user in [project_manager] or user.has_group("project.group_project_manager"): - task.show_approval_button = True - task.show_back_button = True - - is_first_stage = task.stage_id.sequence == min(task.project_id.type_ids.mapped('sequence')) - if is_first_stage: - task.show_back_button = False - is_last_stage = task.stage_id.sequence == max(task.project_id.type_ids.mapped('sequence')) - if is_last_stage: - task.show_submission_button = False - task.show_approval_button = False - task.show_refuse_button = False + if task.stage_id and task.project_id.type_ids: + is_first_stage = task.stage_id.sequence == min(task.project_id.type_ids.mapped('sequence')) + if is_first_stage: + task.show_back_button = False + is_last_stage = task.stage_id.sequence == max(task.project_id.type_ids.mapped('sequence')) + if is_last_stage: + task.show_submission_button = False + task.show_approval_button = False + task.show_refuse_button = False def _get_current_datetime_formatted(self): """Helper method to get current datetime in '5-NOV-2025 1:20 PM' format""" @@ -206,6 +491,7 @@ class projectTask(models.Model): def back_button(self): for task in self: + task.can_edit_approval_flow_stages = True task.approval_status = False prev_stage = task.project_id.type_ids.filtered(lambda s: s.sequence < task.stage_id.sequence) @@ -250,9 +536,12 @@ class projectTask(models.Model): message_type='notification', subtype_xmlid='mail.mt_comment', ) + task.can_edit_approval_flow_stages = False + def submit_for_approval(self): for task in self: + task.can_edit_approval_flow_stages = True task.approval_status = "submitted" stage = task.assignees_timelines.filtered(lambda s: s.stage_id == task.stage_id) responsible_user = stage.responsible_lead if stage and stage.responsible_lead else False @@ -291,8 +580,11 @@ class projectTask(models.Model): subtype_xmlid='mail.mt_comment', ) + task.can_edit_approval_flow_stages = False + def proceed_further(self): for task in self: + task.can_edit_approval_flow_stages = True current_stage = task.stage_id current_timeline = task.assignees_timelines.filtered(lambda s: s.stage_id == current_stage) next_stage = task.assignees_timelines.filtered(lambda s: s.stage_id.sequence > current_stage.sequence) @@ -370,8 +662,11 @@ class projectTask(models.Model): if is_last_stage: task.state = '1_done' + task.can_edit_approval_flow_stages = False + def reject_and_return(self, reason=None): for task in self: + task.can_edit_approval_flow_stages = True if not reason: reason = "" task.approval_status = "refused" @@ -423,6 +718,8 @@ class projectTask(models.Model): subtype_xmlid='mail.mt_comment', ) + task.can_edit_approval_flow_stages = False + def action_open_reject_wizard(self): """Open rejection wizard""" self.ensure_one() @@ -438,6 +735,7 @@ class projectTask(models.Model): def request_timelines(self): """Populate task timelines with all relevant project stages.""" for task in self: + task.can_edit_approval_flow_stages = True task.timelines_requested = True # Clear existing timelines if needed task.assignees_timelines.unlink() @@ -472,6 +770,7 @@ class projectTask(models.Model): # Post to project channel about timeline request channel_message = _("Timelines requested for task %s") % (task.sequence_name or task.name) task._post_to_project_channel(channel_message) + task.can_edit_approval_flow_stages = False @api.model_create_multi def create(self, vals_list): @@ -507,6 +806,20 @@ class projectTask(models.Model): return tasks + @api.model + def write(self, vals): + # Check if the stage is being changed + if 'stage_id' in vals: + for task in self: + # If user is not allowed to change stage + if task.show_approval_flow and not task.can_edit_approval_flow_stages and not self.env.user.has_group("project.group_project_manager"): + raise UserError(_( + "You are not allowed to change the stage of this task because stage editing is restricted." + )) + + return super(projectTask, self).write(vals) + + def button_update_assignees(self): for task in self: if task.assignees_timelines: @@ -518,6 +831,439 @@ class projectTask(models.Model): channel_message = _("Assignees updated for task %s") % (task.sequence_name or task.name) task._post_to_project_channel(channel_message) + def _fetch_planning_overlap(self, additional_domain=None): + use_timeline_logic = any( + t.timelines_requested and t.show_approval_flow and t.estimated_hours > 0 + for t in self + ) + if use_timeline_logic: + return self._fetch_planning_overlap_timelines(additional_domain) + else: + return self._fetch_planning_overlap_normal(additional_domain) + + def _fetch_planning_overlap_normal(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 + + 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') + ) + ) + + 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 _fetch_planning_overlap_timelines(self, additional_domain=None): + Task = self.env['project.task']._table + TL = self.env['project.task.time.lines']._table + + ids_list = list(self.ids) or [0] + + sql = f""" + SELECT + T1.id, + TL1.estimated_start_datetime, + TL1.estimated_end_datetime, + ARRAY_AGG(T2.id) AS task_ids, + MIN(TL2.estimated_start_datetime) AS min_estimated_start, + MAX(TL2.estimated_end_datetime) AS max_estimated_end, + U.id AS user_id, + P.name AS partner_name, + T1.allocated_hours, + SUM(COALESCE(T2.allocated_hours, 0)) AS sum_allocated_hours + FROM {Task} T1 + JOIN {TL} TL1 ON TL1.task_id = T1.id + JOIN {Task} T2 ON T1.id <> T2.id + JOIN {TL} TL2 ON TL2.task_id = T2.id + JOIN res_users U ON TL1.assigned_to = U.id + LEFT JOIN res_partner P ON U.partner_id = P.id + WHERE + T1.active = TRUE + AND T1.project_id IS NOT NULL + AND T1.timelines_requested = TRUE + AND T1.show_approval_flow = TRUE + AND T1.estimated_hours > 0 + AND TL1.estimated_start_datetime IS NOT NULL + AND TL1.estimated_end_datetime IS NOT NULL + AND TL2.estimated_start_datetime IS NOT NULL + AND TL2.estimated_end_datetime IS NOT NULL + AND (TL1.estimated_start_datetime, TL1.estimated_end_datetime) OVERLAPS (TL2.estimated_start_datetime, TL2.estimated_end_datetime) + AND TL1.assigned_to = TL2.assigned_to + AND T1.id = ANY(%s) + GROUP BY T1.id, TL1.estimated_start_datetime, TL1.estimated_end_datetime, U.id, P.name, T1.allocated_hours + ORDER BY P.name + """ + self.env.cr.execute(sql, (ids_list,)) + 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', + 'timelines_requested', 'show_approval_flow', 'estimated_hours' + ]) + + res = defaultdict(lambda: defaultdict(lambda: { + 'overlapping_tasks_ids': [], + 'sum_allocated_hours': 0, + 'min_planned_date_begin': False, + 'max_date_deadline': False, + 'min_estimated_start': False, + 'max_estimated_end': False, + })) + + rows = self._fetch_planning_overlap([('allocated_hours', '>', 0)]) + + for row in rows: + task_id = row.get('id') + user_id = row.get('user_id') + if task_id is None or user_id is None: + continue + + overlapping_ids = row.get('task_ids') or row.get('array_agg') or [] + allocated_hours = 0.0 + sum_other = 0.0 + + if row.get('allocated_hours') is not None: + try: + allocated_hours = float(row.get('allocated_hours') or 0.0) + except Exception: + allocated_hours = 0.0 + if row.get('sum') is not None: + try: + sum_other = float(row.get('sum') or 0.0) + except Exception: + sum_other = 0.0 + if row.get('sum_allocated_hours') is not None: + try: + sum_other = float(row.get('sum_allocated_hours') or 0.0) + except Exception: + sum_other = 0.0 + + sum_allocated_hours = sum_other + allocated_hours + + min_planned = None + max_deadline = None + for k in ('min', 'min_planned_date_begin', 'min_planned_date', 'planned_date_begin'): + if k in row and row[k] is not None: + min_planned = row[k] + break + for k in ('max', 'max_date_deadline', 'max_deadline', 'date_deadline'): + if k in row and row[k] is not None: + max_deadline = row[k] + break + + min_estimated = None + max_estimated = None + for k in ('min_estimated_start', 'min', 'min_estimated_start_datetime', 'estimated_start_datetime'): + if k in row and row[k] is not None: + min_estimated = row[k] + break + for k in ('max_estimated_end', 'max', 'max_estimated_end_datetime', 'estimated_end_datetime'): + if k in row and row[k] is not None: + max_estimated = row[k] + break + + if min_estimated is None and min_planned is not None: + min_estimated = min_planned + if max_estimated is None and max_deadline is not None: + max_estimated = max_deadline + + res[task_id][user_id] = { + 'partner_name': row.get('partner_name') or row.get('name') or '', + 'overlapping_tasks_ids': overlapping_ids or [], + 'sum_allocated_hours': sum_allocated_hours, + 'min_planned_date_begin': min_planned, + 'max_date_deadline': max_deadline, + 'min_estimated_start': min_estimated, + 'max_estimated_end': max_estimated, + } + + return res + + @api.depends( + 'planned_date_begin', 'date_deadline', 'user_ids', + 'timelines_requested', 'show_approval_flow', 'estimated_hours', + 'assignees_timelines.estimated_start_datetime', + 'assignees_timelines.estimated_end_datetime', + 'assignees_timelines.assigned_to' + ) + def _compute_planning_overlap(self): + overlap_mapping = self._get_planning_overlap_per_task() + + if not overlap_mapping: + for task in self: + task.planning_overlap = False + return {} + + user_ids = set() + first = self[0] + absolute_min_start = utc.localize(first.planned_date_begin or datetime.utcnow()) + absolute_max_end = utc.localize(first.date_deadline or datetime.utcnow()) + + for task in self: + for user_id, mapping in overlap_mapping.get(task.id, {}).items(): + timeline_logic = ( + bool(task.timelines_requested) + and bool(task.show_approval_flow) + and (bool(task.estimated_hours) and float(task.estimated_hours) > 0) + ) + if timeline_logic: + start = mapping.get("min_estimated_start") + end = mapping.get("max_estimated_end") + else: + start = mapping.get("min_planned_date_begin") + end = mapping.get("max_date_deadline") + if not start or not end: + continue + try: + start = utc.localize(start) if not getattr(start, 'tzinfo', None) else start + except Exception: + start = fields.Datetime.context_timestamp(task, fields.Datetime.from_string(start)) if isinstance( + start, str) else None + try: + end = utc.localize(end) if not getattr(end, 'tzinfo', None) else end + except Exception: + end = fields.Datetime.context_timestamp(task, fields.Datetime.from_string(end)) if isinstance(end, + str) else None + if not start or not end: + continue + absolute_min_start = min(absolute_min_start, start) + absolute_max_end = max(absolute_max_end, end) + user_ids.add(user_id) + + if not user_ids: + for task in self: + task.planning_overlap = False + return {} + + users = self.env['res.users'].browse(list(user_ids)) + users_work_intervals, _ = users.sudo()._get_valid_work_intervals(absolute_min_start, absolute_max_end) + + result = {} + + for task in self: + messages = [] + for user_id, mapping in overlap_mapping.get(task.id, {}).items(): + timeline_logic = ( + bool(task.timelines_requested) + and bool(task.show_approval_flow) + and (bool(task.estimated_hours) and float(task.estimated_hours) > 0) + ) + if timeline_logic: + start = mapping.get("min_estimated_start") + end = mapping.get("max_estimated_end") + else: + start = mapping.get("min_planned_date_begin") + end = mapping.get("max_date_deadline") + if not start or not end: + continue + try: + start = utc.localize(start) if not getattr(start, 'tzinfo', None) else start + except Exception: + start = fields.Datetime.context_timestamp(task, fields.Datetime.from_string(start)) if isinstance( + start, str) else None + try: + end = utc.localize(end) if not getattr(end, 'tzinfo', None) else end + except Exception: + end = fields.Datetime.context_timestamp(task, fields.Datetime.from_string(end)) if isinstance(end, + str) else None + if not start or not end: + continue + + task_intervals = Intervals([ + (start, end, self.env['resource.calendar.attendance']) + ]) + + allocated_hours = mapping.get('sum_allocated_hours', 0) or 0 + try: + allocated_hours = float(allocated_hours) + except Exception: + allocated_hours = 0.0 + + user_intervals = users_work_intervals.get(user_id) + available_hours = 0.0 if not user_intervals else sum_intervals(user_intervals & task_intervals) + + if allocated_hours > available_hours: + partner = mapping.get('partner_name') or 'User' + amount = len(mapping.get('overlapping_tasks_ids') or []) + messages.append(f"{partner} has {amount} tasks at the same time.") + if task.id not in result: + result[task.id] = {} + result[task.id][user_id] = mapping + + task.planning_overlap = " ".join(messages) or False + + return result + + @api.model + def _search_planning_overlap(self, operator, value): + if operator not in ['=', '!='] or not isinstance(value, bool): + raise NotImplementedError( + _('Operation not supported. Compare planning_overlap to True or False.') + ) + + # Detect if ANY task in the env uses timeline logic + # We cannot check compute fields in SQL, so we do it in Python + use_timeline_logic = any( + t.timelines_requested + and t.show_approval_flow + and t.estimated_hours > 0 + for t in self.env['project.task'].search([]) + ) + + if use_timeline_logic: + # ---------- TIMELINE MODE ---------- + sql = SQL(""" + ( + SELECT T1.id + FROM project_task T1 + JOIN project_task_time_lines TL1 ON TL1.task_id = T1.id + JOIN project_task_time_lines TL2 + ON TL1.assigned_to = TL2.assigned_to + AND TL1.task_id <> TL2.task_id + WHERE + TL1.estimated_start_datetime < TL2.estimated_end_datetime + AND TL1.estimated_end_datetime > TL2.estimated_start_datetime + AND TL1.estimated_start_datetime IS NOT NULL + AND TL1.estimated_end_datetime IS NOT NULL + AND TL2.estimated_start_datetime IS NOT NULL + AND TL2.estimated_end_datetime IS NOT NULL + AND T1.active = 't' + AND T2.active = 't' + ) + """) + + else: + # ---------- NORMAL MODE ---------- + 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 T2.planned_date_begin IS NOT NULL + AND T2.date_deadline IS NOT NULL + AND T1.active = 't' + AND T2.active = 't' + ) + """) + + operator_new = "in" if ( + (operator == "=" and value) or + (operator == "!=" and not value) + ) else "not in" + + return [('id', operator_new, sql)] + + 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 + class projectTaskTimelines(models.Model): _name = 'project.task.time.lines' @@ -538,8 +1284,6 @@ class projectTaskTimelines(models.Model): , compute="_compute_team_members" ) assigned_to = fields.Many2one('res.users', string="Assigned To", domain="[('id','in',team_all_member_ids or [])]") - estimated_time = fields.Float(string="Estimated Time") - actual_time = fields.Float(string="Actual Time", readonly=True) task_id = fields.Many2one("project.task") project_id = fields.Many2one("project.project", related="task_id.project_id") stage_ids = fields.Many2many(related="project_id.type_ids") @@ -549,8 +1293,52 @@ class projectTaskTimelines(models.Model): compute="_compute_allowed_teams", store=False ) - request_date = fields.Date(string="Request Date") - done_date = fields.Date(string="Done Date") + # request_date = fields.Date(string="Request Date") + # done_date = fields.Date(string="Done Date") + estimated_time = fields.Float(string="Estimated Time") + actual_time = fields.Float(string="Actual Time", compute="_compute_actual_time", store=True) + has_edit_access = fields.Boolean(compute="_compute_has_edit_access") + estimated_time_readonly = fields.Boolean(compute="_compute_estimated_time_readonly") + estimated_start_datetime = fields.Datetime(string="Estimated Start Date Time") + estimated_end_datetime = fields.Datetime(string="Estimated End Date Time") + + # @api.constrains('estimated_start_datetime', 'estimated_end_datetime') + # def _check_dates(self): + # for rec in self: + # if rec.estimated_start_datetime and rec.estimated_end_datetime: + # if rec.estimated_start_datetime >= rec.estimated_end_datetime: + # raise ValidationError(_("End datetime must be after start datetime.")) + + @api.depends('task_id','task_id.sequence_name','stage_id','stage_id.name','estimated_time') + def _compute_display_name(self): + for rec in self: + rec.display_name = f"{rec.task_id.sequence_name} {rec.stage_id.name}" if rec.task_id.sequence_name and rec.stage_id else rec.stage_id.name + + @api.depends("project_id","responsible_lead") + def _compute_has_edit_access(self): + for task in self: + current_user = self.env.user + task.has_edit_access = False + if current_user.has_group( + "project.group_project_manager") or current_user == task.project_id.user_id or current_user == task.responsible_lead or current_user == task.project_id.project_lead: + task.has_edit_access = True + + @api.depends("project_id","task_id") + def _compute_estimated_time_readonly(self): + for task in self: + task.estimated_time_readonly = True + current_user = self.env.user + if task.task_id and task.project_id: + is_first_stage = task.task_id.stage_id.sequence == min(task.task_id.project_id.type_ids.mapped('sequence')) + if is_first_stage or current_user == task.project_id.user_id: + task.estimated_time_readonly = False + + @api.depends('task_id','task_id.timesheet_ids', 'stage_id') + def _compute_actual_time(self): + for task in self: + stage = task.stage_id + task.actual_time = sum( + task.task_id.timesheet_ids.filtered(lambda t: t.stage_id == stage).mapped('unit_amount')) @api.depends('team_id', 'project_id') def _compute_team_members(self): diff --git a/addons_extensions/project_task_timesheet_extended/models/project_task_gantt.py b/addons_extensions/project_task_timesheet_extended/models/project_task_gantt.py new file mode 100644 index 000000000..1b5d50258 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_task_gantt.py @@ -0,0 +1,279 @@ +from odoo import models, fields, api, _ +from datetime import datetime, date +from odoo.exceptions import ValidationError + +class ProjectTask(models.Model): + _inherit = 'project.task' + + # Existing fields + gantt_color = fields.Char(compute='_compute_gantt_color', store=True) + gantt_user_id = fields.Many2one('res.users', compute='_compute_gantt_user_id', store=True) + performance_icon = fields.Char(compute='_compute_performance_icon', store=True) + is_overdue = fields.Boolean(compute='_compute_is_overdue', store=True) + gantt_date_start = fields.Datetime(compute='_compute_gantt_dates', store=True, inverse='_inverse_gantt_date_start') + current_stage_performance = fields.Selection([ + ('good', 'Good'), + ('normal', 'Normal'), + ('bad', 'Bad') + ], compute='_compute_current_stage_performance', store=True) + formatted_start_date = fields.Char(compute='_compute_formatted_dates') + formatted_end_date = fields.Char(compute='_compute_formatted_dates') + + # New fields for better Gantt view + progress = fields.Integer(string="Progress", compute='_compute_progress', store=True) + task_priority = fields.Selection([ + ('0', 'Normal'), + ('1', 'High'), + ('2', 'Urgent') + ], default='0', string="Priority") + is_completed = fields.Boolean(compute='_compute_is_completed', store=True) + completion_date = fields.Datetime(string="Completion Date") + days_remaining = fields.Integer(compute='_compute_days_remaining', store=True) + + @api.depends('stage_id', 'project_id.type_ids') + def _compute_progress(self): + for task in self: + if not task.stage_id or not task.project_id.type_ids: + task.progress = 0 + continue + + stages = task.project_id.type_ids.sorted('sequence') + total_stages = len(stages) + if total_stages == 0: + task.progress = 0 + continue + + current_stage_index = next((i for i, stage in enumerate(stages) if stage.id == task.stage_id.id), 0) + task.progress = int((current_stage_index + 1) / total_stages * 100) + + @api.depends('state') + def _compute_is_completed(self): + for task in self: + task.is_completed = task.state in ['1_done', 'done'] + + @api.depends('date_deadline') + def _compute_days_remaining(self): + today = date.today() + for task in self: + if not task.date_deadline: + task.days_remaining = 0 + continue + + if isinstance(task.date_deadline, datetime): + deadline_date = task.date_deadline.date() + else: + deadline_date = task.date_deadline + + delta = deadline_date - today + task.days_remaining = delta.days + + @api.depends('planned_date_begin', 'date_deadline') + def _compute_formatted_dates(self): + for task in self: + if task.planned_date_begin: + if isinstance(task.planned_date_begin, datetime): + task.formatted_start_date = task.planned_date_begin.strftime('%d-%b-%Y %I:%M %p') + else: + task.formatted_start_date = task.planned_date_begin.strftime('%d-%b-%Y') + else: + task.formatted_start_date = False + + if task.date_deadline: + if isinstance(task.date_deadline, datetime): + task.formatted_end_date = task.date_deadline.strftime('%d-%b-%Y %I:%M %p') + else: + task.formatted_end_date = task.date_deadline.strftime('%d-%b-%Y') + else: + task.formatted_end_date = False + + @api.depends('show_approval_flow', 'assignees_timelines.actual_time', 'assignees_timelines.estimated_time') + def _compute_current_stage_performance(self): + for task in self: + # Don't compute performance for completed tasks + if task.is_completed: + task.current_stage_performance = False + continue + + if task.show_approval_flow: + current_timeline = task.assignees_timelines.filtered(lambda t: t.stage_id == task.stage_id) + if current_timeline: + timeline = current_timeline[0] + actual = timeline.actual_time + estimated = timeline.estimated_time + + if actual < estimated: + task.current_stage_performance = 'good' + elif actual == estimated: + task.current_stage_performance = 'normal' + else: + task.current_stage_performance = 'bad' + else: + task.current_stage_performance = False + else: + task.current_stage_performance = False + + @api.depends('planned_date_begin') + def _compute_gantt_dates(self): + for task in self: + if task.planned_date_begin: + # Ensure we're working with a datetime object + if isinstance(task.planned_date_begin, date) and not isinstance(task.planned_date_begin, datetime): + # Convert date to datetime at start of day + task.gantt_date_start = datetime.combine(task.planned_date_begin, datetime.min.time()) + else: + task.gantt_date_start = task.planned_date_begin + else: + task.gantt_date_start = False + + def _inverse_gantt_date_start(self): + for task in self: + if task.gantt_date_start: + task.planned_date_begin = task.gantt_date_start + + @api.depends('assignees_timelines.assigned_to', 'user_ids', 'show_approval_flow', 'stage_id') + def _compute_gantt_user_id(self): + for task in self: + if task.show_approval_flow: + current_timeline = task.assignees_timelines.filtered(lambda t: t.stage_id == task.stage_id) + if current_timeline: + task.gantt_user_id = current_timeline[0].assigned_to + else: + task.gantt_user_id = task.user_ids[:1] if task.user_ids else self.env.user + else: + task.gantt_user_id = task.user_ids[:1] if task.user_ids else self.env.user + + @api.depends('show_approval_flow', 'assignees_timelines.actual_time', 'assignees_timelines.estimated_time') + def _compute_performance_icon(self): + for task in self: + # Don't show performance icon for completed tasks + if task.is_completed: + task.performance_icon = False + continue + + if task.show_approval_flow: + current_timeline = task.assignees_timelines.filtered(lambda t: t.stage_id == task.stage_id) + if current_timeline: + timeline = current_timeline[0] + actual = timeline.actual_time + estimated = timeline.estimated_time + + if actual < estimated: + task.performance_icon = 'fa-smile-o' + elif actual == estimated: + task.performance_icon = 'fa-meh-o' + else: + task.performance_icon = 'fa-frown-o' + else: + task.performance_icon = 'fa-question' + else: + task.performance_icon = False + + @api.depends('date_deadline') + def _compute_is_overdue(self): + today = date.today() + for task in self: + # Completed tasks are never overdue + if task.is_completed: + task.is_overdue = False + continue + + if not task.date_deadline: + task.is_overdue = False + continue + + # Convert datetime to date if needed + if isinstance(task.date_deadline, datetime): + deadline_date = task.date_deadline.date() + else: + deadline_date = task.date_deadline + + task.is_overdue = deadline_date < today + + @api.depends('show_approval_flow', 'assignees_timelines.actual_time', 'assignees_timelines.estimated_time', + 'date_deadline', 'state', 'is_completed') + def _compute_gantt_color(self): + today = date.today() + for task in self: + # If task is completed, always show green + if task.is_completed: + task.gantt_color = '#28a745' # Green + continue + + if task.show_approval_flow: + current_timeline = task.assignees_timelines.filtered(lambda t: t.stage_id == task.stage_id) + if current_timeline: + timeline = current_timeline[0] + actual = timeline.actual_time + estimated = timeline.estimated_time + + if actual < estimated: + task.gantt_color = '#28a745' # Green + elif actual == estimated: + task.gantt_color = '#007bff' # Blue + else: + task.gantt_color = '#dc3545' # Red + else: + task.gantt_color = '#6c757d' # Gray if no timeline + else: + # Color based on deadline + if task.date_deadline: + # Convert datetime to date if needed + if isinstance(task.date_deadline, datetime): + deadline_date = task.date_deadline.date() + else: + deadline_date = task.date_deadline + + if deadline_date < today: + task.gantt_color = '#dc3545' # Red for overdue + else: + task.gantt_color = '#28a745' # Green for on time + else: + task.gantt_color = '#6c757d' # Gray if no deadline + + @api.model + def _expand_domain_dates(self, domain): + new_domain = [] + for dom in domain: + if dom[0] in ['date_start', 'date_end', 'date_deadline', 'gantt_date_start'] and dom[1] in ['>=', '<=', '>', + '<', '=']: + # Handle both datetime and date string formats + if isinstance(dom[2], datetime): + min_date = dom[2] + else: + try: + # Try parsing with time first + min_date = datetime.strptime(dom[2], '%Y-%m-%d %H:%M:%S') + except ValueError: + try: + # If that fails, try parsing without time + min_date = datetime.strptime(dom[2], '%Y-%m-%d') + except ValueError: + # If both fail, use the original value + min_date = dom[2] + new_domain.append((dom[0], dom[1], min_date)) + else: + new_domain.append(dom) + return new_domain + + def write(self, vals): + # Handle date validation more flexibly + if 'planned_date_begin' in vals and 'date_deadline' in vals: + if vals['planned_date_begin'] and vals['date_deadline']: + if vals['planned_date_begin'] > vals['date_deadline']: + raise ValidationError(_("Planned start date must be before planned end date.")) + elif 'planned_date_begin' in vals and vals['planned_date_begin'] and self.date_deadline: + if vals['planned_date_begin'] > self.date_deadline: + raise ValidationError(_("Planned start date must be before planned end date.")) + elif 'date_deadline' in vals and vals['date_deadline'] and self.planned_date_begin: + if self.planned_date_begin > vals['date_deadline']: + raise ValidationError(_("Planned start date must be before planned end date.")) + + # Update gantt_date_start when planned_date_begin changes + if 'planned_date_begin' in vals: + vals['gantt_date_start'] = vals['planned_date_begin'] + + # Set completion date when task is marked as done + if 'state' in vals and vals['state'] in ['1_done', 'done']: + vals['completion_date'] = fields.Datetime.now() + + return super(ProjectTask, self).write(vals) \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/timesheets.py b/addons_extensions/project_task_timesheet_extended/models/timesheets.py new file mode 100644 index 000000000..7497b0058 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/timesheets.py @@ -0,0 +1,19 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError + +class AccountAnalyticLine(models.Model): + _inherit = 'account.analytic.line' + + stage_id = fields.Many2one( + 'project.task.type', + string="Stage", + domain="[('id', 'in', stage_ids)]" + ) + + stage_ids = fields.Many2many('project.task.type',related="task_id.project_id.type_ids") + + @api.constrains('stage_id', 'task_id') + def _check_stage_required(self): + for line in self: + if line.task_id.show_approval_flow and not line.stage_id: + raise ValidationError(_("Stage is required when Approval Flow is enabled for this task.")) diff --git a/addons_extensions/project_task_timesheet_extended/models/user_availability.py b/addons_extensions/project_task_timesheet_extended/models/user_availability.py new file mode 100644 index 000000000..07fb53d1e --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/user_availability.py @@ -0,0 +1,202 @@ +from odoo import models, fields, api, _ + + +class UserTaskAvailability(models.Model): + _name = 'user.task.availability' + _description = 'User Task Availability' + _auto = False + _order = 'work_start_datetime' + + # -------------------------------------------------------------- + # VIEW FIELDS + # -------------------------------------------------------------- + + user_id = fields.Many2one('res.users', string="User", readonly=True) + task_id = fields.Many2one('project.task', string="Task", readonly=True) + project_id = fields.Many2one('project.project', string="Project", readonly=True) + stage_id = fields.Many2one('project.task.type', string="Stage", readonly=True) + task_name = fields.Char(readonly=True) + task_sequence = fields.Char(readonly=True) + is_generic = fields.Boolean(readonly=True) + + work_start_datetime = fields.Datetime(string="Work Start", readonly=True) + work_end_datetime = fields.Datetime(string="Work End", readonly=True) + + estimated_hours = fields.Float(string="Estimated Hours", readonly=True) + actual_hours = fields.Float(string="Actual Hours") + + source_type = fields.Selection([ + ('timeline', 'Timeline'), + ('task', 'Task'), + ], string="Source", readonly=True) + + # task state options + state = fields.Selection([ + ('01_in_progress', 'In Progress'), + ('02_changes_requested', 'Changes Requested'), + ('03_approved', 'Approved'), + ('1_done', 'Done'), + ('1_canceled', 'Cancelled'), + ('04_waiting_normal', 'Waiting'), + ], string="Task State", readonly=True) + + status_label = fields.Char(string="Status Label", readonly=True) + + progress_emoji = fields.Char(compute='_compute_progress_emoji', string='Progress Emoji') + task_status_color = fields.Integer(compute='_compute_task_status_color', string='Status Color') + progress_percentage = fields.Float(compute='_compute_progress_percentage', string='Progress %') + is_overdue = fields.Boolean(compute='_compute_is_overdue', string='Is Overdue') + is_future = fields.Boolean(compute='_compute_is_future', string='Is Future') + + @api.depends('actual_hours', 'estimated_hours') + def _compute_progress_emoji(self): + for record in self: + if record.actual_hours <= record.estimated_hours: + record.progress_emoji = '😊' + elif record.actual_hours > record.estimated_hours: + record.progress_emoji = '😞' + else: + record.progress_emoji = '' + + @api.depends('state', 'work_start_datetime', 'work_end_datetime') + def _compute_task_status_color(self): + today = fields.Date.today() + for record in self: + if record.state == '1_done': + record.task_status_color = 10 # Green + elif record.state == '1_canceled': + record.task_status_color = 0 + elif record.work_start_datetime and record.work_start_datetime.date() > today: + record.task_status_color = 0 # Gray + elif record.work_end_datetime and record.work_end_datetime.date() < today and record.state != '1_done': + record.task_status_color = 1 # Red + else: + record.task_status_color = 8 # Default blue + + @api.depends('actual_hours', 'estimated_hours') + def _compute_progress_percentage(self): + for record in self: + if record.estimated_hours > 0: + record.progress_percentage = min(100, (record.actual_hours / record.estimated_hours) * 100) + else: + record.progress_percentage = 0 + + @api.depends('work_end_datetime', 'state') + def _compute_is_overdue(self): + today = fields.Date.today() + for record in self: + record.is_overdue = (record.work_end_datetime and record.work_end_datetime.date() < today and record.state != '1_done') or (record.actual_hours > record.estimated_hours) + + @api.depends('work_start_datetime') + def _compute_is_future(self): + today = fields.Date.today() + for record in self: + record.is_future = record.work_start_datetime and record.work_start_datetime.date() > today + @api.depends('task_id', 'task_sequence', 'stage_id') + def _compute_display_name(self): + for rec in self: + rec.display_name = f"{rec.task_sequence}" if rec.task_sequence else rec.task_id.name + + + # -------------------------------------------------------------- + # CREATE SQL VIEW + # -------------------------------------------------------------- + def init(self): + self.env.cr.execute("""DROP VIEW IF EXISTS user_task_availability CASCADE;""") + self.env.cr.execute(""" + CREATE OR REPLACE VIEW user_task_availability AS ( + + ----------------------------------------------------------------- + -- 1️⃣ MAIN SOURCE: project.task.time.lines + -- Only when timelines_requested + show_approval_flow are TRUE + ----------------------------------------------------------------- + SELECT + ROW_NUMBER() OVER () AS id, + tl.assigned_to AS user_id, + tl.task_id AS task_id, + t.project_id AS project_id, + tl.stage_id AS stage_id, + t.name AS task_name, + t.sequence_name AS task_sequence, + t.is_generic AS is_generic, + + tl.estimated_start_datetime AS work_start_datetime, + tl.estimated_end_datetime AS work_end_datetime, + + tl.estimated_time AS estimated_hours, + tl.actual_time AS actual_hours, + + 'timeline' AS source_type, + + t.state AS state, + + CASE + WHEN t.state = '01_in_progress' THEN 'In Progress' + WHEN t.state = '02_changes_requested' THEN 'Changes Requested' + WHEN t.state = '03_approved' THEN 'Approved' + WHEN t.state = '1_done' THEN 'Done' + WHEN t.state = '1_canceled' THEN 'Cancelled' + WHEN t.state = '04_waiting_normal' THEN 'Waiting' + ELSE 'Unknown' + END AS status_label + + FROM project_task_time_lines tl + JOIN project_task t ON t.id = tl.task_id + + WHERE t.timelines_requested = TRUE + AND t.show_approval_flow = TRUE + AND tl.assigned_to IS NOT NULL + AND tl.estimated_start_datetime IS NOT NULL + AND tl.estimated_end_datetime IS NOT NULL + + + UNION ALL + + ----------------------------------------------------------------- + -- 2️⃣ FALLBACK SOURCE: project.task + user_ids (Many2many) + -- Only tasks WITHOUT timeline entries for that user + ----------------------------------------------------------------- + SELECT + ROW_NUMBER() OVER () + 1000000 AS id, -- avoid overlap + rel.user_id AS user_id, + t.id AS task_id, + t.project_id AS project_id, + t.stage_id AS stage_id, + t.name AS task_name, + t.sequence_name AS task_sequence, + t.is_generic AS is_generic, + + COALESCE(t.date_assign, t.create_date) AS work_start_datetime, + t.date_deadline AS work_end_datetime, + + t.estimated_hours AS estimated_hours, + t.actual_hours AS actual_hours, + + 'task' AS source_type, + + t.state AS state, + + CASE + WHEN t.state = '01_in_progress' THEN 'In Progress' + WHEN t.state = '02_changes_requested' THEN 'Changes Requested' + WHEN t.state = '03_approved' THEN 'Approved' + WHEN t.state = '1_done' THEN 'Done' + WHEN t.state = '1_canceled' THEN 'Cancelled' + WHEN t.state = '04_waiting_normal' THEN 'Waiting' + ELSE 'Unknown' + END AS status_label + + FROM project_task t + JOIN project_task_user_rel rel ON rel.task_id = t.id + + WHERE NOT EXISTS ( + SELECT 1 + FROM project_task_time_lines tl2 + WHERE tl2.task_id = t.id + AND tl2.assigned_to = rel.user_id + AND tl2.estimated_start_datetime IS NOT NULL + AND tl2.estimated_end_datetime IS NOT NULL + ) + + ); + """) diff --git a/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv b/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv index f9f1dcb03..aaa5eea4b 100644 --- a/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv +++ b/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv @@ -18,4 +18,6 @@ access_project_task_type_supervisor,project.task.type supervisor,project.model_p access_project_tags_supervisor,project.project_tags_supervisor,project.model_project_tags,project_task_timesheet_extended.group_project_supervisor,1,1,1,1 access_project_task_time_lines_user,access_project_task_time_lines_user,model_project_task_time_lines,base.group_user,1,1,1,1 -access_project_task_time_lines_manager,access_project_task_time_lines_manager,model_project_task_time_lines,project.group_project_manager,1,1,1,1 \ No newline at end of file +access_project_task_time_lines_manager,access_project_task_time_lines_manager,model_project_task_time_lines,project.group_project_manager,1,1,1,1 + +access_user_task_availability,user.task.availability.access,model_user_task_availability,base.group_user,1,0,0,0 \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/security/security.xml b/addons_extensions/project_task_timesheet_extended/security/security.xml index 8c40cb2ae..a438c0acd 100644 --- a/addons_extensions/project_task_timesheet_extended/security/security.xml +++ b/addons_extensions/project_task_timesheet_extended/security/security.xml @@ -65,6 +65,19 @@ + + Task Availability: project lead: see all user tasks + + + [ + '|', '|', + ('project_id.project_lead', '=', user.id), + ('user_id', '=', user.id), + ('project_id.user_id', '=', user.id), + ] + + + diff --git a/addons_extensions/project_task_timesheet_extended/view/pro_task_gantt.xml b/addons_extensions/project_task_timesheet_extended/view/pro_task_gantt.xml new file mode 100644 index 000000000..24406dcc9 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/view/pro_task_gantt.xml @@ -0,0 +1,63 @@ + + + + project.task.view.gantt + project.task + 10 + + + +
+
+ Project — + + Private +
+
+ Milestone — +
+
Assignees —
+
Customer —
+
Allocated Time —
+
+ + + +
+
+ +
+
+
+
+
+ + + + + + + + +
+
+
+ +
\ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/view/project.xml b/addons_extensions/project_task_timesheet_extended/view/project.xml index 09371be73..8b0d60958 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project.xml @@ -12,6 +12,7 @@ + @@ -80,4 +81,17 @@ + + + project.invoice.inherit.form.view + project.project + + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/view/project_task.xml b/addons_extensions/project_task_timesheet_extended/view/project_task.xml index a30886c50..01f454d1e 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project_task.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project_task.xml @@ -23,23 +23,33 @@ - + + + + + +