enhancements of PMT

This commit is contained in:
karuna 2026-06-04 11:06:43 +05:30
parent 582225e11e
commit 05bdddc472
7 changed files with 196 additions and 80 deletions

View File

@ -12,4 +12,4 @@
],
},
"installable": True,
}
}

View File

@ -1025,6 +1025,23 @@ class projectTask(models.Model):
if stage and stage.mail_template_id:
stage.mail_template_id.sudo().send_mail(self.id, force_send=True)
def _notify_reassigned_users(self, users, note):
self.ensure_one()
partners = users.mapped('partner_id')
if not partners:
return
task_name = self.display_name or self.name
body = Markup("<p>%s</p><p>%s</p>") % (
_("You have been re-assigned to task %s.") % task_name,
note,
)
self.with_context(mail_notify_force_send=True).message_notify(
partner_ids=partners.ids,
subject=_("Task Re-assigned: %s") % task_name,
body=body,
email_layout_xmlid="mail.mail_notification_light",
)
def _apply_task_stage_users(self, stage, users, reason=False, reassignment=False):
self.ensure_one()
if not stage:
@ -1051,16 +1068,19 @@ class projectTask(models.Model):
self._post_to_project_channel(note, users.mapped('partner_id'))
self._send_stage_mail_template(stage)
if reassignment:
self._notify_reassigned_users(users, note)
self._append_reassignment_history(stage, users, reason)
def _apply_task_stage_user_lines(self, lines, reason=False, reassignment=False, update_assignees=True):
self.ensure_one()
self._ensure_task_timeline_rows()
selected_users = self.env['res.users']
replaced_users = self.env['res.users']
notes = []
for wizard_line in lines:
stage = wizard_line.stage_id
users = wizard_line.user_ids
user = getattr(wizard_line, 'user_id', False)
users = user or getattr(wizard_line, 'user_ids', self.env['res.users'])
responsible_user = getattr(wizard_line, 'responsible_user_id', False)
if not stage or not users:
continue
@ -1068,22 +1088,33 @@ class projectTask(models.Model):
timeline = self.assignees_timelines.filtered(lambda t: t.stage_id == stage)[:1]
primary_user = users[0]
if timeline:
if reassignment and timeline.assigned_to:
replaced_users |= timeline.assigned_to
timeline.sudo().write({
'assigned_to': primary_user.id,
'responsible_lead': responsible_user.id if responsible_user else timeline.responsible_lead.id or primary_user.id,
'team_id': stage.team_id.id if stage.team_id else timeline.team_id.id,
})
notes.append(_("%s: %s") % (stage.name, ", ".join(users.mapped('name'))))
line_reason = getattr(wizard_line, 'reason', False) or reason
note = _("%s: %s") % (stage.name, ", ".join(users.mapped('name')))
if line_reason:
note += _(". Reason: %s") % line_reason
notes.append(note)
self._send_stage_mail_template(stage)
if reassignment:
self._append_reassignment_history(stage, users, reason)
self._append_reassignment_history(stage, users, line_reason)
if not selected_users:
raise UserError(_("Please select at least one user."))
if update_assignees:
self.sudo().write({'user_ids': [(6, 0, selected_users.ids)]})
self.sudo().write({'involved_user_ids': [(6, 0, selected_users.ids)]})
if reassignment:
assignees = (self.user_ids - replaced_users) | selected_users
self.sudo().write({'user_ids': [(6, 0, assignees.ids)]})
self._sync_involved_assignees_from_timelines()
else:
if update_assignees:
self.sudo().write({'user_ids': [(6, 0, selected_users.ids)]})
self.sudo().write({'involved_user_ids': [(6, 0, selected_users.ids)]})
action = _("Re-assigned") if reassignment else _("Timeline users selected")
note = _("%s: %s") % (action, "; ".join(notes))
if reason:
@ -1096,6 +1127,8 @@ class projectTask(models.Model):
subtype_xmlid='mail.mt_comment',
)
self._post_to_project_channel(note, selected_users.mapped('partner_id'))
if reassignment:
self._notify_reassigned_users(selected_users, note)
def _move_to_selected_stage(self, stage):
self.ensure_one()

View File

@ -38,6 +38,28 @@
<value eval="{'noupdate': True}"/>
</function>
<function name="write" model="ir.model.data">
<function name="search" model="ir.model.data">
<value eval="[('module', '=', 'project'), ('name', '=', 'project_project_manager_rule')]"/>
</function>
<value eval="{'noupdate': False}"/>
</function>
<record id="project.project_project_manager_rule" model="ir.rule">
<field name="domain_force">[(1, '=', 1)]</field>
<field name="perm_read" eval="1"/>
<field name="perm_write" eval="1"/>
<field name="perm_create" eval="1"/>
<field name="perm_unlink" eval="1"/>
</record>
<function name="write" model="ir.model.data">
<function name="search" model="ir.model.data">
<value eval="[('module', '=', 'project'), ('name', '=', 'project_project_manager_rule')]"/>
</function>
<value eval="{'noupdate': True}"/>
</function>
<record id="portfolio_rule_company_projects" model="ir.rule">
<field name="name">company: Own Company</field>
<field name="model_id" ref="model_project_portfolio"/>
@ -48,10 +70,10 @@
<field name="name">Manager: Own Projects</field>
<field name="model_id" ref="project.model_project_project"/>
<field name="groups" eval="[(4, ref('project_task_timesheet_extended.group_project_supervisor'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="domain_force">['|', ('user_id', '=', user.id), ('user_id', '=', False)]</field>
<field name="perm_read" eval="1"/>
<field name="perm_write" eval="1"/>
<field name="perm_create" eval="1"/>
<field name="perm_create" eval="0"/>
<field name="perm_unlink" eval="0"/>
</record>
@ -95,11 +117,33 @@
<field name="groups" eval="[(4,ref('base.group_user')),(4,ref('project.group_project_user'))]"/>
<field name="perm_read" eval="1"/>
<field name="perm_write" eval="1"/>
<field name="perm_create" eval="1"/>
<field name="perm_create" eval="0"/>
<field name="perm_unlink" eval="0"/>
</record>
<function name="write" model="ir.model.data">
<function name="search" model="ir.model.data">
<value eval="[('module', '=', 'project'), ('name', '=', 'project_manager_all_project_tasks_rule')]"/>
</function>
<value eval="{'noupdate': False}"/>
</function>
<record id="project.project_manager_all_project_tasks_rule" model="ir.rule">
<field name="domain_force">[(1, '=', 1)]</field>
<field name="perm_read" eval="1"/>
<field name="perm_write" eval="1"/>
<field name="perm_create" eval="1"/>
<field name="perm_unlink" eval="1"/>
</record>
<function name="write" model="ir.model.data">
<function name="search" model="ir.model.data">
<value eval="[('module', '=', 'project'), ('name', '=', 'project_manager_all_project_tasks_rule')]"/>
</function>
<value eval="{'noupdate': True}"/>
</function>
<record model="ir.rule" id="project.ir_rule_private_task">
<field name="domain_force">[
'&amp;',

View File

@ -62,22 +62,9 @@
max-width: calc(100% - 18px);
}
.o_task_reassign_arrow {
padding: 0;
margin-left: 2px;
min-width: 12px;
width: 12px;
height: 12px;
line-height: 12px;
color: #374151;
vertical-align: middle;
}
.o_task_reassign_arrow .fa {
font-size: 10px;
line-height: 12px;
}
.o_task_reassign_arrow:hover {
color: #017e84;
}
.o_task_reassign_button {
margin-left: 8px;
padding: 2px 10px;
line-height: 20px;
white-space: nowrap;
}

View File

@ -29,7 +29,7 @@
<div class="o_task_assignees_inline">
<field name="user_ids" nolabel="1" widget="involved_assignee_avatar_user" domain="[('id', 'in', involved_user_ids)]" options="{'no_create': True, 'no_quick_create': True, 'no_create_edit': True}" invisible="is_generic"/>
<field name="user_ids" nolabel="1" class="o_task_user_field" options="{'no_open': True, 'no_quick_create': True}" widget="many2many_avatar_user" domain="[('id', 'in', assignee_domain_ids)]" invisible="not is_generic"/>
<button name="action_open_reassign_wizard" type="object" icon="fa-caret-down" title="Re-Assign Assignees" class="btn-link o_task_reassign_arrow" invisible="record_paused"/>
<button name="action_open_reassign_wizard" type="object" string="Re-Assign" title="Re-Assign Assignees" class="btn btn-secondary btn-sm o_task_reassign_button" invisible="record_paused"/>
</div>
</xpath>
<xpath expr="//field[@name='timesheet_ids']" position="attributes">
@ -293,7 +293,6 @@
<record id="project.project_task_kanban_action_view" model="ir.actions.act_window.view">
<field name="view_id" ref="project_task_timesheet_extended.project_task_kanban_task_id_module_project_tasks"/>
</record>
</data>
</odoo>

View File

@ -63,7 +63,7 @@ class TaskBackStageWizard(models.TransientModel):
for wizard in self:
stages = wizard.task_id.project_id.type_ids
if wizard.task_id.stage_id:
stages = stages.filtered(lambda s: s.id != wizard.task_id.stage_id.id)
stages = stages.filtered(lambda s: s.id != wizard.task_id.stage_id.id and s.sequence < wizard.task_id.stage_id.sequence)
wizard.available_stage_ids = [(6, 0, stages.ids)]
def action_confirm(self):
@ -89,7 +89,7 @@ class TaskRequestTimelinesWizard(models.TransientModel):
(0, 0, {
"stage_id": line.stage_id.id,
"responsible_user_id": line.responsible_lead.id,
"user_ids": [(6, 0, line.assigned_to.ids)],
"user_id": line.assigned_to.id,
})
for line in task.assignees_timelines.sorted(lambda l: l.stage_sequence)
if line.stage_id
@ -98,7 +98,7 @@ class TaskRequestTimelinesWizard(models.TransientModel):
def action_apply(self):
self.ensure_one()
selected_lines = self.line_ids.filtered(lambda line: line.stage_id and line.user_ids)
selected_lines = self.line_ids.filtered(lambda line: line.stage_id and line.user_id)
if not selected_lines:
raise UserError(_("Please select users for at least one stage."))
self.task_id.sudo().timelines_requested = True
@ -114,18 +114,41 @@ class TaskRequestTimelinesWizardLine(models.TransientModel):
task_id = fields.Many2one(related="wizard_id.task_id", store=False)
stage_id = fields.Many2one("project.task.type", string="Stage")
responsible_user_id = fields.Many2one("res.users", string="Responsible User")
user_ids = fields.Many2many("res.users", string="Assignees")
user_id = fields.Many2one("res.users", string="Assignee")
available_user_ids = fields.Many2many("res.users", compute="_compute_available_user_ids")
@api.depends("stage_id", "task_id")
def _get_project_involved_users(self):
self.ensure_one()
users = self.env["res.users"]
task = self.task_id
project = task.project_id
if project:
users |= project.members_ids | project.user_id | project.project_lead | project.project_sponsor
if self.stage_id:
users |= self.stage_id.involved_user_ids
if self.stage_id.team_id:
users |= self.stage_id.team_id.team_lead | self.stage_id.team_id.all_members_ids
if task:
users |= task.user_ids | task.involved_user_ids
return users.filtered(lambda user: user.active and not user.share)
@api.depends(
"stage_id",
"stage_id.involved_user_ids",
"stage_id.team_id",
"stage_id.team_id.team_lead",
"stage_id.team_id.all_members_ids",
"task_id",
"task_id.user_ids",
"task_id.involved_user_ids",
"task_id.project_id.members_ids",
"task_id.project_id.user_id",
"task_id.project_id.project_lead",
"task_id.project_id.project_sponsor",
)
def _compute_available_user_ids(self):
for line in self:
users = line.stage_id.involved_user_ids
if line.stage_id.team_id:
users |= line.stage_id.team_id.all_members_ids
if line.task_id.project_id:
users |= line.task_id.project_id.members_ids | line.task_id.project_id.user_id | line.task_id.project_id.project_lead
line.available_user_ids = [(6, 0, users.filtered(lambda u: not u.share).ids)]
line.available_user_ids = [(6, 0, line._get_project_involved_users().ids)]
class TaskReassignAssigneesWizard(models.TransientModel):
@ -133,10 +156,8 @@ class TaskReassignAssigneesWizard(models.TransientModel):
_description = "Task Re-Assign Assignees Wizard"
task_id = fields.Many2one("project.task", string="Task", required=True)
reason = fields.Text(string="Re-assignment Reason", required=True)
stage_ids = fields.Many2many("project.task.type", string="Stages", required=True)
available_stage_ids = fields.Many2many("project.task.type", compute="_compute_available_stage_ids")
user_ids = fields.Many2many("res.users", string="New Assignees", required=True)
available_user_ids = fields.Many2many("res.users", compute="_compute_available_user_ids")
line_ids = fields.One2many("task.reassign.assignees.wizard.line", "wizard_id", string="Selected Stage Assignees")
@ -146,14 +167,12 @@ class TaskReassignAssigneesWizard(models.TransientModel):
task = self.env["project.task"].browse(values.get("task_id") or self.env.context.get("default_task_id"))
if task:
default_stage = self.env["project.task.type"].browse(self.env.context.get("default_stage_id")) or task.stage_id
default_users = task.user_ids
values.setdefault("stage_ids", [(6, 0, default_stage.ids)])
values.setdefault("user_ids", [(6, 0, default_users.ids)])
if "line_ids" in fields_list and default_stage:
values["line_ids"] = [
(0, 0, {
"stage_id": stage.id,
"user_ids": [(6, 0, default_users.ids)],
"user_id": task.assignees_timelines.filtered(lambda line: line.stage_id == stage)[:1].assigned_to.id,
})
for stage in default_stage
]
@ -173,34 +192,46 @@ class TaskReassignAssigneesWizard(models.TransientModel):
if stage.team_id:
users |= stage.team_id.all_members_ids
if wizard.task_id.project_id:
users |= wizard.task_id.project_id.members_ids | wizard.task_id.project_id.user_id | wizard.task_id.project_id.project_lead
wizard.available_user_ids = [(6, 0, users.filtered(lambda u: not u.share).ids)]
users |= wizard.task_id.project_id.members_ids | wizard.task_id.project_id.user_id | wizard.task_id.project_id.project_lead | wizard.task_id.project_id.project_sponsor
wizard.available_user_ids = [(6, 0, users.filtered(lambda u: u.active and not u.share).ids)]
@api.onchange("stage_ids", "user_ids")
def _onchange_stage_or_users(self):
def _get_default_user_for_stage(self, stage):
self.ensure_one()
timeline = self.task_id.assignees_timelines.filtered(lambda line: line.stage_id == stage)[:1]
return timeline.assigned_to.id if timeline else False
def _preferred_existing_line(self, stage):
self.ensure_one()
lines = self.line_ids.filtered(lambda line: line.stage_id == stage)
return lines.filtered(lambda line: line.user_id or line.reason)[:1] or lines[:1]
@api.onchange("stage_ids")
def _onchange_stage_ids(self):
Line = self.env["task.reassign.assignees.wizard.line"]
for wizard in self:
existing_users_by_stage = {
line.stage_id.id: line.user_ids.ids
for line in wizard.line_ids
if line.stage_id
}
common_user_ids = wizard.user_ids.ids
wizard.line_ids = [(5, 0, 0)] + [
(0, 0, {
"stage_id": stage.id,
"user_ids": [(6, 0, common_user_ids or existing_users_by_stage.get(stage.id, []))],
})
for stage in wizard.stage_ids.sorted(lambda s: s.sequence)
]
lines = Line
for stage in wizard.stage_ids.sorted(lambda s: s.sequence):
existing_line = wizard._preferred_existing_line(stage)
if existing_line:
lines |= existing_line
else:
lines |= Line.new({
"stage_id": stage.id,
"user_id": wizard._get_default_user_for_stage(stage),
})
wizard.line_ids = lines
def action_confirm(self):
self.ensure_one()
selected_lines = self.line_ids.filtered(lambda line: line.stage_id and line.user_ids)
selected_lines = self.line_ids.filtered(lambda line: line.stage_id in self.stage_ids and line.user_id)
if not selected_lines:
raise UserError(_("Please select stages and new assignees."))
self.task_id._apply_task_stage_user_lines(selected_lines, reason=self.reason, reassignment=True)
if any(not line.reason for line in selected_lines):
raise UserError(_("Please enter a reason for each selected stage."))
self.task_id._apply_task_stage_user_lines(selected_lines, reassignment=True)
return {"type": "ir.actions.act_window_close"}
class TaskReassignAssigneesWizardLine(models.TransientModel):
_name = "task.reassign.assignees.wizard.line"
_description = "Task Re-Assign Assignees Stage User Line"
@ -208,15 +239,39 @@ class TaskReassignAssigneesWizardLine(models.TransientModel):
wizard_id = fields.Many2one("task.reassign.assignees.wizard", required=True, ondelete="cascade")
task_id = fields.Many2one(related="wizard_id.task_id", store=False)
stage_id = fields.Many2one("project.task.type", string="Stage")
user_ids = fields.Many2many("res.users", string="New Assignees")
user_id = fields.Many2one("res.users", string="New Assignee")
reason = fields.Text(string="Reason")
available_user_ids = fields.Many2many("res.users", compute="_compute_available_user_ids")
@api.depends("stage_id", "task_id")
def _get_project_involved_users(self):
self.ensure_one()
users = self.env["res.users"]
task = self.task_id
project = task.project_id
if project:
users |= project.members_ids | project.user_id | project.project_lead | project.project_sponsor
if self.stage_id:
users |= self.stage_id.involved_user_ids
if self.stage_id.team_id:
users |= self.stage_id.team_id.team_lead | self.stage_id.team_id.all_members_ids
if task:
users |= task.user_ids | task.involved_user_ids
return users.filtered(lambda user: user.active and not user.share)
@api.depends(
"stage_id",
"stage_id.involved_user_ids",
"stage_id.team_id",
"stage_id.team_id.team_lead",
"stage_id.team_id.all_members_ids",
"task_id",
"task_id.user_ids",
"task_id.involved_user_ids",
"task_id.project_id.members_ids",
"task_id.project_id.user_id",
"task_id.project_id.project_lead",
"task_id.project_id.project_sponsor",
)
def _compute_available_user_ids(self):
for line in self:
users = line.stage_id.involved_user_ids
if line.stage_id.team_id:
users |= line.stage_id.team_id.all_members_ids
if line.task_id.project_id:
users |= line.task_id.project_id.members_ids | line.task_id.project_id.user_id | line.task_id.project_id.project_lead
line.available_user_ids = [(6, 0, users.filtered(lambda u: not u.share).ids)]
line.available_user_ids = [(6, 0, line._get_project_involved_users().ids)]

View File

@ -79,9 +79,9 @@
<field name="line_ids" nolabel="1">
<list editable="bottom" create="0" delete="0">
<field name="stage_id" readonly="1" force_save="1" options="{'no_open': True}"/>
<field name="responsible_user_id" force_save="1" options="{'no_create': True, 'no_open': True}"/>
<field name="responsible_user_id" force_save="1" domain="[('id', 'in', available_user_ids)]" options="{'no_create': True, 'no_open': True}"/>
<field name="available_user_ids" column_invisible="1"/>
<field name="user_ids" string="Assignees" widget="many2many_tags" domain="[('id', 'in', available_user_ids)]" options="{'no_create': True, 'no_open': True}"/>
<field name="user_id" string="Assignee" domain="[('id', 'in', available_user_ids)]" options="{'no_create': True, 'no_open': True}"/>
</list>
</field>
<footer>
@ -100,16 +100,14 @@
<group>
<field name="task_id" invisible="1"/>
<field name="available_stage_ids" invisible="1"/>
<field name="available_user_ids" invisible="1"/>
<field name="reason" placeholder="Enter re-assignment reason..."/>
<field name="stage_ids" widget="many2many_tags" domain="[('id', 'in', available_stage_ids)]" options="{'no_create': True, 'no_open': True}"/>
<field name="user_ids" widget="many2many_tags" domain="[('id', 'in', available_user_ids)]" options="{'no_create': True, 'no_open': True}"/>
</group>
<field name="line_ids" nolabel="1" invisible="not stage_ids">
<list editable="bottom" create="0" delete="0">
<field name="stage_id" readonly="1" force_save="1" options="{'no_open': True}"/>
<field name="available_user_ids" column_invisible="1"/>
<field name="user_ids" widget="many2many_tags" domain="[('id', 'in', available_user_ids)]" options="{'no_create': True, 'no_open': True}"/>
<field name="user_id" string="New Assignee" force_save="1" domain="[('id', 'in', available_user_ids)]" options="{'no_create': True, 'no_open': True}"/>
<field name="reason" required="1" force_save="1"/>
</list>
</field>
<footer>