From 923304f75993e8ebb857f29d8770fb5d3ebaa8bc Mon Sep 17 00:00:00 2001 From: karuna Date: Tue, 26 May 2026 15:03:48 +0530 Subject: [PATCH] project enhancements --- .../TASK_PROJECT_BY_MODULE.md | 277 +++++++++++++++++ .../__manifest__.py | 2 + .../models/project.py | 3 + .../models/project_task.py | 287 ++++++++++++------ .../security/ir.model.access.csv | 5 + .../static/src/css/delopyment.css | 31 ++ .../view/project_by_module.xml | 85 ++++++ .../view/project_task.xml | 146 ++++++--- .../wizards/task_reject_reason_wizard.py | 260 +++++++++++++--- .../wizards/task_reject_reason_wizard.xml | 166 +++++++--- 10 files changed, 1040 insertions(+), 222 deletions(-) create mode 100644 addons_extensions/project_task_timesheet_extended/TASK_PROJECT_BY_MODULE.md create mode 100644 addons_extensions/project_task_timesheet_extended/view/project_by_module.xml diff --git a/addons_extensions/project_task_timesheet_extended/TASK_PROJECT_BY_MODULE.md b/addons_extensions/project_task_timesheet_extended/TASK_PROJECT_BY_MODULE.md new file mode 100644 index 000000000..97553ebc7 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/TASK_PROJECT_BY_MODULE.md @@ -0,0 +1,277 @@ +# Task Document: Project by Module + +## Requirement + +Add a **Project by Module** submenu in the Odoo **Project** menu. + +The menu should show **Project records grouped by Module**, so users can easily view projects module-wise. + +## Understanding + +Initially, the addon already had a `Module` field on `project.task`: + +```python +model_id = fields.Many2one('project.module.source', string="Module") +``` + +But the requirement is to show **projects by module**, not tasks by module. Since `project.project` did not have a module field, a new field was added on the Project model. + +## Implementation Summary + +The implementation adds: + +- A `module_id` field on `project.project` +- A Module field on the Project form +- A Project search group-by filter for Module +- A new Project menu item named **Project by Module** +- A new action that opens `project.project` grouped by `module_id` +- Manifest entry to load the new XML file + +## Files Changed + +### 1. `models/project.py` + +Added a new Many2one field on Project: + +```python +module_id = fields.Many2one('project.module.source', string="Module") +``` + +Location: + +```python +sequence_name = fields.Char("Project Number", copy=False, readonly=True) +module_id = fields.Many2one('project.module.source', string="Module") +task_sequence_id = fields.Many2one( + 'ir.sequence', + string="Task Sequence", + readonly=True, + copy=False, + help="Sequence for tasks of this project" +) +``` + +### 2. `view/project_by_module.xml` + +Created a new XML file to handle form view, search view, action, and menu. + +Full code: + +```xml + + + + + project.project.form.module.inherit + project.project + + + + + + + + + + project.project.search.project.by.module + project.project + + + + + + + + + + Project by Module + project.project + kanban,list,form,calendar,activity + + {'search_default_group_by_module': 1} + +

+ No projects found. +

+

+ Create projects and set their Module to review projects module-wise. +

+
+
+ + +
+
+``` + +### 3. `__manifest__.py` + +Added the new XML file in the data list: + +```python +'view/project_by_module.xml', +``` + +Location: + +```python +'view/project_task.xml', +'view/project.xml', +'view/project_portfolio.xml', +'view/project_by_module.xml', +'view/timesheets.xml', +``` + +## Expected Result + +After upgrading the module: + +1. Open a Project record. +2. Set the **Module** field. +3. Go to **Project > Project by Module**. +4. Odoo opens Project records grouped module-wise. + +Example: + +```text +PMT + Project A + Project B + +Finance + Project C + +ATS + Project D +``` + +## Module Upgrade Command + +Use the appropriate database name: + +```bash +odoo-bin -d your_database -u project_task_timesheet_extended +``` + +## Validation Done + +- XML syntax checked successfully. +- Python syntax checked successfully using `compile()`. +- `py_compile` could not write bytecode because Windows denied write access to `models/__pycache__`, but the Python syntax itself is valid. + +## Additional Update: Portfolio on Project Card and Module on Task + +After the initial Project by Module work, two display changes were added. + +### 1. Portfolio visible on Project Kanban card + +Project records already have `portfolio_id`. The project kanban card was extended to show the portfolio name under the project title/metadata area. + +Code added in `view/project_by_module.xml`: + +```xml + + project.project.kanban.portfolio.inherit + project.project + + + + + + + + + + + + + +``` + +### 2. Task Module comes from Project Module + +The task module field now comes from the task's selected project, so the task automatically shows which module it belongs to. + +Code changed in `models/project_task.py`: + +```python +model_id = fields.Many2one( + 'project.module.source', + string="Module", + related='project_id.module_id', + store=True, + readonly=True, +) +``` + +The task form also displays the Module near the task title area and keeps the regular Module field read-only. + +Code added in `view/project_by_module.xml`: + +```xml + + project.task.form.project.module.display + project.task + + + +
+ Module: + +
+
+ + 1 + {'no_open': True} + +
+
+``` + +Expected behavior: + +- Project kanban cards show their Portfolio when assigned. +- Task form shows the Module from the parent Project. +- Users only need to set Module on Project; related tasks display it automatically. + +### 3. Task ID and Module visible on Task Kanban card + +The task kanban card was extended to show the generated task sequence and the module directly under the task title. + +Code added in `view/project_task.xml`: + +```xml + + project.task.kanban.task.id.module + project.task + + + + + + + +
+ Task ID: + +
+
+ Module: + +
+
+
+
+``` + +Expected behavior on task kanban cards: + +```text +project task +Task ID: PROJ-012/TASK-001 +Module: PMT +``` diff --git a/addons_extensions/project_task_timesheet_extended/__manifest__.py b/addons_extensions/project_task_timesheet_extended/__manifest__.py index 11c586806..290bd04ab 100644 --- a/addons_extensions/project_task_timesheet_extended/__manifest__.py +++ b/addons_extensions/project_task_timesheet_extended/__manifest__.py @@ -52,6 +52,7 @@ Key Features: 'view/project_task.xml', 'view/project.xml', 'view/project_portfolio.xml', + 'view/project_by_module.xml', 'view/timesheets.xml', 'view/pro_task_gantt.xml', 'view/user_availability.xml', @@ -71,3 +72,4 @@ Key Features: 'auto_install': False, 'post_init_hook': 'post_init_hook', } + diff --git a/addons_extensions/project_task_timesheet_extended/models/project.py b/addons_extensions/project_task_timesheet_extended/models/project.py index 08f65d2d7..34bc07c7d 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project.py +++ b/addons_extensions/project_task_timesheet_extended/models/project.py @@ -25,6 +25,7 @@ class ProjectProject(models.Model): sequence_name = fields.Char("Project Number", copy=False, readonly=True) + module_id = fields.Many2one('project.module.source', string="Module") task_sequence_id = fields.Many2one( 'ir.sequence', string="Task Sequence", @@ -1199,3 +1200,5 @@ class ProjectTask(models.Model): """Toggle visibility of project chatter""" for project in self: project.show_task_chatter = not project.show_task_chatter + + diff --git a/addons_extensions/project_task_timesheet_extended/models/project_task.py b/addons_extensions/project_task_timesheet_extended/models/project_task.py index eb5202edd..1a1754b8e 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project_task.py +++ b/addons_extensions/project_task_timesheet_extended/models/project_task.py @@ -46,9 +46,20 @@ class projectTask(models.Model): ('normal', 'Normal'), ], compute='_compute_deadline_status') - model_id = fields.Many2one('project.module.source', string="Module") + model_id = fields.Many2one('project.module.source', string="Module", related='project_id.module_id', store=True, readonly=True) + task_display_id = fields.Char(string="Task ID", compute="_compute_kanban_display_fields", compute_sudo=True) + module_display_name = fields.Char(string="Module", compute="_compute_kanban_display_fields", compute_sudo=True) allocation_start_date = fields.Date(string="Allocation Start Date") allocation_end_date = fields.Date(string="Allocation End Date") + reassignment_history = fields.Html(string="Reassignment History", readonly=True) + + + @api.depends('sequence_name', 'project_id', 'project_id.module_id', 'project_id.module_id.name', 'model_id', 'model_id.name') + def _compute_kanban_display_fields(self): + for task in self: + task.task_display_id = task.sequence_name or ("TASK-%03d" % task.id if task.id else "New Task") + module = task.model_id or task.project_id.module_id + task.module_display_name = module.name if module else "No Module" @api.depends('date_deadline') def _compute_deadline_status(self): @@ -77,14 +88,8 @@ class projectTask(models.Model): return super(projectTask, self).unlink() def write(self, vals): - # Allow stage update for multiple records - if 'stage_id' in vals: - result = super(projectTask, self).write(vals) - if any(field in vals for field in ['allocation_start_date', 'allocation_end_date']): - self._sync_allocated_hours_from_allocation_dates() - if any(field in vals for field in ['user_ids', 'is_generic']): - self._sync_involved_assignees_from_timelines() - return result + if 'stage_approval' in vals: + raise UserError(_("Are you sure you want to change Stage Approval?")) result = super(projectTask, self).write(vals) if any(field in vals for field in ['allocation_start_date', 'allocation_end_date']): @@ -193,7 +198,7 @@ class projectTask(models.Model): all_internal_users = Users.search([('share', '=', False)]) for task in self: - # no project → all internal + # no project → all internal if not task.project_id: task.assignee_domain_ids = all_internal_users continue @@ -769,55 +774,15 @@ class projectTask(models.Model): task.task_activity_log = Markup(log_entry) def back_button(self): - for task in self: - task.can_edit_approval_flow_stages = True - task.approval_status = False - - prev_stage = task.project_id.type_ids.filtered(lambda s: s.sequence < task.stage_id.sequence) - prev_stage = prev_stage.sorted(key=lambda s: s.sequence, reverse=True)[:1] # Get next one - - stage = task.assignees_timelines.filtered(lambda s: s.stage_id == prev_stage) - responsible_user = stage.assigned_to if stage and stage.assigned_to else ( - task.project_id.project_lead if task.project_id.project_lead else False) - - activity_log = "%s : %s Reverted the stage Back to %s" % ( - task.stage_id.name, - self.env.user.employee_id.name, - prev_stage.name - ) - - task.stage_id = prev_stage - - # Use the helper method to add activity log - task._add_activity_log(activity_log) - - # Post to project channel with mention using proper Odoo format - if responsible_user: - channel_message = _("Task %s reverted from %s back to %s. %s please take action.") % ( - task.sequence_name or task.name, - task.stage_id.name, - prev_stage.name, - self._create_odoo_mention(responsible_user.partner_id) - ) - else: - channel_message = _("Task %s reverted from %s back to %s") % ( - task.sequence_name or task.name, - task.stage_id.name, - prev_stage.name - ) - task._post_to_project_channel(channel_message) - - # Send chatter notification - if responsible_user: - task.message_post( - body=activity_log, - partner_ids=[responsible_user.partner_id.id], - message_type='notification', - subtype_xmlid='mail.mt_comment', - ) - task.can_edit_approval_flow_stages = False - - + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Move Task Back"), + "res_model": "task.back.stage.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_task_id": self.id}, + } def submit_for_approval(self): for task in self: task.can_edit_approval_flow_stages = True @@ -876,7 +841,7 @@ class projectTask(models.Model): task.stage_id = n_stage task.approval_status = "approved" - activity_log = "%s: ✅ approved by %s and moved to %s" % ( + activity_log = "%s: ✅ approved by %s and moved to %s" % ( current_stage.name, self.env.user.employee_id.name, n_stage.name) @@ -884,7 +849,7 @@ class projectTask(models.Model): # Use the helper method to add activity log task._add_activity_log(activity_log) - user_notes = "%s: ✅ moved to %s and awaiting your completion" % ( + user_notes = "%s: ✅ moved to %s and awaiting your completion" % ( task.sequence_name, n_stage.name ) @@ -917,9 +882,9 @@ class projectTask(models.Model): ) else: task.approval_status = "approved" - notes = "%s: ✅ Task approved and completed by %s" % (task.sequence_name, self.env.user.employee_id.name) + notes = "%s: ✅ Task approved and completed by %s" % (task.sequence_name, self.env.user.employee_id.name) - activity_log = "%s: ✅ approved by %s" % ( + activity_log = "%s: ✅ approved by %s" % ( current_stage.name, self.env.user.employee_id.name) @@ -943,7 +908,7 @@ class projectTask(models.Model): task.can_edit_approval_flow_stages = False - def reject_and_return(self, reason=None): + def reject_and_return(self, reason=None, return_stage=False): for task in self: task.can_edit_approval_flow_stages = True if not reason: @@ -955,9 +920,9 @@ class projectTask(models.Model): # Optional: find previous stage if you want to send back stage = task.assignees_timelines.filtered(lambda s: s.stage_id == task.stage_id) - notes = "%s: ❌ %s rejected by %s" % (task.sequence_name, current_stage.name, self.env.user.employee_id.name) + notes = "%s: ❌ %s rejected by %s" % (task.sequence_name, current_stage.name, self.env.user.employee_id.name) - activity_log = "%s: ❌ rejected by %s: %s" % ( + activity_log = "%s: ❌ rejected by %s: %s" % ( current_stage.name, self.env.user.employee_id.name, reason) @@ -979,6 +944,9 @@ class projectTask(models.Model): subtype_xmlid='mail.mt_comment', ) + if return_stage: + task._move_to_selected_stage(return_stage) + if stage: responsible_user = stage.assigned_to if stage.assigned_to else stage.responsible_lead if stage.responsible_lead else task.project_id.user_id @@ -1012,45 +980,166 @@ class projectTask(models.Model): } def request_timelines(self): - """Populate task timelines with all relevant project stages.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Request Timelines"), + "res_model": "task.request.timelines.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_task_id": self.id, + "default_stage_id": self.stage_id.id, + }, + } + def _get_stage_responsible_user(self, stage): + self.ensure_one() + if stage.approval_by == 'assigned_team_lead' and stage.team_id.team_lead: + return stage.team_id.team_lead + if stage.approval_by == 'project_manager' and self.project_id.user_id: + return self.project_id.user_id + if stage.approval_by == 'project_lead' and self.project_id.project_lead: + return self.project_id.project_lead + return self.project_id.project_lead or self.project_id.user_id + + def _ensure_task_timeline_rows(self): + Timeline = self.env['project.task.time.lines'].sudo() for task in self: - task.can_edit_approval_flow_stages = True - task.timelines_requested = True - # Clear existing timelines if needed - task.assignees_timelines.unlink() - - # Fetch project stages stages = task.project_id.type_ids.filtered(lambda s: not s.fold) - if not stages: - continue - - timeline_vals = [] for stage in stages: - responsible_user = False - if stage.approval_by == 'assigned_team_lead' and stage.team_id.team_lead: - responsible_user = stage.team_id.team_lead.id - elif stage.approval_by == 'project_manager' and task.project_id.user_id: - responsible_user = task.project_id.user_id.id - elif stage.approval_by == 'project_lead' and getattr(task.project_id, 'project_lead', False): - responsible_user = task.project_id.project_lead.id - - timeline_vals.append({ + line = task.assignees_timelines.filtered(lambda t: t.stage_id == stage)[:1] + if line: + continue + responsible_user = task._get_stage_responsible_user(stage) + Timeline.create({ 'stage_id': stage.id, 'team_id': stage.team_id.id if stage.team_id else False, - 'responsible_lead': responsible_user, - 'assigned_to': responsible_user, + 'responsible_lead': responsible_user.id if responsible_user else False, + 'assigned_to': responsible_user.id if responsible_user else False, 'estimated_time': 0.0, 'task_id': task.id, }) - if timeline_vals: - self.env['project.task.time.lines'].create(timeline_vals) - task._sync_involved_assignees_from_timelines() + def _send_stage_mail_template(self, stage): + self.ensure_one() + if stage and stage.mail_template_id: + stage.mail_template_id.sudo().send_mail(self.id, force_send=True) - # Post to project channel about timeline request - channel_message = _("Timelines requested for task %s") % (task.sequence_name or task.name) - task._post_to_project_channel(channel_message) - task.can_edit_approval_flow_stages = False + def _apply_task_stage_users(self, stage, users, reason=False, reassignment=False): + self.ensure_one() + if not stage: + raise UserError(_("Please select a stage.")) + if not users: + raise UserError(_("Please select at least one user.")) + self._ensure_task_timeline_rows() + line = self.assignees_timelines.filtered(lambda t: t.stage_id == stage)[:1] + primary_user = users[0] + if line: + line.sudo().write({ + 'assigned_to': primary_user.id, + 'responsible_lead': line.responsible_lead.id or primary_user.id, + 'team_id': stage.team_id.id if stage.team_id else line.team_id.id, + }) + self.sudo().write({'user_ids': [(6, 0, users.ids)]}) + self.sudo().write({'involved_user_ids': [(6, 0, users.ids)]}) + action = _("Re-assigned") if reassignment else _("Timeline users selected") + note = _("%s for stage %s: %s") % (action, stage.name, ", ".join(users.mapped('name'))) + if reason: + note += _(". Reason: %s") % reason + self._add_activity_log(note) + self.message_post(body=note, partner_ids=users.mapped('partner_id').ids, message_type='notification', subtype_xmlid='mail.mt_comment') + self._post_to_project_channel(note, users.mapped('partner_id')) + self._send_stage_mail_template(stage) + if reassignment: + self._append_reassignment_history(stage, users, reason) + + def _apply_task_stage_user_lines(self, lines, reason=False, reassignment=False): + self.ensure_one() + self._ensure_task_timeline_rows() + selected_users = self.env['res.users'] + notes = [] + for wizard_line in lines: + stage = wizard_line.stage_id + users = wizard_line.user_ids + if not stage or not users: + continue + selected_users |= users + timeline = self.assignees_timelines.filtered(lambda t: t.stage_id == stage)[:1] + primary_user = users[0] + if timeline: + timeline.sudo().write({ + 'assigned_to': primary_user.id, + 'responsible_lead': 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')))) + self._send_stage_mail_template(stage) + if reassignment: + self._append_reassignment_history(stage, users, reason) + + if not selected_users: + raise UserError(_("Please select at least one user.")) + + 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: + note += _(". Reason: %s") % reason + self._add_activity_log(note) + self.message_post( + body=note, + partner_ids=selected_users.mapped('partner_id').ids, + message_type='notification', + subtype_xmlid='mail.mt_comment', + ) + self._post_to_project_channel(note, selected_users.mapped('partner_id')) + + def _move_to_selected_stage(self, stage): + self.ensure_one() + if not stage: + raise UserError(_("Please select a stage.")) + old_stage = self.stage_id + self.can_edit_approval_flow_stages = True + self.approval_status = False + self.stage_id = stage + self.can_edit_approval_flow_stages = False + line = self.assignees_timelines.filtered(lambda t: t.stage_id == stage)[:1] + responsible_user = line.assigned_to or line.responsible_lead or self._get_stage_responsible_user(stage) + note = _("%s moved task from %s to %s") % (self.env.user.name, old_stage.name if old_stage else "", stage.name) + self._add_activity_log(note) + partner_ids = responsible_user.partner_id.ids if responsible_user else [] + self.message_post(body=note, partner_ids=partner_ids, message_type='notification', subtype_xmlid='mail.mt_comment') + self._post_to_project_channel(note, responsible_user.partner_id if responsible_user else False) + self._send_stage_mail_template(stage) + + def _append_reassignment_history(self, stage, users, reason): + self.ensure_one() + formatted_datetime = self._get_current_datetime_formatted() + entry = "[%s] Stage: %s | Users: %s | Reason: %s" % ( + formatted_datetime, + stage.name, + ", ".join(users.mapped('name')), + reason or "", + ) + if self.reassignment_history: + self.reassignment_history = Markup(self.reassignment_history) + Markup('
') + Markup(entry) + else: + self.reassignment_history = Markup(entry) + + def action_open_reassign_wizard(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Re-Assign Assignees"), + "res_model": "task.reassign.assignees.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_task_id": self.id, + "default_stage_id": self.stage_id.id, + }, + } @api.model_create_multi def create(self, vals_list): @@ -1599,6 +1688,7 @@ class projectTaskTimelines(models.Model): estimated_start_datetime = fields.Datetime(string="Estimated Start Date Time") estimated_end_datetime = fields.Datetime(string="Estimated End Date Time") + @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) @@ -1716,3 +1806,10 @@ class projectTaskTimelines(models.Model): allowed_teams |= team allowed_teams |= team.child_ids rec.allowed_team_ids = allowed_teams + + + + + + + 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 91ecad012..7ad8b014d 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 @@ -95,3 +95,8 @@ access_project_resource_contract_period_manager,project.resource.contract.period access_timesheets_user,timesheets user,model_account_analytic_line,base.group_user,1,1,1,0 access_stage_approval_wizard,stage.approval.wizard,model_stage_approval_wizard,,1,1,1,1 +access_task_back_stage_wizard,task.back.stage.wizard,model_task_back_stage_wizard,base.group_user,1,1,1,1 +access_task_request_timelines_wizard,task.request.timelines.wizard,model_task_request_timelines_wizard,base.group_user,1,1,1,1 +access_task_reassign_assignees_wizard,task.reassign.assignees.wizard,model_task_reassign_assignees_wizard,base.group_user,1,1,1,1 +access_task_request_timelines_wizard_line,task.request.timelines.wizard.line,model_task_request_timelines_wizard_line,base.group_user,1,1,1,1 +access_task_reassign_assignees_wizard_line,task.reassign.assignees.wizard.line,model_task_reassign_assignees_wizard_line,base.group_user,1,1,1,1 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 index 795b5b0b4..c451556fb 100644 --- a/addons_extensions/project_task_timesheet_extended/static/src/css/delopyment.css +++ b/addons_extensions/project_task_timesheet_extended/static/src/css/delopyment.css @@ -49,4 +49,35 @@ .badge { font-weight: bold; color: #2d8f2d; +} +.o_task_assignees_inline { + display: inline-flex; + align-items: center; + gap: 4px; + width: 100%; +} + +.o_task_assignees_inline .o_field_widget { + flex: 0 1 auto; + 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; } diff --git a/addons_extensions/project_task_timesheet_extended/view/project_by_module.xml b/addons_extensions/project_task_timesheet_extended/view/project_by_module.xml new file mode 100644 index 000000000..c2183fdd4 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/view/project_by_module.xml @@ -0,0 +1,85 @@ + + + + + project.project.form.module.inherit + project.project + + + + + + + + + + project.project.search.project.by.module + project.project + + + + + + + + + + Project by Module + project.project + kanban,list,form,calendar,activity + + {'search_default_group_by_module': 1} + +

+ No projects found. +

+

+ Create projects and set their Module to review projects module-wise. +

+
+
+ + + + + project.project.kanban.portfolio.inherit + project.project + + + + + + + + + + + + + + + + project.task.form.project.module.display + project.task + + + +
+ Module: + +
+
+ + 1 + {'no_open': True} + +
+
+
+
+ + diff --git a/addons_extensions/project_task_timesheet_extended/view/project_task.xml b/addons_extensions/project_task_timesheet_extended/view/project_task.xml index 5c695d016..c56a36292 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project_task.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project_task.xml @@ -24,13 +24,14 @@ THIS TASK IS CURRENTLY PAUSED - - involved_assignee_avatar_user - [('id', 'in', involved_user_ids)] - is_generic - {'no_create': True, 'no_quick_create': True, 'no_create_edit': True} + + - { @@ -60,13 +61,12 @@ - - - - - - - + + + + + + @@ -95,6 +95,9 @@ + + + @@ -191,28 +194,6 @@ - - - - - - - - - - - - - - - - - - - - - - ['|', ('user_ids', 'in', uid), ('involved_user_ids', 'in', uid)] @@ -226,7 +207,100 @@ ['|', ('user_ids', 'in', uid), ('involved_user_ids', 'in', uid)] + + + + + + + + + project.task.kanban.task.id.module.base + project.task + + 80 + + + + + + +
+ Task ID: + +
+
+ Module: + +
+
+ + + + + + + + project.task.kanban.task.id.module.my.tasks + project.task + + primary + 80 + + + + + + + + + + project.task.kanban.task.id.module.all.tasks + project.task + + primary + 80 + + + + + + + + + + project.task.kanban.task.id.module.project.tasks + project.task + + primary + 80 + + + + + + + + + + + + + + + + + - \ No newline at end of file + + + + + + + + + + diff --git a/addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.py b/addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.py index 917ad462c..981f3380b 100644 --- a/addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.py +++ b/addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.py @@ -1,44 +1,216 @@ -from odoo import models, fields, api, _ -from odoo.exceptions import UserError - -class TaskRejectReasonWizard(models.TransientModel): - _name = "task.reject.reason.wizard" - _description = "Task Rejection Reason Wizard" - - reason = fields.Text(string="Rejection Reason", required=True) - task_id = fields.Many2one("project.task", string="Task", required=True) - - def action_reject(self): - """Trigger the rejection action on the selected task""" - self.ensure_one() - if not self.reason: - raise UserError(_("Please enter a reason for rejection.")) - - # Call the existing reject method on the task - self.task_id.reject_and_return(reason=self.reason) - - return { - "type": "ir.actions.act_window_close" - } - - - -class ProjectRejectReasonWizard(models.TransientModel): - _name = "project.reject.reason.wizard" - _description = "Project Rejection Reason Wizard" - - reason = fields.Text(string="Rejection Reason", required=True) - project_id = fields.Many2one("project.project", string="Project", required=True) - - def action_reject(self): - """Trigger the rejection action on the selected task""" - self.ensure_one() - if not self.reason: - raise UserError(_("Please enter a reason for rejection.")) - - # Call the existing reject method on the task - self.project_id.reject_and_return(reason=self.reason) - - return { - "type": "ir.actions.act_window_close" - } +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class TaskRejectReasonWizard(models.TransientModel): + _name = "task.reject.reason.wizard" + _description = "Task Rejection Reason Wizard" + + reason = fields.Text(string="Rejection Reason", required=True) + task_id = fields.Many2one("project.task", string="Task", required=True) + stage_id = fields.Many2one("project.task.type", string="Return To Stage", required=True) + available_stage_ids = fields.Many2many("project.task.type", compute="_compute_available_stage_ids") + + @api.depends("task_id", "task_id.project_id", "task_id.stage_id") + def _compute_available_stage_ids(self): + 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) + wizard.available_stage_ids = [(6, 0, stages.ids)] + + def action_reject(self): + self.ensure_one() + if not self.reason: + raise UserError(_("Please enter a reason for rejection.")) + if not self.stage_id: + raise UserError(_("Please select the stage where the task should be returned.")) + + self.task_id.reject_and_return(reason=self.reason, return_stage=self.stage_id) + return {"type": "ir.actions.act_window_close"} + + +class ProjectRejectReasonWizard(models.TransientModel): + _name = "project.reject.reason.wizard" + _description = "Project Rejection Reason Wizard" + + reason = fields.Text(string="Rejection Reason", required=True) + project_id = fields.Many2one("project.project", string="Project", required=True) + + def action_reject(self): + self.ensure_one() + if not self.reason: + raise UserError(_("Please enter a reason for rejection.")) + + self.project_id.reject_and_return(reason=self.reason) + return {"type": "ir.actions.act_window_close"} + + +class TaskBackStageWizard(models.TransientModel): + _name = "task.back.stage.wizard" + _description = "Task Back Stage Selection Wizard" + + task_id = fields.Many2one("project.task", string="Task", required=True) + stage_id = fields.Many2one("project.task.type", string="Move To Stage", required=True) + available_stage_ids = fields.Many2many("project.task.type", compute="_compute_available_stage_ids") + + @api.depends("task_id", "task_id.project_id", "task_id.stage_id") + def _compute_available_stage_ids(self): + 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) + wizard.available_stage_ids = [(6, 0, stages.ids)] + + def action_confirm(self): + self.ensure_one() + self.task_id._move_to_selected_stage(self.stage_id) + return {"type": "ir.actions.act_window_close"} + + +class TaskRequestTimelinesWizard(models.TransientModel): + _name = "task.request.timelines.wizard" + _description = "Task Request Timelines User Selection Wizard" + + task_id = fields.Many2one("project.task", string="Task", required=True) + line_ids = fields.One2many("task.request.timelines.wizard.line", "wizard_id", string="Stage Assignees") + + @api.model + def default_get(self, fields_list): + values = super().default_get(fields_list) + task = self.env["project.task"].browse(values.get("task_id") or self.env.context.get("default_task_id")) + if task and "line_ids" in fields_list: + task._ensure_task_timeline_rows() + values["line_ids"] = [ + (0, 0, { + "stage_id": line.stage_id.id, + "user_ids": [(6, 0, line.assigned_to.ids)], + }) + for line in task.assignees_timelines.sorted(lambda l: l.stage_sequence) + if line.stage_id + ] + return values + + def action_apply(self): + self.ensure_one() + selected_lines = self.line_ids.filtered(lambda line: line.stage_id and line.user_ids) + if not selected_lines: + raise UserError(_("Please select users for at least one stage.")) + self.task_id.sudo().timelines_requested = True + self.task_id._apply_task_stage_user_lines(selected_lines, reassignment=False) + return {"type": "ir.actions.act_window_close"} + + +class TaskRequestTimelinesWizardLine(models.TransientModel): + _name = "task.request.timelines.wizard.line" + _description = "Task Request Timelines Stage User Line" + + wizard_id = fields.Many2one("task.request.timelines.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", required=True) + user_ids = fields.Many2many("res.users", string="Assigned To") + available_user_ids = fields.Many2many("res.users", compute="_compute_available_user_ids") + + @api.depends("stage_id", "task_id") + 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)] + + +class TaskReassignAssigneesWizard(models.TransientModel): + _name = "task.reassign.assignees.wizard" + _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") + + @api.model + def default_get(self, fields_list): + values = super().default_get(fields_list) + 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)], + }) + for stage in default_stage + ] + return values + + @api.depends("task_id") + def _compute_available_stage_ids(self): + for wizard in self: + wizard.available_stage_ids = [(6, 0, wizard.task_id.project_id.type_ids.ids)] + + @api.depends("stage_ids", "task_id") + def _compute_available_user_ids(self): + for wizard in self: + users = self.env["res.users"] + for stage in wizard.stage_ids: + users |= stage.involved_user_ids + 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)] + + @api.onchange("stage_ids", "user_ids") + def _onchange_stage_or_users(self): + 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) + ] + + def action_confirm(self): + self.ensure_one() + selected_lines = self.line_ids.filtered(lambda line: line.stage_id and line.user_ids) + 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) + 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" + + 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", required=True) + user_ids = fields.Many2many("res.users", string="New Assignees") + available_user_ids = fields.Many2many("res.users", compute="_compute_available_user_ids") + + @api.depends("stage_id", "task_id") + 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)] diff --git a/addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.xml b/addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.xml index 64a727fd7..bee17f1ad 100644 --- a/addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.xml +++ b/addons_extensions/project_task_timesheet_extended/wizards/task_reject_reason_wizard.xml @@ -1,48 +1,120 @@ - - - - task.reject.reason.wizard.form - task.reject.reason.wizard - -
- - - -
-
-
-
-
- - - Reject Task - task.reject.reason.wizard - form - new - - - - project.reject.reason.wizard.form - project.reject.reason.wizard - -
- - - -
-
-
-
-
- - - Reject Task - project.reject.reason.wizard - form - new - + + + + task.reject.reason.wizard.form + task.reject.reason.wizard + +
+ + + + + + +
+
+
+
+
+ + + Reject Task + task.reject.reason.wizard + form + new + + + + project.reject.reason.wizard.form + project.reject.reason.wizard + +
+ + + +
+
+
+
+
+ + + Reject Task + project.reject.reason.wizard + form + new + + + + task.back.stage.wizard.form + task.back.stage.wizard + +
+ + + + + +
+
+
+
+
+ + + task.request.timelines.wizard.form + task.request.timelines.wizard + +
+ + + + + + + + + + +
+
+
+
+
+ + + task.reassign.assignees.wizard.form + task.reassign.assignees.wizard + +
+ + + + + + + + + + + + + + + + +
+
+