1052 lines
42 KiB
Python
1052 lines
42 KiB
Python
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'
|
|
|
|
sequence_name = fields.Char("Project Number", copy=False, readonly=True)
|
|
task_sequence_id = fields.Many2one(
|
|
'ir.sequence',
|
|
string="Task Sequence",
|
|
readonly=True,
|
|
copy=False,
|
|
help="Sequence for tasks of this project"
|
|
)
|
|
discuss_channel_id = fields.Many2one(
|
|
'discuss.channel',
|
|
string="Channel",
|
|
domain="[('parent_channel_id', '=', default_projects_channel_id)]",
|
|
help="Select a channel for project communications. Channels must be sub-channels of the main Projects Channel."
|
|
)
|
|
default_projects_channel_id = fields.Many2one(
|
|
'discuss.channel',
|
|
default=lambda self: self._get_default_projects_channel(),
|
|
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")
|
|
|
|
show_approval_button_filter = fields.Boolean(
|
|
string="Needs Approval",
|
|
compute="_compute_show_approval_button_filter",
|
|
search="_search_show_approval_button_filter"
|
|
)
|
|
|
|
show_submission_button_filter = fields.Boolean(
|
|
string="Needs To Submit",
|
|
compute="_compute_show_submission_button_filter",
|
|
search="_search_show_submission_button_filter"
|
|
)
|
|
|
|
def _compute_show_submission_button_filter(self):
|
|
for record in self:
|
|
record.show_submission_button_filter = record.show_submission_button
|
|
|
|
def _search_show_submission_button_filter(self, operator, value):
|
|
if operator not in ('=', '!=') or not isinstance(value, bool):
|
|
return []
|
|
|
|
all_records = self.sudo().search([])
|
|
matching_ids = []
|
|
|
|
for record in all_records:
|
|
record._compute_access_check()
|
|
if (record.show_submission_button == value and record.assign_approval_flow and record.begin_approval_processing) if operator == '=' else (record.show_submission_button != value):
|
|
matching_ids.append(record.id)
|
|
|
|
return [('id', 'in', matching_ids)]
|
|
|
|
def _compute_show_approval_button_filter(self):
|
|
"""Simply copy the value for display purposes"""
|
|
for record in self:
|
|
record.show_approval_button_filter = record.show_approval_button
|
|
|
|
def _search_show_approval_button_filter(self, operator, value):
|
|
"""Search implementation"""
|
|
# Same logic as above
|
|
if operator not in ('=', '!=') or not isinstance(value, bool):
|
|
return []
|
|
|
|
all_records = self.sudo().search([])
|
|
matching_ids = []
|
|
|
|
for record in all_records:
|
|
record._compute_access_check()
|
|
if (record.show_approval_button == value and record.assign_approval_flow and record.begin_approval_processing) if operator == '=' else (record.show_approval_button != value):
|
|
matching_ids.append(record.id)
|
|
|
|
return [('id', 'in', matching_ids)]
|
|
|
|
project_activity_log = fields.Html(string="Project Activity Log")
|
|
project_scope = fields.Html(string="Scope", default=lambda self: """
|
|
<h3>Scope Description</h3><br/><br/>
|
|
<p><b>1. In Scope Items?</b></p><br/>
|
|
<p><b>2. Out Scope Items?</b></p><br/>
|
|
""")
|
|
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 planned Amount")
|
|
total_planned_budget_amount = fields.Float(string="Total Estimated planned 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"
|
|
)
|
|
|
|
can_edit_stage_in_approval = fields.Boolean(default=False)
|
|
initial_estimated_resource_cost = fields.Float(string="Estimated Resource Cost", compute='_estimated_cost_planned')
|
|
initial_estimated_material_cost = fields.Float(string="Estimated Material Cost",compute='_estimated_cost_planned')
|
|
initial_estimated_equiipment_cost = fields.Float(string="Estimated Asset Cost",compute='_estimated_cost_planned')
|
|
|
|
@api.depends('resource_cost_ids.total_cost','material_cost_ids.total_cost','equipment_cost_ids.total_cost')
|
|
def _estimated_cost_planned(self):
|
|
for project in self:
|
|
project.initial_estimated_resource_cost = sum(
|
|
project.resource_cost_ids.mapped('total_cost')
|
|
)
|
|
project.initial_estimated_material_cost = sum(
|
|
project.material_cost_ids.mapped('total_cost')
|
|
)
|
|
project.initial_estimated_equiipment_cost = sum(
|
|
project.equipment_cost_ids.mapped('total_cost')
|
|
)
|
|
|
|
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"
|
|
)
|
|
maintenance_support_ids = fields.One2many(
|
|
'project.maintenance.support',
|
|
'project_id',
|
|
string="Maintenance Logs"
|
|
)
|
|
|
|
all_deliverables_submitted = fields.Boolean(string="All Deliverables Submitted")
|
|
final_qa_done = fields.Boolean(string="Final QA Completed")
|
|
client_signoff_closure = fields.Boolean(string="Client Closure Sign-Off")
|
|
billing_completed = fields.Boolean(string="Billing Completed")
|
|
training_completed = fields.Boolean(string="Training Completed")
|
|
|
|
# ------------------------
|
|
# B. Closure Documents
|
|
# ------------------------
|
|
closure_document_ids = fields.One2many(
|
|
"project.closure.document",
|
|
"project_id",
|
|
string="Closure Documents"
|
|
)
|
|
|
|
# ------------------------
|
|
# C. Learning & Review
|
|
# ------------------------
|
|
lessons_learned = fields.Text(string="Lessons Learned")
|
|
challenges_faced = fields.Text(string="Challenges Faced")
|
|
future_recommendations = fields.Text(string="Future Recommendations")
|
|
|
|
project_state = fields.Selection([
|
|
('active', 'Active'),
|
|
('hold', 'On Hold'),
|
|
('cancel', 'Cancelled'),
|
|
], default='active', tracking=True)
|
|
|
|
cancel_reason = fields.Text(string="Cancel Reason", tracking=True)
|
|
hold_reason = fields.Text(string="Hold Reason", tracking=True)
|
|
privacy_visibility = fields.Selection(default="followers")
|
|
|
|
def action_hold_unhold(self):
|
|
for project in self:
|
|
if project.project_state == 'hold':
|
|
project.write({'project_state': 'active'})
|
|
else:
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Hold Project',
|
|
'res_model': 'project.cancel.hold.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_project_id': project.id,
|
|
'default_action_type': 'hold',
|
|
}
|
|
}
|
|
|
|
def action_cancel_project(self):
|
|
for project in self:
|
|
if project.project_state == 'active':
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Cancel Project',
|
|
'res_model': 'project.cancel.hold.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_project_id': project.id,
|
|
'default_action_type': 'cancel',
|
|
}
|
|
}
|
|
|
|
@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_planned_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().can_edit_stage_in_approval = True
|
|
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
|
|
|
|
project.sudo().can_edit_stage_in_approval = False
|
|
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.sudo():
|
|
|
|
project.can_edit_stage_in_approval = True
|
|
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)
|
|
project.can_edit_stage_in_approval = False
|
|
|
|
def reject_and_return(self, reason=None):
|
|
"""Reject project at current stage with optional reason"""
|
|
for project in self.sudo():
|
|
project.can_edit_stage_in_approval = True
|
|
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'
|
|
)
|
|
|
|
project.can_edit_stage_in_approval = False
|
|
|
|
def project_back_button(self):
|
|
"""Revert project to previous stage"""
|
|
for project in self.sudo():
|
|
|
|
project.can_edit_stage_in_approval = True
|
|
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)
|
|
|
|
project.can_edit_stage_in_approval = False
|
|
|
|
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('<br>') + 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'<div>{message_body}</div>'
|
|
|
|
message_parts = ['<div>', message_body]
|
|
for partner in mention_partners:
|
|
if partner and partner.name:
|
|
mention_html = f'<a href="#" data-oe-model="res.partner" data-oe-id="{partner.id}">@{partner.name}</a>'
|
|
message_parts.append(mention_html)
|
|
message_parts.append('</div>')
|
|
return ' '.join(message_parts)
|
|
|
|
def _create_odoo_mention(self, partner):
|
|
"""Create Odoo mention link for a partner"""
|
|
if not partner:
|
|
return ""
|
|
return f'<a href="#" data-oe-model="res.partner" data-oe-id="{partner.id}">@{partner.name}</a>'
|
|
|
|
|
|
@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()
|
|
|
|
if any(field in vals for field in ['stage_id']):
|
|
for project in self:
|
|
if project.assign_approval_flow:
|
|
if not project.can_edit_stage_in_approval:
|
|
raise UserError(_(
|
|
"This project uses Approval Flow.\n"
|
|
"Stage cannot be changed from Kanban view.\n"
|
|
"Please use the action buttons to move through stages."
|
|
))
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
@api.model
|
|
def _get_shared_project_sequence(self):
|
|
"""Get or create a shared sequence for all projects"""
|
|
sequence = self.env['ir.sequence'].sudo().search([('code', '=', 'project.project.sequence')], limit=1)
|
|
if not sequence:
|
|
sequence = self.env['ir.sequence'].sudo().create({
|
|
'name': _("Project Sequence"),
|
|
'implementation': 'no_gap',
|
|
'padding': 3,
|
|
'use_date_range': False,
|
|
'prefix': 'PROJ-',
|
|
'code': 'project.project.sequence',
|
|
})
|
|
return sequence
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
projects = super().create(vals_list)
|
|
sequence = self._get_shared_project_sequence()
|
|
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",
|
|
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=False, tracking=True,
|
|
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())
|
|
|
|
|
|
estimated_hours = fields.Float(string="Estimated Hours")
|
|
task_estimated_hours = fields.Float(string="Task Estimated Hours", compute="_compute_task_estimated_hours", store=True)
|
|
actual_hours = fields.Float(string="Actual Hours", compute="_compute_actual_hours", store=True)
|
|
|
|
|
|
|
|
@api.depends('task_ids.estimated_hours')
|
|
def _compute_task_estimated_hours(self):
|
|
for project in self:
|
|
project.task_estimated_hours = sum(project.task_ids.mapped('estimated_hours'))
|
|
|
|
@api.depends('task_ids.timesheet_ids.unit_amount')
|
|
def _compute_actual_hours(self):
|
|
for project in self:
|
|
project.actual_hours = sum(project.task_ids.timesheet_ids.mapped('unit_amount'))
|
|
|
|
def add_users(self):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Add Users',
|
|
'res_model': 'project.user.assign.wizard',
|
|
'view_mode': 'form',
|
|
'view_id': self.env.ref('project_task_timesheet_extended.project_user_assignment_form_view').id,
|
|
'target': 'new',
|
|
'context': {'default_members_ids':[(6, 0, self.members_ids.ids)],
|
|
},
|
|
}
|
|
|
|
|
|
|
|
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 |