diff --git a/addons_extensions/project_task_timesheet_extended/__manifest__.py b/addons_extensions/project_task_timesheet_extended/__manifest__.py
index bfc57f567..f9fa63cda 100644
--- a/addons_extensions/project_task_timesheet_extended/__manifest__.py
+++ b/addons_extensions/project_task_timesheet_extended/__manifest__.py
@@ -32,13 +32,17 @@ Key Features:
'security/security.xml',
'security/ir.model.access.csv',
'data/data.xml',
+ 'data/project_roles_data.xml',
'wizards/project_user_assign_wizard.xml',
+ 'wizards/roles_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/project_roles_master.xml',
'view/project_stages.xml',
'view/task_stages.xml',
+ 'view/deployment_log.xml',
'view/project.xml',
'view/project_task.xml',
'view/timesheets.xml',
@@ -47,6 +51,9 @@ Key Features:
# 'view/project_task_gantt.xml',
],
'assets': {
+ 'web.assets_backend':{
+ 'project_task_timesheet_extended/static/src/css/delopyment.css'
+ }
},
'installable': True,
'application': False,
diff --git a/addons_extensions/project_task_timesheet_extended/data/project_roles_data.xml b/addons_extensions/project_task_timesheet_extended/data/project_roles_data.xml
new file mode 100644
index 000000000..900ff3485
--- /dev/null
+++ b/addons_extensions/project_task_timesheet_extended/data/project_roles_data.xml
@@ -0,0 +1,111 @@
+
+
+
+
+
+ Admin
+ administrative
+ Full system access
+Create and manage projects
+Configure applications and integrations
+Validate submitted work when required
+Override approvals when necessary
+Manage user access and permissions
+ 10
+
+
+
+ Program Manager
+ administrative
+ Oversee multiple projects
+Review progress and performance
+Coordinate resource distribution
+Support project-level decision-making
+ 11
+
+
+
+
+ Project Manager
+ leadership
+ Lead one or more projects
+Approve development, QA, and deployment stages
+Manage scope, timeline, budget, and deliverables
+Validate final project closure
+Coordinate communication with stakeholders
+ 5
+
+
+
+ Project Lead
+ leadership
+ Manage technical execution of the project
+Validate development output
+Review and merge code
+Coordinate with QA teams
+Support Team Leads in execution
+ 6
+
+
+
+ Team Lead
+ leadership
+ Lead module-level or team-level execution
+Assign tasks to Developers and QA
+Validate development work
+Monitor daily progress
+Ensure delivery quality and adherence to process
+ 7
+
+
+
+ Financial Manager
+ leadership
+ Manage project budgets
+Validate financial feasibility
+Approve expense-related considerations
+ 8
+
+
+
+
+ QA Lead
+ execution
+ Oversees all QA activity
+Coordinates test cycles
+Validates QA results
+Ensures quality standards are met
+ 3
+
+
+
+ QA Engineer / Tester
+ execution
+ Executes test cases
+Logs defects
+Validates fixes
+Supports QA lead in test management
+ 4
+
+
+
+ Developer
+ execution
+ Executes assigned development tasks
+Updates task progress and logs timesheets
+Submits completed work for approval
+ 2
+
+
+
+ IT
+ execution
+ Performs generic tasks
+Provides inputs as required
+ 1
+
+
+
+
+
+
\ No newline at end of file
diff --git a/addons_extensions/project_task_timesheet_extended/models/__init__.py b/addons_extensions/project_task_timesheet_extended/models/__init__.py
index cb910db8e..671e43486 100644
--- a/addons_extensions/project_task_timesheet_extended/models/__init__.py
+++ b/addons_extensions/project_task_timesheet_extended/models/__init__.py
@@ -1,4 +1,5 @@
from . import teams
+from . import project_roles_master
from . import project_sprint
from . import task_documents
from . import project_architecture_design
@@ -8,6 +9,7 @@ from . import project_costings
from . import project_code_commit
from . import project_stages
from . import task_stages
+from . import deployment_log
from . import project
from . import project_task
from . import timesheets
diff --git a/addons_extensions/project_task_timesheet_extended/models/deployment_log.py b/addons_extensions/project_task_timesheet_extended/models/deployment_log.py
new file mode 100644
index 000000000..e666fbe65
--- /dev/null
+++ b/addons_extensions/project_task_timesheet_extended/models/deployment_log.py
@@ -0,0 +1,30 @@
+from odoo import models, fields, api
+
+class DeploymentLog(models.Model):
+ _name = 'project.deployment.log'
+ _description = "Project Deployment Log"
+ _order = 'deployment_date desc'
+
+ project_id = fields.Many2one(
+ 'project.project',
+ string="Project",
+ required=True,
+ ondelete="cascade"
+ )
+
+ deployment_date = fields.Datetime(string="Deployment Date", required=True)
+
+ deployment_ready = fields.Boolean(string="Deployment Ready?")
+ qa_signoff = fields.Boolean(string="QA Signoff")
+ client_signoff = fields.Boolean(string="Client Signoff")
+ backup_completed = fields.Boolean(string="Backup Completed?")
+
+ deployment_version = fields.Char(string="Version")
+ deployed_by = fields.Char(string="Deployed By")
+
+ deployment_notes = fields.Text(string="Notes")
+
+ deployment_files_ids = fields.Many2many(
+ 'ir.attachment',
+ string="Deployment Files"
+ )
diff --git a/addons_extensions/project_task_timesheet_extended/models/project.py b/addons_extensions/project_task_timesheet_extended/models/project.py
index 9d7b1a893..4b6381ad1 100644
--- a/addons_extensions/project_task_timesheet_extended/models/project.py
+++ b/addons_extensions/project_task_timesheet_extended/models/project.py
@@ -1,6 +1,8 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
-
+from markupsafe import Markup
+from datetime import datetime, timedelta
+import pytz
class ProjectProject(models.Model):
_inherit = 'project.project'
@@ -25,6 +27,634 @@ class ProjectProject(models.Model):
string="Default Projects Channel"
)
+
+ project_stages = fields.One2many('project.stages.approval.flow', 'project_id')
+ assign_approval_flow = fields.Boolean(default=False)
+ project_sponsor = fields.Many2one('res.users')
+ show_project_chatter = fields.Boolean(default=False)
+ project_vision = fields.Text(
+ string="Project Vision",
+ help="Concise statement describing the project's ultimate goal and purpose"
+ )
+
+ # Requirement Documentation
+ description = fields.Html("Requirement Description")
+ requirement_file = fields.Binary("Requirement Document")
+ requirement_file_name = fields.Char("Requirement File Name")
+
+ # Feasibility Assessment
+ feasibility_html = fields.Html("Feasibility Assessment")
+ feasibility_file = fields.Binary("Feasibility Document")
+ feasibility_file_name = fields.Char("Feasibility File Name")
+
+ manager_level_edit_access = fields.Boolean(compute="_compute_has_manager_level_edit_access")
+ approval_status = fields.Selection([
+ ('submitted', 'Submitted'),
+ ('reject', 'Rejected')
+ ])
+ 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")
+ project_activity_log = fields.Html(string="Project Activity Log")
+ project_scope = fields.Html(string="Scope", default=lambda self: """
+
Scope Description
+
1. In Scope Items?
+
2. Out Scope Items?
+ """)
+ risk_ids = fields.One2many(
+ "project.risk",
+ "project_id",
+ string="Project Risks"
+ )
+ # stage_id = fields.Many2one(domain="[('id','in',showable_stage_ids or [])]")
+
+ showable_stage_ids = fields.Many2many('project.project.stage',compute='_compute_project_project_stages')
+
+ # fields:
+ estimated_amount = fields.Float(string="Estimated Amount")
+ total_budget_amount = fields.Float(string="Total Budget Amount", compute="_compute_total_budget", store=True)
+
+ # Manpower
+ resource_cost_ids = fields.One2many(
+ "project.resource.cost",
+ "project_id",
+ string="Resource Costs"
+ )
+
+ # Material
+ material_cost_ids = fields.One2many(
+ "project.material.cost",
+ "project_id",
+ string="Material Costs"
+ )
+
+ # Equipment
+ equipment_cost_ids = fields.One2many(
+ "project.equipment.cost",
+ "project_id",
+ string="Equipment Costs"
+ )
+
+ architecture_design_ids = fields.One2many(
+ "project.architecture.design",
+ "project_id",
+ string="Architecture & Design"
+ )
+
+ require_sprint = fields.Boolean(
+ string="Require Sprints?",
+ default=False,
+ help="Enable sprint-based planning for this project."
+ )
+
+ sprint_ids = fields.One2many(
+ "project.sprint",
+ "project_id",
+ string="Project Sprints"
+ )
+
+ commit_step_ids = fields.One2many(
+ 'project.commit.step',
+ 'project_id',
+ string="Commit Steps"
+ )
+
+ development_document_ids = fields.One2many(
+ "task.development.document",
+ "project_id",
+ string="Development Documents"
+ )
+
+ testing_document_ids = fields.One2many(
+ "task.testing.document",
+ "project_id",
+ string="Testing Documents"
+ )
+ development_notes = fields.Html()
+ testing_notes = fields.Html()
+ deployment_log_ids = fields.One2many(
+ 'project.deployment.log',
+ 'project_id',
+ string="Deployment Logs"
+ )
+
+ @api.depends('require_sprint','project_stages','assign_approval_flow')
+ def _compute_project_project_stages(self):
+ for rec in self:
+ project_stages = self.env['project.project.stage'].sudo().search([])
+ if rec.assign_approval_flow:
+ project_stages = rec.project_stages.filtered(lambda x: x.activate).stage_id + self.env.ref("project_task_timesheet_extended.project_project_stage_sprint_planning")
+
+ stage_ids = self.env['project.project.stage'].sudo().search([('id','in',project_stages.ids),('active', '=', True), ('company_id','in',[self.env.company.id,False])]).ids
+
+ if not rec.require_sprint:
+ stage_ids = self.env['project.project.stage'].sudo().search([
+ ('id', 'in', project_stages.ids),
+ ('active', '=', True),('company_id','in',[self.env.company.id,False]), ('id', '!=', self.env.ref(
+ "project_task_timesheet_extended.project_project_stage_sprint_planning").id)
+ ]).ids
+
+ rec.showable_stage_ids = stage_ids
+
+ @api.depends("resource_cost_ids.total_cost", "material_cost_ids.total_cost", "equipment_cost_ids.total_cost")
+ def _compute_total_budget(self):
+ for project in self:
+ project.total_budget_amount = (
+ sum(project.resource_cost_ids.mapped("total_cost"))
+ + sum(project.material_cost_ids.mapped("total_cost"))
+ + sum(project.equipment_cost_ids.mapped("total_cost"))
+ )
+
+ def fetch_project_task_stage_users(self):
+ for project in self:
+ users_list = list()
+ if project.assign_approval_flow:
+ users_list.extend(project.project_stages.involved_users.ids)
+ else:
+ users_list.extend(project.showable_stage_ids.user_ids.ids)
+
+ if project.project_sponsor:
+ users_list.append(project.project_sponsor.id)
+ if project.user_id:
+ users_list.append(project.user_id.id)
+ if project.project_lead:
+ users_list.append(project.project_lead.id)
+ if project.type_ids:
+ for task_stage in project.type_ids:
+ if task_stage.team_id:
+ users_list.append(task_stage.team_id.team_lead.id)
+ if task_stage.involved_user_ids:
+ users_list.extend(task_stage.involved_user_ids.ids)
+ users_list = list(set(users_list))
+ base_users = project.members_ids.ids
+ removed_ids = set(base_users).difference(users_list)
+ newly_added_ids = set(users_list).difference(base_users)
+ project.update({
+ "members_ids": [(6, 0, users_list)],
+ "message_partner_ids": [(4, user_id.partner_id.id) for user_id in
+ self.env['res.users'].sudo().search([('id', 'in', list(newly_added_ids))])],
+ })
+ project.update({
+ "message_partner_ids": [(3, user_id.partner_id.id) for user_id in
+ self.env['res.users'].sudo().search([('id', 'in', list(removed_ids))])],
+ })
+
+
+ # --------------------------------------------------------
+ # Fetch Resource Data Button
+ # --------------------------------------------------------
+ def action_fetch_resource_data(self):
+ """Fetch all members' employee records and create manpower cost lines with full auto-fill."""
+ for project in self:
+
+ # Project users = members + project manager + project lead
+ users = project.members_ids | project.user_id | project.project_lead
+
+ # Fetch employees linked to those users
+ employees = self.env["hr.employee"].search([("user_id", "in", users.ids)])
+
+ for emp in employees:
+
+ # Avoid duplicate manpower lines
+ existing = project.resource_cost_ids.filtered(lambda r: r.employee_id == emp)
+ if existing:
+ continue
+
+ # Get active contract for salary details
+ contract = emp.contract_id
+
+ monthly_salary = contract.wage if contract else 0.0
+ daily_rate = (contract.wage / 30) if contract and contract.wage else 0.0
+
+ # Project Dates
+ start_date = project.date_start or fields.Date.today()
+ end_date = project.date or start_date
+
+ # Duration
+ duration = (end_date - start_date).days + 1 if start_date and end_date else 0
+
+ # Total Cost
+ total_cost = daily_rate * duration if daily_rate else 0
+
+ # Create manpower line
+ self.env["project.resource.cost"].create({
+ "project_id": project.id,
+ "employee_id": emp.id,
+ "monthly_salary": monthly_salary,
+ "daily_rate": daily_rate,
+ "start_date": start_date,
+ "end_date": end_date,
+ "duration_days": duration,
+ "total_cost": total_cost,
+ })
+
+ @api.depends("project_stages", "stage_id", "approval_status")
+ def _compute_access_check(self):
+ """Compute visibility of action buttons based on user permissions and project state"""
+ for project in self:
+ project.show_submission_button = False
+ project.show_approval_button = False
+ project.show_refuse_button = False
+ project.show_back_button = False
+
+ if not project.assign_approval_flow:
+ continue
+
+ user = self.env.user
+ project_manager = project.user_id
+ project_sponsor = project.project_sponsor
+
+ # Current approval timeline for this stage
+ current_approval_timeline = project.project_stages.filtered(
+ lambda s: s.stage_id == project.stage_id
+ )
+
+ # Compute button visibility based on approval flow
+ if current_approval_timeline:
+ line = current_approval_timeline[0]
+ assigned_to = line.assigned_to
+ responsible_lead = line.approval_by
+
+ # Submission button for assigned users
+ if (assigned_to == user and
+ project.approval_status != "submitted" and
+ assigned_to != responsible_lead):
+ project.show_submission_button = True
+
+ # Approval/refusal buttons for responsible leads
+ if (project.approval_status == "submitted" and
+ responsible_lead == user):
+ project.show_approval_button = True
+ project.show_refuse_button = True
+
+ # Direct approval when no assigned user
+ if not assigned_to and responsible_lead == user:
+ project.show_approval_button = True
+
+ # Direct approval when assigned user is also responsible
+ if (assigned_to == responsible_lead and
+ user == assigned_to):
+ project.show_approval_button = True
+ else:
+ # Managers can approve without specific flow
+ if user.has_group("project.group_project_manager"):
+ project.show_approval_button = True
+
+ # Managers get additional permissions
+ if user in [project_manager] or user.has_group("project.group_project_manager"):
+ project.show_back_button = True
+ if user.has_group("project.group_project_manager"):
+ project.show_approval_button = True
+
+ # Stage-specific button visibility
+ if project.stage_id:
+ stages = self.env['project.project.stage'].search([('id','in',project.showable_stage_ids.ids),
+ ('id', '!=', self.env.ref("project.project_project_stage_3").id)
+ ])
+ if stages:
+ first_stage = stages.sorted('sequence')[0]
+ last_stage = stages.sorted('sequence')[-1]
+
+ if project.stage_id == first_stage:
+ project.show_back_button = False
+ if project.stage_id == last_stage:
+ project.show_submission_button = False
+ project.show_approval_button = False
+ project.show_refuse_button = False
+
+ @api.depends("user_id")
+ def _compute_has_manager_level_edit_access(self):
+ """Determine if current user has manager-level edit permissions"""
+ for rec in self:
+ rec.manager_level_edit_access = (
+ rec.user_id == self.env.user or
+ self.env.user.has_group("project.group_project_manager")
+ )
+
+ def action_show_project_chatter(self):
+ """Toggle visibility of project chatter"""
+ for project in self:
+ project.show_project_chatter = not project.show_project_chatter
+
+ def action_assign_approval_flow(self):
+ """Configure approval flow for project stages"""
+ for project in self:
+ if not project.project_sponsor or not project.user_id:
+ raise ValidationError(_("Sponsor and Manager are required to assign Stage Approvals"))
+
+ project.assign_approval_flow = not project.assign_approval_flow
+
+ if project.assign_approval_flow:
+ # Clear existing records
+ project.project_stages.unlink()
+
+ # Fetch all project stages
+ stages = self.env['project.project.stage'].sudo().search([('active', '=', True),
+ ('company_id', 'in', [self.env.company.id, False])])
+
+ for stage in stages:
+ # Determine approval authority based on stage configuration
+ approval_by = (
+ project.user_id.id if stage.sudo().approval_by == 'project_manager' else
+ project.project_sponsor.id if stage.sudo().approval_by == 'project_sponsor' else
+ False
+ )
+ assigned_to = (approval_by if approval_by in stage.user_ids.ids else
+ min(stage.user_ids, key=lambda u: u.id).id if stage.user_ids else False
+ )
+
+ self.env['project.stages.approval.flow'].sudo().create({
+ 'project_id': project.id,
+ 'stage_id': stage.id,
+ 'approval_by': approval_by,
+ 'assigned_to': assigned_to,
+ 'involved_users': [(6, 0, stage.user_ids.ids)],
+ 'assigned_date': fields.Datetime.now() if stage == project.stage_id else False,
+ 'submission_date': False,
+ })
+
+ # Log approval flow assignment
+ self.sudo()._add_activity_log("Approval flow assigned by %s" % self.env.user.name)
+ self.sudo()._post_to_project_channel(
+ _("Approval flow configured for project %s") % project.name
+ )
+ else:
+ project.sudo().project_stages.unlink()
+ self.sudo()._add_activity_log("Approval flow removed by %s" % self.env.user.name)
+ self.sudo()._post_to_project_channel(
+ _("Approval flow removed for project %s") % project.name
+ )
+
+ def submit_project_for_approval(self):
+ """Submit project for current stage approval"""
+ for project in self:
+ project.sudo().approval_status = "submitted"
+ current_stage = project.sudo().stage_id
+ current_approval_timeline = project.sudo().project_stages.filtered(
+ lambda s: s.stage_id == project.sudo().stage_id
+ )
+
+ if current_approval_timeline:
+ current_approval_timeline.sudo().submission_date = fields.Datetime.now()
+
+ stage_line = project.sudo().project_stages.filtered(lambda s: s.stage_id == current_stage)
+ responsible_user = stage_line.sudo().approval_by if stage_line else False
+
+ # Create activity log
+ activity_log = "%s : %s submitted for approval to %s" % (
+ current_stage.sudo().name,
+ self.env.user.name,
+ responsible_user.sudo().name if responsible_user else "N/A"
+ )
+ project.sudo()._add_activity_log(activity_log)
+
+ # Post to project channel
+ if responsible_user:
+ channel_message = _("Project %s submitted for approval at stage %s. %s please review.") % (
+ project.sudo().name,
+ current_stage.sudo().name,
+ project.sudo()._create_odoo_mention(responsible_user.partner_id)
+ )
+ else:
+ channel_message = _("Project %s submitted for approval at stage %s") % (
+ project.sudo().name,
+ current_stage.sudo().name
+ )
+ project.sudo()._post_to_project_channel(channel_message)
+
+ # Send notification
+ if responsible_user:
+ project.sudo().message_post(
+ body=activity_log,
+ partner_ids=[responsible_user.sudo().partner_id.id],
+ message_type='notification',
+ subtype_xmlid='mail.mt_comment'
+ )
+
+ def project_proceed_further(self):
+ """Advance project to next stage after approval"""
+ for project in self:
+ current_stage = project.stage_id
+ next_stage = self.env["project.project.stage"].search([
+ ('sequence', '>', project.stage_id.sequence),
+ ('id', '!=', self.env.ref("project.project_project_stage_3").id),
+ ('id', 'in', project.showable_stage_ids.ids),
+ ], order="sequence asc", limit=1)
+
+ current_approval_timeline = project.project_stages.filtered(
+ lambda s: s.stage_id == project.stage_id
+ )
+
+ if current_approval_timeline:
+ current_approval_timeline.submission_date = fields.Datetime.now()
+ if not current_approval_timeline.assigned_date:
+ current_approval_timeline.assigned_date = fields.Datetime.now()
+
+ if next_stage:
+ next_approval_timeline = project.project_stages.filtered(
+ lambda s: s.stage_id == next_stage
+ )
+ if next_approval_timeline and not next_approval_timeline.assigned_date:
+ next_approval_timeline.assigned_date = fields.Datetime.now()
+
+ project.stage_id = next_stage
+ project.approval_status = ""
+
+ # Create activity log
+ activity_log = "%s approved by %s â moved to %s" % (
+ current_stage.name,
+ self.env.user.name,
+ next_stage.name
+ )
+ project._add_activity_log(activity_log)
+
+ # Post to project channel
+ next_user = next_approval_timeline.assigned_to if next_approval_timeline else False
+ if next_user:
+ channel_message = _("Project %s approved at stage %s and moved to %s. %s please proceed.") % (
+ project.name,
+ current_stage.name,
+ next_stage.name,
+ project._create_odoo_mention(next_user.partner_id)
+ )
+ else:
+ channel_message = _("Project %s approved at stage %s and moved to %s") % (
+ project.name,
+ current_stage.name,
+ next_stage.name
+ )
+ project._post_to_project_channel(channel_message)
+
+ # Send notification
+ if next_user:
+ project.message_post(
+ body=activity_log,
+ partner_ids=[next_user.partner_id.id],
+ message_type='notification',
+ subtype_xmlid='mail.mt_comment'
+ )
+ else:
+ # Last stage completed
+ project.approval_status = ""
+ activity_log = "%s fully approved and completed" % project.name
+ project._add_activity_log(activity_log)
+ project._post_to_project_channel(
+ _("Project %s completed and fully approved") % project.name
+ )
+ project.message_post(body=activity_log)
+
+ def reject_and_return(self, reason=None):
+ """Reject project at current stage with optional reason"""
+ for project in self:
+ reason = reason or ""
+ current_stage = project.stage_id
+ project.approval_status = "reject"
+
+ # Create activity log
+ activity_log = "%s rejected by %s â %s" % (
+ current_stage.name,
+ self.env.user.name,
+ reason
+ )
+ project._add_activity_log(activity_log)
+
+ # Update approval timeline
+ current_approval_timeline = project.project_stages.filtered(
+ lambda s: s.stage_id == project.stage_id
+ )
+ if current_approval_timeline:
+ current_approval_timeline.note = f"Reject Reason: {reason}"
+
+ # Post to project channel
+ channel_message = _("Project %s rejected at stage %s. Reason: %s") % (
+ project.name,
+ current_stage.name,
+ reason
+ )
+ project._post_to_project_channel(channel_message)
+
+ # Send notification
+ project.message_post(body=activity_log)
+
+ # Notify responsible users
+ if current_approval_timeline:
+ responsible_user = (
+ current_approval_timeline.assigned_to or
+ current_approval_timeline.approval_by
+ )
+ if responsible_user:
+ project.message_post(
+ body=_("Project %s has been rejected and returned to you") % project.name,
+ partner_ids=[responsible_user.partner_id.id],
+ message_type='notification',
+ subtype_xmlid='mail.mt_comment'
+ )
+
+ def project_back_button(self):
+ """Revert project to previous stage"""
+ for project in self:
+ prev_stage = self.env["project.project.stage"].search([
+ ('sequence', '<', project.stage_id.sequence),
+ ('id', 'in', project.showable_stage_ids.ids)
+ ], order="sequence desc", limit=1)
+
+ if not prev_stage:
+ raise ValidationError(_("No previous stage available."))
+
+ # Create activity log
+ activity_log = "%s reverted back to %s by %s" % (
+ project.stage_id.name,
+ prev_stage.name,
+ self.env.user.name
+ )
+ project._add_activity_log(activity_log)
+
+ # Post to project channel
+ channel_message = _("Project %s reverted from %s back to %s") % (
+ project.name,
+ project.stage_id.name,
+ prev_stage.name
+ )
+ project._post_to_project_channel(channel_message)
+
+ # Update stage
+ project.stage_id = prev_stage
+ project.message_post(body=activity_log)
+
+ def action_open_reject_wizard(self):
+ """Open rejection wizard for projects"""
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_window",
+ "name": _("Reject Project"),
+ "res_model": "project.reject.reason.wizard",
+ "view_mode": "form",
+ "target": "new",
+ "context": {"default_project_id": self.id},
+ }
+
+ # Activity Log Helper Methods
+ def _get_current_datetime_formatted(self):
+ """Get current datetime in 'DD-MMM-YYYY HH:MM AM/PM' format"""
+ now = fields.Datetime.context_timestamp(self, fields.datetime.now())
+ formatted_date = now.strftime('%d-%b-%Y %I:%M %p').upper()
+ return formatted_date[1:] if formatted_date.startswith('0') else formatted_date
+
+ def _add_activity_log(self, activity_text):
+ """Add formatted entry to project activity log"""
+ formatted_datetime = self._get_current_datetime_formatted()
+ for project in self:
+ log_entry = f"[{formatted_datetime}] {activity_text}"
+ if project.project_activity_log:
+ project.project_activity_log = Markup(project.project_activity_log) + Markup('
') + Markup(log_entry)
+ else:
+ project.project_activity_log = Markup(log_entry)
+
+ def _post_to_project_channel(self, message_body, mention_partners=None):
+ """Post message to project's discuss channel with proper mentions"""
+ for project in self:
+ if not project.id:
+ continue
+
+ # Get project channel
+ channel = (
+ project.discuss_channel_id or
+ project.default_projects_channel_id
+ )
+
+ if channel:
+ formatted_message = self._format_message_with_odoo_mentions(
+ message_body,
+ mention_partners
+ )
+ 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"""
+ if not mention_partners:
+ return f'
{message_body}
'
+
+ message_parts = ['
', message_body]
+ 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 Odoo mention link for a partner"""
+ if not partner:
+ return ""
+ return f'
@{partner.name}'
+
+
@api.model
def _get_default_projects_channel(self):
"""Get or create the default Projects Channel"""
@@ -148,14 +778,16 @@ class ProjectProject(models.Model):
# 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")
+
+ project_lead = fields.Many2one("res.users", string="Project Lead",
+ domain=lambda self: [('id','in',self.env.ref('project_task_timesheet_extended.role_project_lead').user_ids.ids)])
members_ids = fields.Many2many('res.users', 'project_user_rel', 'project_id',
'user_id', 'Project Members', help="""Project's
members are users who can have an access to
the tasks related to this project."""
)
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)],)
+ domain=lambda self: [('id','in',self.env.ref('project_task_timesheet_extended.role_project_manager').user_ids.ids),('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())
@@ -186,3 +818,69 @@ class ProjectProject(models.Model):
},
}
+
+
+class ProjectTask(models.Model):
+ _inherit = 'project.task'
+
+
+ def _default_sprint_id(self):
+ """Return the current active (in-progress) sprint of the project."""
+ if 'project_id' in self._context:
+ project_id = self._context.get('project_id')
+ sprint = self.env['project.sprint'].search([
+ ('project_id', '=', project_id),
+ ('status', '=', 'in_progress')
+ ], limit=1)
+ return sprint.id
+ return False
+
+ sprint_id = fields.Many2one(
+ "project.sprint",
+ string="Sprint",
+ default=_default_sprint_id
+ )
+
+ require_sprint = fields.Boolean(
+ related="project_id.require_sprint",
+ store=False
+ )
+
+ commit_step_ids = fields.One2many(
+ 'project.commit.step',
+ 'task_id',
+ string="Commit Steps"
+ )
+
+ show_task_chatter = fields.Boolean(default=False)
+
+ development_document_ids = fields.One2many(
+ "task.development.document",
+ "task_id",
+ string="Development Documents"
+ )
+
+ testing_document_ids = fields.One2many(
+ "task.testing.document",
+ "task_id",
+ string="Testing Documents"
+ )
+
+ @api.onchange("project_id")
+ def _onchange_project_id_sprint_required(self):
+ for task in self:
+ if task.project_id and not task.project_id.require_sprint:
+ task.sprint_id = False
+ else:
+ if task.project_id and task.project_id.require_sprint:
+ sprint = self.env['project.sprint'].search([
+ ('project_id', '=', task.project_id.id),
+ ('status', '=', 'in_progress')
+ ], limit=1)
+ task.sprint_id = sprint.id
+
+
+ def action_show_project_task_chatter(self):
+ """Toggle visibility of project chatter"""
+ for project in self:
+ project.show_task_chatter = not project.show_task_chatter
\ No newline at end of file
diff --git a/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py b/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py
index 96a4b9b95..875644f03 100644
--- a/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py
+++ b/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py
@@ -25,8 +25,8 @@ class ProjectResourceCost(models.Model):
"""Auto-fill start_date and end_date from project."""
if self.project_id:
# If project has dates â use them
- self.start_date = self.project_id.start_date or date.today()
- self.end_date = self.project_id.end_date or self.start_date
+ self.start_date = self.project_id.date_start or date.today()
+ self.end_date = self.project_id.date or self.start_date
# -------------------------------------------------------------
# 2. If employee selected â load salary
diff --git a/addons_extensions/project_task_timesheet_extended/models/project_roles_master.py b/addons_extensions/project_task_timesheet_extended/models/project_roles_master.py
new file mode 100644
index 000000000..00f6ae093
--- /dev/null
+++ b/addons_extensions/project_task_timesheet_extended/models/project_roles_master.py
@@ -0,0 +1,99 @@
+from odoo import fields, models
+
+
+class ProjectRole(models.Model):
+ _name = 'project.role'
+ _description = 'Project Role Management'
+ _order = 'role_level, name'
+
+ ROLE_LEVELS = [
+ ('administrative', 'Administrative'),
+ ('leadership', 'Project Leadership'),
+ ('execution', 'Execution'),
+ ]
+
+ name = fields.Char(
+ string='Role Name',
+ required=True,
+ help="Name of the project role"
+ )
+ description = fields.Text(
+ string='Description',
+ help="Detailed description of the role responsibilities"
+ )
+ user_ids = fields.Many2many(
+ 'res.users',
+ 'project_role_user_rel',
+ 'role_id',
+ 'user_id',
+ string='Assigned Users',
+ help="Users assigned to this role"
+ )
+ active = fields.Boolean(
+ string='Active',
+ default=True,
+ help="If unchecked, the role will be hidden but not deleted"
+ )
+ color = fields.Integer(
+ string='Color Index',
+ default=0,
+ help="Color index for kanban view"
+ )
+ role_level = fields.Selection(
+ ROLE_LEVELS,
+ string='Authority Level',
+ required=True,
+ help="Structured authority level of the role"
+ )
+
+ def action_update_users(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Add Users',
+ 'res_model': 'roles.user.assign.wizard',
+ 'view_mode': 'form',
+ 'view_id': self.env.ref('project_task_timesheet_extended.roles_user_assignment_form_view').id,
+ 'target': 'new',
+ 'context': {'default_members_ids': [(6, 0, self.user_ids.ids)],
+ },
+ }
+ def action_view_users(self):
+ """Open users assigned to this role"""
+ self.ensure_one()
+ action = {
+ 'name': f'Users in {self.name} Role',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'res.users',
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', self.user_ids.ids)],
+ 'context': {'default_role_id': self.id},
+ }
+ if len(self.user_ids) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': self.user_ids.id,
+ })
+ return action
+
+class ProjectStage(models.Model):
+ _inherit = 'project.project.stage'
+
+ role_ids = fields.Many2many(
+ 'project.role',
+ 'project_stage_role_rel',
+ 'stage_id',
+ 'role_id',
+ string='Default Access By Roles',
+ help="Roles assigned to this project stage"
+ )
+ user_ids = fields.Many2many(
+ 'res.users',
+ compute='_compute_user_ids',
+ string='Related Role Users',
+ help="Users assigned to the roles of this stage"
+ )
+
+ def _compute_user_ids(self):
+ """Compute users from assigned roles"""
+ for stage in self:
+ stage.user_ids = stage.role_ids.mapped('user_ids')
\ No newline at end of file
diff --git a/addons_extensions/project_task_timesheet_extended/models/project_stages.py b/addons_extensions/project_task_timesheet_extended/models/project_stages.py
index 9ec1f4eb9..8545094b2 100644
--- a/addons_extensions/project_task_timesheet_extended/models/project_stages.py
+++ b/addons_extensions/project_task_timesheet_extended/models/project_stages.py
@@ -17,13 +17,17 @@ class projectStagesApprovalFlow(models.Model):
stage_id = fields.Many2one('project.project.stage')
stage_approval_by = fields.Selection(related='stage_id.approval_by')
approval_by = fields.Many2one("res.users", domain="[('id','in',approval_by_users)]")
- assigned_to = fields.Many2one("res.users")
+ assigned_to = fields.Many2one("res.users", domain="[('id','in',related_stage_users)]")
+ related_stage_users = fields.Many2many("res.users",related="stage_id.user_ids")
assigned_date = fields.Datetime()
submission_date = fields.Datetime()
project_id = fields.Many2one('project.project')
approval_by_users = fields.Many2many('res.users', compute="_compute_all_project_managers")
note = fields.Text()
manager_level_edit_access = fields.Boolean(compute="_compute_manager_level_edit_access")
+ activate = fields.Boolean(default=True)
+ involved_users = fields.Many2many('res.users', 'project_stage_approval_user_rel', 'project_stage_approval_id',
+ 'user_id',string="Related Users",domain="[('id','in',related_stage_users)]")
def _compute_manager_level_edit_access(self):
for rec in self:
@@ -50,647 +54,3 @@ class projectStagesApprovalFlow(models.Model):
rec.approval_by_users = [(6, 0, pm_users)]
-
-class ProjectProject(models.Model):
- _inherit = 'project.project'
-
- project_stages = fields.One2many('project.stages.approval.flow', 'project_id')
- assign_approval_flow = fields.Boolean(default=False)
- project_sponsor = fields.Many2one('res.users')
- show_project_chatter = fields.Boolean(default=False)
- project_vision = fields.Text(
- string="Project Vision",
- help="Concise statement describing the project's ultimate goal and purpose"
- )
-
- # Requirement Documentation
- description = fields.Html("Requirement Description")
- requirement_file = fields.Binary("Requirement Document")
- requirement_file_name = fields.Char("Requirement File Name")
-
- # Feasibility Assessment
- feasibility_html = fields.Html("Feasibility Assessment")
- feasibility_file = fields.Binary("Feasibility Document")
- feasibility_file_name = fields.Char("Feasibility File Name")
-
- manager_level_edit_access = fields.Boolean(compute="_compute_has_manager_level_edit_access")
- approval_status = fields.Selection([
- ('submitted', 'Submitted'),
- ('reject', 'Rejected')
- ])
- 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")
- project_activity_log = fields.Html(string="Project Activity Log")
- project_scope = fields.Html(string="Scope", default=lambda self: """
-
Scope Description
-
1. In Scope Items?
-
2. Out Scope Items?
- """)
- risk_ids = fields.One2many(
- "project.risk",
- "project_id",
- string="Project Risks"
- )
- # stage_id = fields.Many2one(domain="[('id','in',showable_stage_ids or [])]")
-
- showable_stage_ids = fields.Many2many('project.project.stage',compute='_compute_project_project_stages')
-
- # fields:
- estimated_amount = fields.Float(string="Estimated Amount")
- total_budget_amount = fields.Float(string="Total Budget Amount", compute="_compute_total_budget", store=True)
-
- # Manpower
- resource_cost_ids = fields.One2many(
- "project.resource.cost",
- "project_id",
- string="Resource Costs"
- )
-
- # Material
- material_cost_ids = fields.One2many(
- "project.material.cost",
- "project_id",
- string="Material Costs"
- )
-
- # Equipment
- equipment_cost_ids = fields.One2many(
- "project.equipment.cost",
- "project_id",
- string="Equipment Costs"
- )
-
- architecture_design_ids = fields.One2many(
- "project.architecture.design",
- "project_id",
- string="Architecture & Design"
- )
-
- require_sprint = fields.Boolean(
- string="Require Sprints?",
- default=False,
- help="Enable sprint-based planning for this project."
- )
-
- sprint_ids = fields.One2many(
- "project.sprint",
- "project_id",
- string="Project Sprints"
- )
-
- commit_step_ids = fields.One2many(
- 'project.commit.step',
- 'project_id',
- string="Commit Steps"
- )
-
- development_document_ids = fields.One2many(
- "task.development.document",
- "project_id",
- string="Development Documents"
- )
-
- testing_document_ids = fields.One2many(
- "task.testing.document",
- "project_id",
- string="Testing Documents"
- )
- development_notes = fields.Html()
- testing_notes = fields.Html()
-
- @api.depends('require_sprint')
- def _compute_project_project_stages(self):
- for rec in self:
- stage_ids = self.env['project.project.stage'].sudo().search([('active', '=', True), ('company_id','in',[self.env.company.id,False])]).ids
-
- if not rec.require_sprint:
- stage_ids = self.env['project.project.stage'].sudo().search([
- ('active', '=', True),('company_id','in',[self.env.company.id,False]), ('id', '!=', self.env.ref(
- "project_task_timesheet_extended.project_project_stage_sprint_planning").id)
- ]).ids
- rec.showable_stage_ids = stage_ids
-
- @api.depends("resource_cost_ids.total_cost", "material_cost_ids.total_cost", "equipment_cost_ids.total_cost")
- def _compute_total_budget(self):
- for project in self:
- project.total_budget_amount = (
- sum(project.resource_cost_ids.mapped("total_cost"))
- + sum(project.material_cost_ids.mapped("total_cost"))
- + sum(project.equipment_cost_ids.mapped("total_cost"))
- )
-
- # --------------------------------------------------------
- # Fetch Resource Data Button
- # --------------------------------------------------------
- def action_fetch_resource_data(self):
- """Fetch all members' employee records and create manpower cost lines with full auto-fill."""
- for project in self:
-
- # Project users = members + project manager + project lead
- users = project.members_ids | project.user_id | project.project_lead
-
- # Fetch employees linked to those users
- employees = self.env["hr.employee"].search([("user_id", "in", users.ids)])
-
- for emp in employees:
-
- # Avoid duplicate manpower lines
- existing = project.resource_cost_ids.filtered(lambda r: r.employee_id == emp)
- if existing:
- continue
-
- # Get active contract for salary details
- contract = emp.contract_id
-
- monthly_salary = contract.wage if contract else 0.0
- daily_rate = (contract.wage / 30) if contract and contract.wage else 0.0
-
- # Project Dates
- start_date = project.date_start or fields.Date.today()
- end_date = project.date or start_date
-
- # Duration
- duration = (end_date - start_date).days + 1 if start_date and end_date else 0
-
- # Total Cost
- total_cost = daily_rate * duration if daily_rate else 0
-
- # Create manpower line
- self.env["project.resource.cost"].create({
- "project_id": project.id,
- "employee_id": emp.id,
- "monthly_salary": monthly_salary,
- "daily_rate": daily_rate,
- "start_date": start_date,
- "end_date": end_date,
- "duration_days": duration,
- "total_cost": total_cost,
- })
-
- @api.depends("project_stages", "stage_id", "approval_status")
- def _compute_access_check(self):
- """Compute visibility of action buttons based on user permissions and project state"""
- for project in self:
- project.show_submission_button = False
- project.show_approval_button = False
- project.show_refuse_button = False
- project.show_back_button = False
-
- if not project.assign_approval_flow:
- continue
-
- user = self.env.user
- project_manager = project.user_id
- project_sponsor = project.project_sponsor
-
- # Current approval timeline for this stage
- current_approval_timeline = project.project_stages.filtered(
- lambda s: s.stage_id == project.stage_id
- )
-
- # Compute button visibility based on approval flow
- if current_approval_timeline:
- line = current_approval_timeline[0]
- assigned_to = line.assigned_to
- responsible_lead = line.approval_by
-
- # Submission button for assigned users
- if (assigned_to == user and
- project.approval_status != "submitted" and
- assigned_to != responsible_lead):
- project.show_submission_button = True
-
- # Approval/refusal buttons for responsible leads
- if (project.approval_status == "submitted" and
- responsible_lead == user):
- project.show_approval_button = True
- project.show_refuse_button = True
-
- # Direct approval when no assigned user
- if not assigned_to and responsible_lead == user:
- project.show_approval_button = True
-
- # Direct approval when assigned user is also responsible
- if (assigned_to == responsible_lead and
- user == assigned_to):
- project.show_approval_button = True
- else:
- # Managers can approve without specific flow
- if user.has_group("project.group_project_manager"):
- project.show_approval_button = True
-
- # Managers get additional permissions
- if user in [project_manager] or user.has_group("project.group_project_manager"):
- project.show_back_button = True
- if user.has_group("project.group_project_manager"):
- project.show_approval_button = True
-
- # Stage-specific button visibility
- if project.stage_id:
- stages = self.env['project.project.stage'].search([('id','in',project.showable_stage_ids.ids),
- ('id', '!=', self.env.ref("project.project_project_stage_3").id)
- ])
- if stages:
- first_stage = stages.sorted('sequence')[0]
- last_stage = stages.sorted('sequence')[-1]
-
- if project.stage_id == first_stage:
- project.show_back_button = False
- if project.stage_id == last_stage:
- project.show_submission_button = False
- project.show_approval_button = False
- project.show_refuse_button = False
-
- @api.depends("user_id")
- def _compute_has_manager_level_edit_access(self):
- """Determine if current user has manager-level edit permissions"""
- for rec in self:
- rec.manager_level_edit_access = (
- rec.user_id == self.env.user or
- self.env.user.has_group("project.group_project_manager")
- )
-
- def action_show_project_chatter(self):
- """Toggle visibility of project chatter"""
- for project in self:
- project.show_project_chatter = not project.show_project_chatter
-
- def action_assign_approval_flow(self):
- """Configure approval flow for project stages"""
- for project in self:
- if not project.project_sponsor or not project.user_id:
- raise ValidationError(_("Sponsor and Manager are required to assign Stage Approvals"))
-
- project.assign_approval_flow = not project.assign_approval_flow
-
- if project.assign_approval_flow:
- # Clear existing records
- project.project_stages.unlink()
-
- # Fetch all project stages
- stages = self.env['project.project.stage'].sudo().search([('id','in',project.showable_stage_ids.ids)])
-
- for stage in stages:
- # Determine approval authority based on stage configuration
- approval_by = (
- project.user_id.id if stage.sudo().approval_by == 'project_manager' else
- project.project_sponsor.id if stage.sudo().approval_by == 'project_sponsor' else
- False
- )
-
- self.env['project.stages.approval.flow'].sudo().create({
- 'project_id': project.id,
- 'stage_id': stage.id,
- 'approval_by': approval_by,
- 'assigned_to': approval_by,
- 'assigned_date': fields.Datetime.now() if stage == project.stage_id else False,
- 'submission_date': False,
- })
-
- # Log approval flow assignment
- self.sudo()._add_activity_log("Approval flow assigned by %s" % self.env.user.name)
- self.sudo()._post_to_project_channel(
- _("Approval flow configured for project %s") % project.name
- )
- else:
- project.sudo().project_stages.unlink()
- self.sudo()._add_activity_log("Approval flow removed by %s" % self.env.user.name)
- self.sudo()._post_to_project_channel(
- _("Approval flow removed for project %s") % project.name
- )
-
- def submit_project_for_approval(self):
- """Submit project for current stage approval"""
- for project in self:
- project.sudo().approval_status = "submitted"
- current_stage = project.sudo().stage_id
- current_approval_timeline = project.sudo().project_stages.filtered(
- lambda s: s.stage_id == project.sudo().stage_id
- )
-
- if current_approval_timeline:
- current_approval_timeline.sudo().submission_date = fields.Datetime.now()
-
- stage_line = project.sudo().project_stages.filtered(lambda s: s.stage_id == current_stage)
- responsible_user = stage_line.sudo().approval_by if stage_line else False
-
- # Create activity log
- activity_log = "%s : %s submitted for approval to %s" % (
- current_stage.sudo().name,
- self.env.user.name,
- responsible_user.sudo().name if responsible_user else "N/A"
- )
- project.sudo()._add_activity_log(activity_log)
-
- # Post to project channel
- if responsible_user:
- channel_message = _("Project %s submitted for approval at stage %s. %s please review.") % (
- project.sudo().name,
- current_stage.sudo().name,
- project.sudo()._create_odoo_mention(responsible_user.partner_id)
- )
- else:
- channel_message = _("Project %s submitted for approval at stage %s") % (
- project.sudo().name,
- current_stage.sudo().name
- )
- project.sudo()._post_to_project_channel(channel_message)
-
- # Send notification
- if responsible_user:
- project.sudo().message_post(
- body=activity_log,
- partner_ids=[responsible_user.sudo().partner_id.id],
- message_type='notification',
- subtype_xmlid='mail.mt_comment'
- )
-
- def project_proceed_further(self):
- """Advance project to next stage after approval"""
- for project in self:
- current_stage = project.stage_id
- next_stage = self.env["project.project.stage"].search([
- ('sequence', '>', project.stage_id.sequence),
- ('id', '!=', self.env.ref("project.project_project_stage_3").id),
- ('id', 'in', project.showable_stage_ids.ids),
- ], order="sequence asc", limit=1)
-
- current_approval_timeline = project.project_stages.filtered(
- lambda s: s.stage_id == project.stage_id
- )
-
- if current_approval_timeline:
- current_approval_timeline.submission_date = fields.Datetime.now()
- if not current_approval_timeline.assigned_date:
- current_approval_timeline.assigned_date = fields.Datetime.now()
-
- if next_stage:
- next_approval_timeline = project.project_stages.filtered(
- lambda s: s.stage_id == next_stage
- )
- if next_approval_timeline and not next_approval_timeline.assigned_date:
- next_approval_timeline.assigned_date = fields.Datetime.now()
-
- project.stage_id = next_stage
- project.approval_status = ""
-
- # Create activity log
- activity_log = "%s approved by %s â moved to %s" % (
- current_stage.name,
- self.env.user.name,
- next_stage.name
- )
- project._add_activity_log(activity_log)
-
- # Post to project channel
- next_user = next_approval_timeline.assigned_to if next_approval_timeline else False
- if next_user:
- channel_message = _("Project %s approved at stage %s and moved to %s. %s please proceed.") % (
- project.name,
- current_stage.name,
- next_stage.name,
- project._create_odoo_mention(next_user.partner_id)
- )
- else:
- channel_message = _("Project %s approved at stage %s and moved to %s") % (
- project.name,
- current_stage.name,
- next_stage.name
- )
- project._post_to_project_channel(channel_message)
-
- # Send notification
- if next_user:
- project.message_post(
- body=activity_log,
- partner_ids=[next_user.partner_id.id],
- message_type='notification',
- subtype_xmlid='mail.mt_comment'
- )
- else:
- # Last stage completed
- project.approval_status = ""
- activity_log = "%s fully approved and completed" % project.name
- project._add_activity_log(activity_log)
- project._post_to_project_channel(
- _("Project %s completed and fully approved") % project.name
- )
- project.message_post(body=activity_log)
-
- def reject_and_return(self, reason=None):
- """Reject project at current stage with optional reason"""
- for project in self:
- reason = reason or ""
- current_stage = project.stage_id
- project.approval_status = "reject"
-
- # Create activity log
- activity_log = "%s rejected by %s â %s" % (
- current_stage.name,
- self.env.user.name,
- reason
- )
- project._add_activity_log(activity_log)
-
- # Update approval timeline
- current_approval_timeline = project.project_stages.filtered(
- lambda s: s.stage_id == project.stage_id
- )
- if current_approval_timeline:
- current_approval_timeline.note = f"Reject Reason: {reason}"
-
- # Post to project channel
- channel_message = _("Project %s rejected at stage %s. Reason: %s") % (
- project.name,
- current_stage.name,
- reason
- )
- project._post_to_project_channel(channel_message)
-
- # Send notification
- project.message_post(body=activity_log)
-
- # Notify responsible users
- if current_approval_timeline:
- responsible_user = (
- current_approval_timeline.assigned_to or
- current_approval_timeline.approval_by
- )
- if responsible_user:
- project.message_post(
- body=_("Project %s has been rejected and returned to you") % project.name,
- partner_ids=[responsible_user.partner_id.id],
- message_type='notification',
- subtype_xmlid='mail.mt_comment'
- )
-
- def project_back_button(self):
- """Revert project to previous stage"""
- for project in self:
- prev_stage = self.env["project.project.stage"].search([
- ('sequence', '<', project.stage_id.sequence),
- ('id', 'in', project.showable_stage_ids.ids)
- ], order="sequence desc", limit=1)
-
- if not prev_stage:
- raise ValidationError(_("No previous stage available."))
-
- # Create activity log
- activity_log = "%s reverted back to %s by %s" % (
- project.stage_id.name,
- prev_stage.name,
- self.env.user.name
- )
- project._add_activity_log(activity_log)
-
- # Post to project channel
- channel_message = _("Project %s reverted from %s back to %s") % (
- project.name,
- project.stage_id.name,
- prev_stage.name
- )
- project._post_to_project_channel(channel_message)
-
- # Update stage
- project.stage_id = prev_stage
- project.message_post(body=activity_log)
-
- def action_open_reject_wizard(self):
- """Open rejection wizard for projects"""
- self.ensure_one()
- return {
- "type": "ir.actions.act_window",
- "name": _("Reject Project"),
- "res_model": "project.reject.reason.wizard",
- "view_mode": "form",
- "target": "new",
- "context": {"default_project_id": self.id},
- }
-
- # Activity Log Helper Methods
- def _get_current_datetime_formatted(self):
- """Get current datetime in 'DD-MMM-YYYY HH:MM AM/PM' format"""
- now = fields.Datetime.context_timestamp(self, fields.datetime.now())
- formatted_date = now.strftime('%d-%b-%Y %I:%M %p').upper()
- return formatted_date[1:] if formatted_date.startswith('0') else formatted_date
-
- def _add_activity_log(self, activity_text):
- """Add formatted entry to project activity log"""
- formatted_datetime = self._get_current_datetime_formatted()
- for project in self:
- log_entry = f"[{formatted_datetime}] {activity_text}"
- if project.project_activity_log:
- project.project_activity_log = Markup(project.project_activity_log) + Markup('
') + Markup(log_entry)
- else:
- project.project_activity_log = Markup(log_entry)
-
- def _post_to_project_channel(self, message_body, mention_partners=None):
- """Post message to project's discuss channel with proper mentions"""
- for project in self:
- if not project.id:
- continue
-
- # Get project channel
- channel = (
- project.discuss_channel_id or
- project.default_projects_channel_id
- )
-
- if channel:
- formatted_message = self._format_message_with_odoo_mentions(
- message_body,
- mention_partners
- )
- 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"""
- if not mention_partners:
- return f'
{message_body}
'
-
- message_parts = ['
', message_body]
- 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 Odoo mention link for a partner"""
- if not partner:
- return ""
- return f'
@{partner.name}'
-
-
-class ProjectTask(models.Model):
- _inherit = 'project.task'
-
-
- def _default_sprint_id(self):
- """Return the current active (in-progress) sprint of the project."""
- if 'project_id' in self._context:
- project_id = self._context.get('project_id')
- sprint = self.env['project.sprint'].search([
- ('project_id', '=', project_id),
- ('status', '=', 'in_progress')
- ], limit=1)
- return sprint.id
- return False
-
- sprint_id = fields.Many2one(
- "project.sprint",
- string="Sprint",
- default=_default_sprint_id
- )
-
- require_sprint = fields.Boolean(
- related="project_id.require_sprint",
- store=False
- )
-
- commit_step_ids = fields.One2many(
- 'project.commit.step',
- 'task_id',
- string="Commit Steps"
- )
-
- show_task_chatter = fields.Boolean(default=False)
-
- development_document_ids = fields.One2many(
- "task.development.document",
- "task_id",
- string="Development Documents"
- )
-
- testing_document_ids = fields.One2many(
- "task.testing.document",
- "task_id",
- string="Testing Documents"
- )
-
- @api.onchange("project_id")
- def _onchange_project_id_sprint_required(self):
- for task in self:
- if task.project_id and not task.project_id.require_sprint:
- task.sprint_id = False
- else:
- if task.project_id and task.project_id.require_sprint:
- sprint = self.env['project.sprint'].search([
- ('project_id', '=', task.project_id.id),
- ('status', '=', 'in_progress')
- ], limit=1)
- task.sprint_id = sprint.id
-
-
- def action_show_project_task_chatter(self):
- """Toggle visibility of project chatter"""
- for project in self:
- project.show_task_chatter = not project.show_task_chatter
\ 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 17b1ec630..c95f2f6f5 100644
--- a/addons_extensions/project_task_timesheet_extended/models/task_stages.py
+++ b/addons_extensions/project_task_timesheet_extended/models/task_stages.py
@@ -6,6 +6,13 @@ class TaskStages(models.Model):
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 / Manager')])
+ involved_user_ids = fields.Many2many('res.users')
+
+ @api.onchange('team_id')
+ def onchange_team_id(self):
+ for rec in self:
+ if rec.team_id and rec.team_id.all_members_ids:
+ rec.involved_user_ids = [(6,0,rec.team_id.all_members_ids.ids)]
def create_or_update_data(self):
"""Open wizard for updating this stage inside a project context."""
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 4f6a086a0..5b618eb20 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,6 +3,9 @@ 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
+access_project_role_user,project.role.user,model_project_role,base.group_user,1,0,0,0
+access_project_role_manager,project.role.manager,model_project_role,project.group_project_manager,1,1,1,1
+
access_project_sprint_user,access.project.sprint.user,model_project_sprint,project.group_project_user,1,1,1,1
access_project_sprint_manager,access.project.sprint.manager,model_project_sprint,project.group_project_manager,1,1,1,1
@@ -30,7 +33,10 @@ access_task_testing_document,access_task_testing_document,model_task_testing_doc
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_assign_wizard_user,project.user.assign.wizard.user,model_project_user_assign_wizard,base.group_user,1,0,0,0
+
+roles_user_assign_wizard_user,roles.user.assign.wizard.user,model_roles_user_assign_wizard,base.group_user,1,1,1,1
+
project_user_project_reject_reason_wizard,project.reject.reason.wizard.user,model_project_reject_reason_wizard,base.group_user,1,1,1,1
project_user_task_reject_reason_wizard,task.reject.reason.wizard.user,model_task_reject_reason_wizard,base.group_user,1,1,1,1
@@ -50,4 +56,6 @@ access_project_tags_supervisor,project.project_tags_supervisor,project.model_pro
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
-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
+access_user_task_availability,user.task.availability.access,model_user_task_availability,base.group_user,1,0,0,0
+
+access_project_deployment_log_user,access.project.deployment.log.user,model_project_deployment_log,base.group_user,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 a438c0acd..1b1cdbb2a 100644
--- a/addons_extensions/project_task_timesheet_extended/security/security.xml
+++ b/addons_extensions/project_task_timesheet_extended/security/security.xml
@@ -6,6 +6,12 @@
+
+ Project Lead
+
+
+
+
diff --git a/addons_extensions/project_task_timesheet_extended/static/src/css/delopyment.css b/addons_extensions/project_task_timesheet_extended/static/src/css/delopyment.css
new file mode 100644
index 000000000..da2e9ae91
--- /dev/null
+++ b/addons_extensions/project_task_timesheet_extended/static/src/css/delopyment.css
@@ -0,0 +1,52 @@
+.deployment_card {
+ border-radius: 12px;
+ padding: 15px;
+ background: white;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+ margin-bottom: 12px;
+}
+
+.deployment_header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.deployment_version {
+ font-size: 18px;
+ font-weight: bold;
+ color: #4a4a4a;
+}
+
+.deployment_date {
+ font-size: 13px;
+ color: #888;
+}
+
+.deployment_status_container {
+ border-top: 1px solid #eee;
+ padding-top: 10px;
+ margin-top: 10px;
+}
+
+.deployment_status {
+ display: flex;
+ justify-content: space-between;
+ margin: 3px 0;
+ font-size: 14px;
+}
+
+.deployment_notes {
+ margin-top: 12px;
+ background: #f8f8ff;
+ padding: 8px;
+ border-radius: 6px;
+ font-size: 13px;
+ color: #333;
+ white-space: normal;
+}
+
+.badge {
+ font-weight: bold;
+ color: #2d8f2d;
+}
diff --git a/addons_extensions/project_task_timesheet_extended/view/deployment_log.xml b/addons_extensions/project_task_timesheet_extended/view/deployment_log.xml
new file mode 100644
index 000000000..1519d770a
--- /dev/null
+++ b/addons_extensions/project_task_timesheet_extended/view/deployment_log.xml
@@ -0,0 +1,60 @@
+
+
+
+
+ project.deployment.log.kanban
+ project.deployment.log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Deployment Ready:
+
+
+ - QA Signoff:
+
+
+ - Client Signoff:
+
+
+ - Backup:
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons_extensions/project_task_timesheet_extended/view/project.xml b/addons_extensions/project_task_timesheet_extended/view/project.xml
index 38ddf6185..823f6eab8 100644
--- a/addons_extensions/project_task_timesheet_extended/view/project.xml
+++ b/addons_extensions/project_task_timesheet_extended/view/project.xml
@@ -1,6 +1,6 @@
-
+
project.project.inherit.form.view
project.project
@@ -31,7 +31,7 @@
string="Create Project Channel"
type="object"
class="btn-primary"
- invisible = "discuss_channel_id"/>
+ invisible="discuss_channel_id"/>
@@ -39,29 +39,60 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
\ No newline at end of file
diff --git a/addons_extensions/project_task_timesheet_extended/view/project_roles_master.xml b/addons_extensions/project_task_timesheet_extended/view/project_roles_master.xml
new file mode 100644
index 000000000..fba7ed26a
--- /dev/null
+++ b/addons_extensions/project_task_timesheet_extended/view/project_roles_master.xml
@@ -0,0 +1,208 @@
+
+
\ No newline at end of file
diff --git a/addons_extensions/project_task_timesheet_extended/view/project_stages.xml b/addons_extensions/project_task_timesheet_extended/view/project_stages.xml
index c283b2c1a..fa8066efd 100644
--- a/addons_extensions/project_task_timesheet_extended/view/project_stages.xml
+++ b/addons_extensions/project_task_timesheet_extended/view/project_stages.xml
@@ -10,531 +10,4 @@
-
-
-
\ No newline at end of file
diff --git a/addons_extensions/project_task_timesheet_extended/view/task_stages.xml b/addons_extensions/project_task_timesheet_extended/view/task_stages.xml
index 04e8743dc..7af6dd6e7 100644
--- a/addons_extensions/project_task_timesheet_extended/view/task_stages.xml
+++ b/addons_extensions/project_task_timesheet_extended/view/task_stages.xml
@@ -8,6 +8,7 @@
diff --git a/addons_extensions/project_task_timesheet_extended/wizards/__init__.py b/addons_extensions/project_task_timesheet_extended/wizards/__init__.py
index 0a1c411ad..3019c3d49 100644
--- a/addons_extensions/project_task_timesheet_extended/wizards/__init__.py
+++ b/addons_extensions/project_task_timesheet_extended/wizards/__init__.py
@@ -1,4 +1,5 @@
from . import project_user_assign_wizard
+from . import roles_user_assign_wizard
from . import internal_team_members_wizard
from . import project_stage_update_wizard
from . import task_reject_reason_wizard
\ No newline at end of file
diff --git a/addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.py b/addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.py
index 9a4c57b28..69347adb0 100644
--- a/addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.py
+++ b/addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.py
@@ -15,8 +15,19 @@ class ProjectStageUpdateWizard(models.TransientModel):
('project_lead', 'Project Lead / Manager')
], readonly=False)
fold = fields.Boolean(string='Folded in Kanban', readonly=False)
+ involved_user_ids = fields.Many2many('res.users', domain="[('id','in',related_user_ids)]")
+ related_user_ids = fields.Many2many(related="team_id.all_members_ids")
+
+ @api.onchange('team_id')
+ def onchange_team_id(self):
+ for rec in self:
+ if rec.team_id and rec.team_id.all_members_ids:
+ rec.involved_user_ids = [(6,0,rec.team_id.all_members_ids.ids)]
+ else:
+ rec.involved_user_ids = [(5, 0)]
+
def action_save_changes(self):
"""Create/update the stage and sync tasks and project links."""
self.ensure_one()
@@ -29,6 +40,7 @@ class ProjectStageUpdateWizard(models.TransientModel):
('team_id', '=', self.team_id.id),
('approval_by', '=', self.approval_by),
('fold', '=', self.fold),
+ ('involved_user_ids','=',self.involved_user_ids.ids)
], limit=1)
if existing_stage:
@@ -41,6 +53,7 @@ class ProjectStageUpdateWizard(models.TransientModel):
'approval_by': self.approval_by ,
'fold': self.fold,
'sequence': old_stage.sequence, # optional: keep same order
+ 'involved_user_ids': [(6,0,self.involved_user_ids.ids)]
})
# If new_stage is different from old_stage â update references
diff --git a/addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.xml b/addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.xml
index fba7759a9..b4ae02c65 100644
--- a/addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.xml
+++ b/addons_extensions/project_task_timesheet_extended/wizards/project_stage_update_wizard.xml
@@ -9,8 +9,10 @@