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 @@
+
+
+
+