From 66077d181935d82429b04fe60267708afdd2bc3c Mon Sep 17 00:00:00 2001 From: pranay Date: Thu, 13 Nov 2025 12:36:54 +0530 Subject: [PATCH] Project Task Extended Changes --- .../__manifest__.py | 5 + .../data/data.xml | 72 +++ .../project_task_timesheet_extended/hooks.py | 28 + .../models/project.py | 112 ++++ .../models/project_task.py | 573 +++++++++++++++++- .../models/task_stages.py | 26 +- .../models/teams.py | 27 +- .../security/ir.model.access.csv | 11 +- .../security/security.xml | 63 ++ .../view/project.xml | 46 +- .../view/project_task.xml | 77 ++- .../view/teams.xml | 57 +- .../wizards/__init__.py | 5 +- .../wizards/internal_team_members_wizard.py | 44 ++ .../wizards/internal_team_members_wizard.xml | 28 + .../wizards/project_stage_update_wizard.py | 70 +++ .../wizards/project_stage_update_wizard.xml | 32 + .../wizards/task_reject_reason_wizard.py | 22 + .../wizards/task_reject_reason_wizard.xml | 25 + 19 files changed, 1297 insertions(+), 26 deletions(-) create mode 100644 addons_extensions/project_task_timesheet_extended/data/data.xml create mode 100644 addons_extensions/project_task_timesheet_extended/wizards/internal_team_members_wizard.py create mode 100644 addons_extensions/project_task_timesheet_extended/wizards/internal_team_members_wizard.xml create mode 100644 addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.py create mode 100644 addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.xml create mode 100644 addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.py create mode 100644 addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.xml diff --git a/addons_extensions/project_task_timesheet_extended/__manifest__.py b/addons_extensions/project_task_timesheet_extended/__manifest__.py index 95f62283d..c1d253a94 100644 --- a/addons_extensions/project_task_timesheet_extended/__manifest__.py +++ b/addons_extensions/project_task_timesheet_extended/__manifest__.py @@ -23,11 +23,16 @@ Key Features: 'project', 'hr_timesheet', 'base', + 'analytic', ], 'data': [ 'security/security.xml', 'security/ir.model.access.csv', + 'data/data.xml', 'wizards/project_user_assign_wizard.xml', + 'wizards/internal_team_members_wizard.xml', + 'wizards/project_stage_update_wizard.xml', + 'wizards/task_reject_reason_wizard.xml', 'view/teams.xml', 'view/task_stages.xml', 'view/project.xml', diff --git a/addons_extensions/project_task_timesheet_extended/data/data.xml b/addons_extensions/project_task_timesheet_extended/data/data.xml new file mode 100644 index 000000000..ed6782d2e --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/data/data.xml @@ -0,0 +1,72 @@ + + + + Pause/Unpause + + + action + code + + if records: + action = records.action_toggle_pause() + + + + + Projects Channel + Main channel for all project communications + channel + + + + + + 100 + Backlog + + + + + 101 + Development + + + + + 102 + Code Review & Git Merging + + + + + 103 + Testing + + + + + 104 + Deployment + + + + + 105 + Completed + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/hooks.py b/addons_extensions/project_task_timesheet_extended/hooks.py index 6ee15a629..597b8659b 100644 --- a/addons_extensions/project_task_timesheet_extended/hooks.py +++ b/addons_extensions/project_task_timesheet_extended/hooks.py @@ -103,6 +103,34 @@ def post_init_hook(env): """ }) + timesheet_approver_rule = env.ref('hr_timesheet.timesheet_line_rule_approver', raise_if_not_found=False) + if timesheet_approver_rule: + timesheet_approver_rule.write({ + 'domain_force': """ + ['&', '&', + ('project_id', '!=', False), + ('task_id', '!=', False), + '|', + '&', + ('project_id.privacy_visibility', '=', 'followers'), + '|', + '|', + ('project_id.project_lead', '=', user.id), + ('project_id.user_id', '=', user.id), + '|', + '&', + ('task_id.is_generic', '=', False), + ('user_id', 'in', 'task_id.user_ids'), + '&', + ('task_id.is_generic', '=', True), + ('user_id.partner_id', 'in', 'project_id.message_partner_ids'), + '&', '&', + ('project_id.privacy_visibility', '!=', 'followers'), + ('task_id.is_generic', '=', False), + ('user_id', 'in', 'task_id.user_ids') + ] + """ + }) # Get all projects without sequence_name, sorted by creation date projects = env['project.project'].search([('sequence_name', '=', False)], order='create_date asc') diff --git a/addons_extensions/project_task_timesheet_extended/models/project.py b/addons_extensions/project_task_timesheet_extended/models/project.py index cb0244d2b..fd20d56cd 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project.py +++ b/addons_extensions/project_task_timesheet_extended/models/project.py @@ -1,4 +1,5 @@ from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError class ProjectProject(models.Model): @@ -12,6 +13,100 @@ class ProjectProject(models.Model): copy=False, help="Sequence for tasks of this project" ) + discuss_channel_id = fields.Many2one( + 'discuss.channel', + string="Project 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." + ) + default_projects_channel_id = fields.Many2one( + 'discuss.channel', + default=lambda self: self._get_default_projects_channel(), + string="Default Projects Channel" + ) + + @api.model + def _get_default_projects_channel(self): + """Get or create the default Projects Channel""" + channel = self.env['discuss.channel'].search([ + ('name', '=', 'Projects Channel'), + ('channel_type', '=', 'channel') + ], limit=1) + + if not channel: + channel = self.env['discuss.channel'].create({ + 'name': 'Projects Channel', + 'description': 'Main channel for all project communications', + 'channel_type': 'channel', + }) + return channel + + def action_create_project_channel(self): + """Create a new channel for this project under the Projects Channel""" + self.ensure_one() + + if self.discuss_channel_id: + raise UserError(_("This project already has a channel assigned.")) + + # Create new channel + channel_vals = { + 'name': self.name, + 'description': _("Communication channel for project %s") % self.name, + 'channel_type': 'channel', + 'parent_channel_id': self.default_projects_channel_id.id, + } + + new_channel = self.env['discuss.channel'].create(channel_vals) + self.discuss_channel_id = new_channel.id + + # Add project members to the channel + self._add_project_members_to_channel() + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'discuss.channel', + 'res_id': new_channel.id, + 'view_mode': 'form', + 'target': 'current', + 'context': {'create': False} + } + + def _add_project_members_to_channel(self): + """Add all project members as followers of the channel""" + if not self.discuss_channel_id: + return + + # Get all users related to this project + members_to_add = self.env['res.users'] + + # Add project members + if self.members_ids: + members_to_add |= self.members_ids + + # Add project manager + if self.user_id: + members_to_add |= self.user_id + + # Add project lead if exists + if hasattr(self, 'project_lead') and self.project_lead: + members_to_add |= self.project_lead + + # Add members to channel + for member in members_to_add: + self.discuss_channel_id.add_members(member.partner_id.ids) + + def write(self, vals): + """Override write to update channel members when project members change""" + result = super().write(vals) + + # If members changed, update channel members + if any(field in vals for field in ['members_ids', 'user_id', 'project_lead']): + for project in self: + if project.discuss_channel_id: + project._add_project_members_to_channel() + + return result + @api.model def _get_shared_project_sequence(self): @@ -35,8 +130,24 @@ class ProjectProject(models.Model): for project in projects: if not project.sequence_name: project.sequence_name = sequence.next_by_id() + if project.discuss_channel_id: + project._add_project_members_to_channel() return projects + def _default_type_ids(self): + default_stage_ids = [ + self.env.ref('project_task_timesheet_extended.task_type_backlog').id, + self.env.ref('project_task_timesheet_extended.task_type_development').id, + self.env.ref('project_task_timesheet_extended.task_type_code_review_and_merging').id, + self.env.ref('project_task_timesheet_extended.task_type_testing').id, + self.env.ref('project_task_timesheet_extended.task_type_deployment').id, + self.env.ref('project_task_timesheet_extended.task_type_completed').id, + ] + + # self.env.ref('project_task_timesheet_extended.task_type_cancelled').id, + # self.env.ref('project_task_timesheet_extended.task_type_hold').id, + return self.env['project.task.type'].browse(default_stage_ids) + project_lead = fields.Many2one("res.users", string="Project Lead") members_ids = fields.Many2many('res.users', 'project_user_rel', 'project_id', 'user_id', 'Project Members', help="""Project's @@ -46,6 +157,7 @@ class ProjectProject(models.Model): user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user, tracking=True, domain=lambda self: [('groups_id', 'in', [self.env.ref('project.group_project_manager').id,self.env.ref('project_task_timesheet_extended.group_project_supervisor').id]),('share','=',False)],) + type_ids = fields.Many2many(default=lambda self: self._default_type_ids()) def add_users(self): return { 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 4af1a847b..1aa8594d0 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project_task.py +++ b/addons_extensions/project_task_timesheet_extended/models/project_task.py @@ -1,23 +1,477 @@ from odoo import api, fields, models, _ +from markupsafe import Markup +from datetime import datetime +from odoo.exceptions import UserError, ValidationError + +CLOSED_STATES = { + '1_done': 'Done', + '1_canceled': 'Cancelled', +} + class projectTask(models.Model): _inherit = 'project.task' _rec_name = 'name' sequence_name = fields.Char("Sequence", copy=False) - is_generic = fields.Boolean(string='Generic',default=True, help='All the followers would be able to see this task if the generic is set to true else only the assigned users would have the access to it') - assigned_team = fields.Many2one("internal.teams") + is_generic = fields.Boolean(string='Generic', default=True, tracking=True, + help='All the followers would be able to see this task if the generic is set to true else only the assigned users would have the access to it') + assignees_timelines = fields.One2many('project.task.time.lines', 'task_id', string="Assignees Timelines", + tracking=True) + task_activity_log = fields.Html(string="Task Activity Log") + timelines_requested = fields.Boolean(tracking=True) + approval_status = fields.Selection([('submitted', 'Submitted'), ('approved', 'Approved'), ('refused', 'Refused')]) + project_privacy_visibility = fields.Selection(related="project_id.privacy_visibility") + show_submission_button = fields.Boolean(compute="_compute_access_check") + show_approval_button = fields.Boolean(compute="_compute_access_check") + show_refuse_button = fields.Boolean(compute="_compute_access_check") + show_back_button = fields.Boolean(compute="_compute_access_check") - @api.onchange("assigned_team") - def onchange_assigned_team(self): + show_approval_flow = fields.Boolean(compute="_compute_show_approval_flow") + record_paused = fields.Boolean(default=False, tracking=True) + + def _post_to_project_channel(self, message_body, mention_partners=None): + """Post message to project's discuss channel with proper Odoo mention format""" + for task in self: + if not task.project_id: + continue + + # Get the project channel or default projects channel + channel = task.project_id.discuss_channel_id or task.project_id.default_projects_channel_id + + if channel: + # Format message with proper Odoo mentions + formatted_message = self._format_message_with_odoo_mentions(message_body, mention_partners) + + # Post to channel - use Markup to ensure HTML is rendered properly + channel.message_post( + body=Markup(formatted_message), + message_type='comment', + subtype_xmlid='mail.mt_comment', + author_id=self.env.user.partner_id.id + ) + + def _format_message_with_odoo_mentions(self, message_body, mention_partners=None): + """Format message with proper Odoo @mentions that will render correctly""" + if not mention_partners: + # Return plain message wrapped in a div for proper rendering + return f'
{message_body}
' + + # Build the message with mentions + message_parts = [] + message_parts.append('
') + message_parts.append(message_body) + + # Add mentions at the end + for partner in mention_partners: + if partner and partner.name: + mention_html = f'@{partner.name}' + message_parts.append(mention_html) + + message_parts.append('
') + return ' '.join(message_parts) + + def _create_odoo_mention(self, partner): + """Create the proper Odoo mention format that renders correctly""" + if not partner: + return "" + return f'@{partner.name}' + + def _get_mention_partners(self, users): + """Convert user records to partner records for mentioning""" + if not users: + return self.env['res.partner'] + return users.mapped('partner_id') + + @api.depends("project_id", "stage_id", "is_generic") + def _compute_show_approval_flow(self): for rec in self: - if rec.assigned_team: - user_ids = rec.assigned_team.members_ids.ids - if rec.assigned_team.team_lead: - user_ids.append(rec.assigned_team.team_lead.id) - rec.user_ids = [(6, 0, user_ids)] + if rec.project_id.privacy_visibility == 'followers' and not rec.is_generic: + rec.show_approval_flow = True else: - rec.user_ids = [(5, 0, 0)] + rec.show_approval_flow = False + + def action_toggle_pause(self): + """Toggle pause state for the record""" + for record in self: + 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( + 'project.group_project_manager'): + raise UserError(_("Access denied: You do not have sufficient privileges to use this feature.")) + record.record_paused = not record.record_paused + + # Post to project channel + action = "paused" if record.record_paused else "resumed" + channel_message = _("Task %s has been %s by %s") % ( + record.sequence_name or record.name, + action, + self.env.user.name + ) + record._post_to_project_channel(channel_message) + + # Add to activity log + record._add_activity_log(f"Task {action} by {self.env.user.name}") + + @api.depends("assignees_timelines", "stage_id", "project_id", "approval_status") + def _compute_access_check(self): + for task in self: + task.show_submission_button = False + task.show_approval_button = False + task.show_refuse_button = False + task.show_back_button = False + + 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] + + # 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 + + # 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 + + # 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 + + def _get_current_datetime_formatted(self): + """Helper method to get current datetime in '5-NOV-2025 1:20 PM' format""" + now = fields.Datetime.context_timestamp(self, datetime.now()) + # Format: Day-MON-YEAR Hour:Minute AM/PM + formatted_date = now.strftime('%d-%b-%Y %I:%M %p').upper() + # Remove leading zero from day if present + if formatted_date[0] == '0': + formatted_date = formatted_date[1:] + return formatted_date + + def _add_activity_log(self, activity_text): + """Helper method to properly format HTML activity log""" + formatted_datetime = self._get_current_datetime_formatted() + for task in self: + log_entry = f"[{formatted_datetime}] {activity_text}" + if task.task_activity_log: + # Use Markup to safely combine HTML content + task.task_activity_log = Markup(task.task_activity_log) + Markup('
') + Markup(log_entry) + else: + task.task_activity_log = Markup(log_entry) + + def back_button(self): + for task in self: + task.approval_status = False + + prev_stage = task.project_id.type_ids.filtered(lambda s: s.sequence < task.stage_id.sequence) + prev_stage = prev_stage.sorted(key=lambda s: s.sequence, reverse=True)[:1] # Get next one + + stage = task.assignees_timelines.filtered(lambda s: s.stage_id == prev_stage) + responsible_user = stage.assigned_to if stage and stage.assigned_to else ( + task.project_id.project_lead if task.project_id.project_lead else False) + + activity_log = "%s : %s Reverted the stage Back to %s" % ( + task.stage_id.name, + self.env.user.employee_id.name, + prev_stage.name + ) + + task.stage_id = prev_stage + + # Use the helper method to add activity log + task._add_activity_log(activity_log) + + # Post to project channel with mention using proper Odoo format + if responsible_user: + channel_message = _("Task %s reverted from %s back to %s. %s please take action.") % ( + task.sequence_name or task.name, + task.stage_id.name, + prev_stage.name, + self._create_odoo_mention(responsible_user.partner_id) + ) + else: + channel_message = _("Task %s reverted from %s back to %s") % ( + task.sequence_name or task.name, + task.stage_id.name, + prev_stage.name + ) + task._post_to_project_channel(channel_message) + + # Send chatter notification + if responsible_user: + task.message_post( + body=activity_log, + partner_ids=[responsible_user.partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment', + ) + + def submit_for_approval(self): + for task in self: + 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 + + activity_log = "%s : %s Submitted to %s for approval" % ( + task.stage_id.name, + self.env.user.employee_id.name, + stage.responsible_lead.name if stage and stage.responsible_lead else "" + ) + + message_notes = "%s : %s submitted for approval" % (task.stage_id.name, task.sequence_name) + + # Use the helper method to add activity log + task._add_activity_log(activity_log) + + # Post to project channel with proper Odoo mention format + if responsible_user: + channel_message = _("Task %s submitted for approval at stage %s. %s please review.") % ( + task.sequence_name or task.name, + task.stage_id.name, + self._create_odoo_mention(responsible_user.partner_id) + ) + else: + channel_message = _("Task %s submitted for approval at stage %s") % ( + task.sequence_name or task.name, + task.stage_id.name + ) + task._post_to_project_channel(channel_message) + + # Send chatter notification + if responsible_user: + task.message_post( + body=message_notes, + partner_ids=[responsible_user.partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment', + ) + + def proceed_further(self): + for task in self: + 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) + next_stage = next_stage.sorted(key=lambda s: s.stage_id.sequence)[:1] # Get next one + + n_stage = task.project_id.type_ids.filtered(lambda s: s.sequence > task.stage_id.sequence) + n_stage = n_stage.sorted(key=lambda s: s.sequence)[:1] + + if n_stage: + task.stage_id = n_stage + task.approval_status = "approved" + + activity_log = "%s: ✅ approved by %s and moved to %s" % ( + current_stage.name, + self.env.user.employee_id.name, + n_stage.name) + + # Use the helper method to add activity log + task._add_activity_log(activity_log) + + user_notes = "%s: ✅ moved to %s and awaiting your completion" % ( + task.sequence_name, + n_stage.name + ) + + next_user = next_stage.responsible_lead if next_stage.responsible_lead else task.project_id.user_id + if next_stage.assigned_to: + next_user = next_stage.assigned_to + + # Post to project channel with proper Odoo mention format + if next_user: + channel_message = _("Task %s approved at stage %s and moved to %s. %s please proceed.") % ( + task.sequence_name or task.name, + current_stage.name, + n_stage.name, + self._create_odoo_mention(next_user.partner_id) + ) + else: + channel_message = _("Task %s approved at stage %s and moved to %s") % ( + task.sequence_name or task.name, + current_stage.name, + n_stage.name + ) + task._post_to_project_channel(channel_message) + + task.message_post( + body=user_notes, + partner_ids=[next_user.partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment', + ) + else: + task.approval_status = "approved" + notes = "%s: ✅ Task approved and completed by %s" % (task.sequence_name, self.env.user.employee_id.name) + + activity_log = "%s: ✅ approved by %s" % ( + current_stage.name, + self.env.user.employee_id.name) + + # Use the helper method to add activity log + task._add_activity_log(activity_log) + + # Post to project channel + channel_message = _("Task %s completed and approved at stage %s") % ( + task.sequence_name or task.name, + current_stage.name + ) + task._post_to_project_channel(channel_message) + + task.message_post(body=notes, partner_ids=task.user_ids.partner_id.ids, + message_type='notification', + subtype_xmlid='mail.mt_comment', ) + + is_last_stage = task.stage_id.sequence == max(task.project_id.type_ids.mapped('sequence')) + if is_last_stage: + task.state = '1_done' + + def reject_and_return(self, reason=None): + for task in self: + if not reason: + reason = "" + task.approval_status = "refused" + current_stage = task.stage_id + current_timeline = task.assignees_timelines.filtered(lambda s: s.stage_id == current_stage) + + # Optional: find previous stage if you want to send back + stage = task.assignees_timelines.filtered(lambda s: s.stage_id == task.stage_id) + + notes = "%s: ❌ %s rejected by %s" % (task.sequence_name, current_stage.name, self.env.user.employee_id.name) + + activity_log = "%s: ❌ rejected by %s: %s" % ( + current_stage.name, + self.env.user.employee_id.name, + reason) + + # Use the helper method to add activity log + task._add_activity_log(activity_log) + + # Post to project channel + channel_message = _("Task %s rejected at stage %s. Reason: %s") % ( + task.sequence_name or task.name, + current_stage.name, + reason + ) + task._post_to_project_channel(channel_message) + + task.message_post( + body=notes, + message_type='notification', + subtype_xmlid='mail.mt_comment', + ) + + if stage: + responsible_user = stage.assigned_to if stage.assigned_to else stage.responsible_lead if stage.responsible_lead else task.project_id.user_id + + # Post additional notification to responsible user with proper Odoo mention + if responsible_user: + user_channel_message = _("Task %s has been rejected and returned to you %s") % ( + task.sequence_name or task.name, + self._create_odoo_mention(responsible_user.partner_id) + ) + task._post_to_project_channel(user_channel_message) + + task.message_post( + body="%s: Task has been rejected and returned to you." % (task.sequence_name), + partner_ids=[responsible_user.partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment', + ) + + def action_open_reject_wizard(self): + """Open rejection wizard""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Reject Task"), + "res_model": "task.reject.reason.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_task_id": self.id}, + } + + def request_timelines(self): + """Populate task timelines with all relevant project stages.""" + for task in self: + task.timelines_requested = True + # Clear existing timelines if needed + task.assignees_timelines.unlink() + + # Fetch project stages + stages = task.project_id.type_ids.filtered(lambda s: not s.fold) + if not stages: + continue + + timeline_vals = [] + for stage in stages: + responsible_user = False + if stage.approval_by == 'assigned_team_lead' and stage.team_id.team_lead: + responsible_user = stage.team_id.team_lead.id + elif stage.approval_by == 'project_manager' and task.project_id.user_id: + responsible_user = task.project_id.user_id.id + elif stage.approval_by == 'project_lead' and getattr(task.project_id, 'project_lead', False): + responsible_user = task.project_id.project_lead.id + + timeline_vals.append({ + 'stage_id': stage.id, + 'team_id': stage.team_id.id if stage.team_id else False, + 'responsible_lead': responsible_user, + 'assigned_to': responsible_user, + 'estimated_time': 0.0, + 'task_id': task.id, + }) + + if timeline_vals: + self.env['project.task.time.lines'].create(timeline_vals) + + # 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) @api.model_create_multi def create(self, vals_list): @@ -45,4 +499,103 @@ class projectTask(models.Model): for task in tasks: if task.project_id and task.project_id.task_sequence_id: task.sequence_name = task.project_id.task_sequence_id.next_by_id() + + # Post to project channel about task creation + if task.project_id: + channel_message = _("New task created: %s") % (task.sequence_name or task.name) + task._post_to_project_channel(channel_message) + return tasks + + def button_update_assignees(self): + for task in self: + if task.assignees_timelines: + users_list = list( + set(task.assignees_timelines.responsible_lead.ids + task.assignees_timelines.assigned_to.ids + task.assignees_timelines.team_id.team_lead.ids)) + task.user_ids = [(6, 0, users_list)] + + # Post to project channel about assignee update + channel_message = _("Assignees updated for task %s") % (task.sequence_name or task.name) + task._post_to_project_channel(channel_message) + + +class projectTaskTimelines(models.Model): + _name = 'project.task.time.lines' + _sql_constraints = [ + ( + 'unique_project_stage_task', + 'unique(project_id, stage_id, task_id)', + 'A timeline with the same Project, Stage, and Task already exists.' + ), + ] + + stage_id = fields.Many2one('project.task.type', string="Stage", domain="[('id','in',stage_ids)]", required=True) + stage_sequence = fields.Integer(related="stage_id.sequence") + responsible_lead = fields.Many2one('res.users', string="Responsible Approver") + team_id = fields.Many2one("internal.teams", domain="[('id','in',allowed_team_ids)]") + team_all_member_ids = fields.Many2many('res.users' + # ,related="team_id.all_members_ids" + , 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") + allowed_team_ids = fields.Many2many( + 'internal.teams', + string="Allowed Teams", + compute="_compute_allowed_teams", + store=False + ) + request_date = fields.Date(string="Request Date") + done_date = fields.Date(string="Done Date") + + @api.depends('team_id', 'project_id') + def _compute_team_members(self): + for rec in self: + members = self.env['res.users'] + if rec.team_id: + valid_members = rec.team_id.all_members_ids.filtered(lambda u: u.exists()) + lead = rec.team_id.team_lead if rec.team_id.team_lead.exists() else False + rec.team_all_member_ids = list(set(valid_members.ids + ([lead.id] if lead else []))) + + elif rec.project_id and rec.project_id.privacy_visibility == 'followers': + project_members = rec.project_id.members_ids.filtered(lambda u: u.exists()) + partners = rec.project_id.message_partner_ids.mapped('user_ids').filtered(lambda u: u.exists()) + project_user = rec.project_id.user_id if rec.project_id.user_id.exists() else False + project_lead = rec.project_id.project_lead if rec.project_id.project_lead.exists() else False + + all_ids = ( + project_members.ids + + partners.ids + + ([project_user.id] if project_user else []) + + ([project_lead.id] if project_lead else []) + ) + rec.team_all_member_ids = list(set(all_ids)) + + else: + rec.team_all_member_ids = self.env['res.users'].sudo().search([ + ('active', '=', True), + ('partner_id', '!=', False) + ]).ids + + @api.onchange("team_id") + def onchange_team_id(self): + for rec in self: + if rec.team_id and rec.team_id.team_lead: + rec.assigned_to = rec.team_id.team_lead.id + else: + rec.assigned_to = False + + @api.depends('stage_id') + def _compute_allowed_teams(self): + for rec in self: + allowed_teams = self.env['internal.teams'] + if rec.stage_id and rec.stage_id.team_id: + # Include the main team and its child teams + team = rec.stage_id.team_id + allowed_teams |= team + allowed_teams |= team.child_ids + rec.allowed_team_ids = allowed_teams \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/task_stages.py b/addons_extensions/project_task_timesheet_extended/models/task_stages.py index c8e629713..17b1ec630 100644 --- a/addons_extensions/project_task_timesheet_extended/models/task_stages.py +++ b/addons_extensions/project_task_timesheet_extended/models/task_stages.py @@ -1,7 +1,31 @@ from odoo import api, fields, models, _ +from odoo.exceptions import UserError class TaskStages(models.Model): _inherit = 'project.task.type' team_id = fields.Many2one('internal.teams','Assigned to') - approval_by = fields.Selection([('assigned_team_lead','Assigned Team Lead'),('project_manager','Project Manager'),('project_lead','Project Lead')]) \ No newline at end of file + approval_by = fields.Selection([('assigned_team_lead','Assigned Team Lead'),('project_manager','Project Manager'),('project_lead','Project Lead / Manager')]) + + def create_or_update_data(self): + """Open wizard for updating this stage inside a project context.""" + self.ensure_one() + project_id = self.env.context.get('project_id') or self.env.context.get('active_id') + + if not project_id: + raise UserError(_("No project found in context.")) + + return { + 'type': 'ir.actions.act_window', + 'name': 'Edit Stage', + 'res_model': 'project.stage.update.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_project_id': project_id, + 'default_stage_id': self.id, + 'default_team_id': self.team_id.id if self.team_id else False, + 'default_approval_by': self.approval_by if self.approval_by else False, + 'default_fold': self.fold, + }, + } \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/teams.py b/addons_extensions/project_task_timesheet_extended/models/teams.py index 34b26563e..455a4a9f3 100644 --- a/addons_extensions/project_task_timesheet_extended/models/teams.py +++ b/addons_extensions/project_task_timesheet_extended/models/teams.py @@ -14,13 +14,38 @@ class InternalTeams(models.Model): parent_id = fields.Many2one('internal.teams', string="Parent Team", domain="[('id', '!=', id)]") child_ids = fields.One2many('internal.teams', 'parent_id', string="Child Teams") - active = fields.Boolean(default=True) + complete_name = fields.Char(string='Full Path', compute='_compute_complete_name', recursive=True) # Computed field to include members + child team members and leads all_members_ids = fields.Many2many( 'res.users', compute='_compute_all_members', string="All Members", store=False ) + + @api.depends('team_name', 'parent_id.complete_name') + def _compute_complete_name(self): + for rec in self: + rec.complete_name = rec._get_full_name() + + def _get_full_name(self, level=6): + """ Return the full name of ``self`` (up to a certain level). """ + if level <= 0: + return '...' + if self.parent_id: + return self.parent_id._get_full_name(level - 1) + " / " + (self.team_name or "") + else: + return self.team_name + + def add_internal_team_members(self): + return { + 'name': 'Add Team Members', + 'type': 'ir.actions.act_window', + 'res_model': 'internal.team.members.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': {'default_user_ids': self.members_ids.ids} + } + @api.depends('members_ids', 'child_ids.members_ids', 'child_ids.team_lead') def _compute_all_members(self): for rec in self: 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 7a3a73591..f9f1dcb03 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 @@ -3,10 +3,19 @@ internal_teams_admin,internal.teams.admin,model_internal_teams,project.group_pro internal_teams_manager,internal.teams.manager,model_internal_teams,project.group_project_user,1,1,1,0 internal_teams_user,internal.teams.user,model_internal_teams,base.group_user,1,0,0,0 -project_user_assign_wizard_manager,project.user.assign.wizard,model_project_user_assign_wizard,project.group_project_manager,1,1,1,1 +project_user_assign_wizard_manager,project.user.assign.wizard,model_project_user_assign_wizard,project_task_timesheet_extended.group_project_supervisor,1,1,1,1 +project_user_assign_wizard_admin,project.user.assign.wizard.admin,model_project_user_assign_wizard,project.group_project_manager,1,1,1,1 +project_user_assign_wizard_user,project.user.assign.wizard.user,model_project_user_assign_wizard,project.group_project_manager,1,0,0,0 +project_user_task_reject_reason_wizard,task.reject.reason.wizard.user,model_task_reject_reason_wizard,base.group_user,1,1,1,1 + +project_internal_team_members_wizard,internal.team.members.wizard.manager,model_internal_team_members_wizard,base.group_user,1,1,1,1 +project_project_stage_update_wizard,project.stage.update.wizard.manager,model_project_stage_update_wizard,base.group_user,1,1,1,1 access_project_project_supervisor,project.project,project.model_project_project,project_task_timesheet_extended.group_project_supervisor,1,1,1,0 access_project_project_stage_supervisor,project.project_stage.supervisor,project.model_project_project_stage,project_task_timesheet_extended.group_project_supervisor,1,1,1,0 access_project_task_type_supervisor,project.task.type supervisor,project.model_project_task_type,project_task_timesheet_extended.group_project_supervisor,1,1,1,1 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 diff --git a/addons_extensions/project_task_timesheet_extended/security/security.xml b/addons_extensions/project_task_timesheet_extended/security/security.xml index b469d8299..8c40cb2ae 100644 --- a/addons_extensions/project_task_timesheet_extended/security/security.xml +++ b/addons_extensions/project_task_timesheet_extended/security/security.xml @@ -65,6 +65,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/project_task_timesheet_extended/view/project.xml b/addons_extensions/project_task_timesheet_extended/view/project.xml index 9ff099542..09371be73 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project.xml @@ -18,6 +18,38 @@ + + + +
+ + +
+
+ +
+
+ + + + + + + + + + + + @@ -31,7 +63,19 @@ - + + + + + + + +