diff --git a/addons_extensions/menu_control_center/__manifest__.py b/addons_extensions/menu_control_center/__manifest__.py index 25a53fdeb..48e8d8b62 100644 --- a/addons_extensions/menu_control_center/__manifest__.py +++ b/addons_extensions/menu_control_center/__manifest__.py @@ -15,6 +15,7 @@ 'security/ir.model.access.csv', 'data/data.xml', 'views/masters.xml', + 'views/groups.xml', 'views/login.xml', 'views/menu_access_control_views.xml', ], diff --git a/addons_extensions/menu_control_center/controllers/main.py b/addons_extensions/menu_control_center/controllers/main.py index cb59c6a73..7e333b9bd 100644 --- a/addons_extensions/menu_control_center/controllers/main.py +++ b/addons_extensions/menu_control_center/controllers/main.py @@ -18,6 +18,8 @@ class CustomMasterLogin(Home): # load your masters masters = request.env['master.control'].sudo().search([]) response.qcontext['masters'] = masters + request.env['ir.ui.menu'].sudo().clear_caches() + request.env['ir.ui.menu'].sudo()._visible_menu_ids() # After successful login if request.session.uid and master_selected: diff --git a/addons_extensions/menu_control_center/models/__init__.py b/addons_extensions/menu_control_center/models/__init__.py index 3e41a1d7b..ba2150479 100644 --- a/addons_extensions/menu_control_center/models/__init__.py +++ b/addons_extensions/menu_control_center/models/__init__.py @@ -1,3 +1,5 @@ from . import masters +from . import ir_http +from . import groups from . import models from . import menu \ No newline at end of file diff --git a/addons_extensions/menu_control_center/models/groups.py b/addons_extensions/menu_control_center/models/groups.py new file mode 100644 index 000000000..fa05dbd20 --- /dev/null +++ b/addons_extensions/menu_control_center/models/groups.py @@ -0,0 +1,73 @@ +from odoo import api, fields, models +from odoo.http import request + + +class ResGroups(models.Model): + _inherit = 'res.groups' + + is_visible_for_master = fields.Boolean( + compute='_compute_is_visible_for_master', + search='_search_is_visible_for_master' + ) + + @api.depends_context('active_master') + def _compute_is_visible_for_master(self): + master_code = request.session.active_master + if not master_code: + self.update({'is_visible_for_master': True}) + return + + master_control = self.env['master.control'].sudo().search([ + ('code', '=', master_code) + ], limit=1) + + if not master_control: + self.update({'is_visible_for_master': True}) + return + + # If NO access_group_ids -> show ALL groups + if not master_control.access_group_ids: + self.update({'is_visible_for_master': True}) + return + + visible_group_ids = master_control.access_group_ids.filtered( + lambda line: line.show_group + ).mapped('group_id').ids + + # If there are no 'show_group = True' -> show ALL groups + if not visible_group_ids: + self.update({'is_visible_for_master': True}) + return + + for group in self: + group.is_visible_for_master = group.id in visible_group_ids + + + def _search_is_visible_for_master(self, operator, value): + if operator != '=' or not value: + return [] + + master_code = request.session.active_master + if not master_code: + return [] + + master_control = self.env['master.control'].sudo().search([ + ('code', '=', master_code) + ], limit=1) + + if not master_control: + return [] + + # If NO access_group_ids → show ALL groups → no domain filter + if not master_control.access_group_ids: + return [] + + visible_group_ids = master_control.access_group_ids.filtered( + lambda line: line.show_group + ).mapped('group_id').ids + + # If none of the lines have show_group=True → show ALL + if not visible_group_ids: + return [] + + return [('id', 'in', visible_group_ids)] diff --git a/addons_extensions/menu_control_center/models/ir_http.py b/addons_extensions/menu_control_center/models/ir_http.py new file mode 100644 index 000000000..4021b1d47 --- /dev/null +++ b/addons_extensions/menu_control_center/models/ir_http.py @@ -0,0 +1,16 @@ +import odoo +from odoo import api, models, fields +from odoo.http import request +from odoo import http + +class IrHttp(models.AbstractModel): + _inherit = 'ir.http' + + def session_info(self): + info = super().session_info() + active_master = http.request.session.get("active_master") + if active_master: + info['user_context']['active_master'] = active_master + else: + info['user_context']['active_master'] = '' + return info \ No newline at end of file diff --git a/addons_extensions/menu_control_center/models/masters.py b/addons_extensions/menu_control_center/models/masters.py index 93b4f4f5d..2d77a54ac 100644 --- a/addons_extensions/menu_control_center/models/masters.py +++ b/addons_extensions/menu_control_center/models/masters.py @@ -1,4 +1,4 @@ -from odoo import models, fields, _ +from odoo import models, fields, _, api class MasterControl(models.Model): _name = 'master.control' @@ -10,6 +10,13 @@ class MasterControl(models.Model): default_show = fields.Boolean(default=True) access_group_ids = fields.One2many('group.access.line','master_control_id',string='Roles') + @api.depends('name', 'code') + def _compute_display_name(self): + for record in self: + if record.name: + record.display_name = record.name + (f' ({record.code})' if record.code else '') + else: + record.display_name = False def action_generate_groups(self): """Generate category → groups list""" @@ -42,6 +49,8 @@ class MasterControl(models.Model): # UPDATE GROUPS (Detect new groups) # ----------------------------------------- def action_update_groups(self): + import pdb + pdb.set_trace() for rec in self: created_count = 0 diff --git a/addons_extensions/menu_control_center/models/menu.py b/addons_extensions/menu_control_center/models/menu.py index 8a0e1f736..36fe416a0 100644 --- a/addons_extensions/menu_control_center/models/menu.py +++ b/addons_extensions/menu_control_center/models/menu.py @@ -1,6 +1,8 @@ +from passlib.apps import master_context + from odoo import models, fields, api, tools, _ from collections import defaultdict - +from odoo.http import request class IrUiMenu(models.Model): _inherit = 'ir.ui.menu' @@ -8,41 +10,100 @@ class IrUiMenu(models.Model): @api.model @tools.ormcache('frozenset(self.env.user.groups_id.ids)', 'debug') def _visible_menu_ids(self, debug=False): - """ Return the ids of the menu items visible to the user. """ - # retrieve all menus, and determine which ones are visible + """Return the IDs of menu items visible to the current user based on permissions and active master.""" + # Clear existing cache to ensure fresh menu visibility calculation + self.env['ir.ui.menu'].sudo().clear_caches() + + # Retrieve all menus with required fields context = {'ir.ui.menu.full_list': True} menus = self.with_context(context).search_fetch([], ['action', 'parent_id']).sudo() - # first discard all menus with groups the user does not have + # Get active master and control configuration + active_master = request.session.get('active_master') + master_control = False + control_unit = False + if active_master: + master_control = self.env['master.control'].sudo().search( + [('code', '=', active_master)], limit=1 + ) + if master_control: + control_unit = self.env['menu.access.control'].sudo().search([ + ('user_ids', 'ilike', self.env.user.id), + ('master_control', '=', master_control.id) + ]) + + # Get user groups and exclude technical group in non-debug mode group_ids = set(self.env.user._get_group_ids()) if not debug: - parent_menus = self.env['menu.access.control'].sudo().search([('user_ids','ilike',self.env.user.id)]).access_menu_line_ids.filtered(lambda menu: not(menu.is_main_menu)).menu_id.ids - sub_menus = self.env['menu.access.control'].sudo().search([('user_ids','ilike',self.env.user.id)]).access_sub_menu_line_ids.filtered(lambda menu: not(menu.is_main_menu)).menu_id.ids + group_ids -= { + self.env['ir.model.data']._xmlid_to_res_id( + 'base.group_no_one', raise_if_not_found=False + ) + } - hide_menus_list = list(set(parent_menus + sub_menus)) - - menus = menus.filtered(lambda menu: (menu.id not in hide_menus_list)) - group_ids = group_ids - { - self.env['ir.model.data']._xmlid_to_res_id('base.group_no_one', raise_if_not_found=False)} + # Filter menus by group permissions menus = menus.filtered( - lambda menu: not (menu.groups_id and group_ids.isdisjoint(menu.groups_id._ids))) + lambda menu: not (menu.groups_id and group_ids.isdisjoint(menu.groups_id._ids)) + ) - # take apart menus that have an action + # Determine menus to hide based on access control + hide_menus_list = self._get_hidden_menu_ids(control_unit, master_control, debug) + menus = menus.filtered(lambda menu: menu.id not in hide_menus_list) + + # Process menus with actions + visible = self._process_action_menus(menus) + + return set(visible.ids) + + def _get_hidden_menu_ids(self, control_unit, master_control, debug): + """Helper method to determine menu IDs that should be hidden from the user.""" + if debug and control_unit: + # In debug mode with control unit, use its specific menu restrictions + parent_menus = control_unit.access_menu_line_ids.filtered( + lambda menu: not menu.is_main_menu + ).menu_id.ids + sub_menus = control_unit.access_sub_menu_line_ids.filtered( + lambda menu: not menu.is_main_menu + ).menu_id.ids + elif not debug: + # In non-debug mode, determine menus to hide based on control configuration + domain = [('user_ids', 'ilike', self.env.user.id)] + if master_control: + domain.append(('master_control', '=', master_control.id)) + + access_controls = self.env['menu.access.control'].sudo().search(domain) + parent_menus = access_controls.access_menu_line_ids.filtered( + lambda menu: not menu.is_main_menu + ).menu_id.ids + sub_menus = access_controls.access_sub_menu_line_ids.filtered( + lambda menu: not menu.is_main_menu + ).menu_id.ids + else: + # Default case: no menus to hide + return [] + + return list(set(parent_menus + sub_menus)) + + def _process_action_menus(self, menus): + """Process menus with actions and determine visibility based on model access.""" + # Separate menus with actions from folder menus actions_by_model = defaultdict(set) for action in menus.mapped('action'): if action: actions_by_model[action._name].add(action.id) + existing_actions = { action for model_name, action_ids in actions_by_model.items() for action in self.env[model_name].browse(action_ids).exists() } + action_menus = menus.filtered(lambda m: m.action and m.action in existing_actions) folder_menus = menus - action_menus visible = self.browse() - # process action menus, check whether their action is allowed + # Model access check configuration access = self.env['ir.model.access'] MODEL_BY_TYPE = { 'ir.actions.act_window': 'res_model', @@ -50,21 +111,22 @@ class IrUiMenu(models.Model): 'ir.actions.server': 'model_name', } - # performance trick: determine the ids to prefetch by type + # Prefetch action data for performance prefetch_ids = defaultdict(list) for action in action_menus.mapped('action'): prefetch_ids[action._name].append(action.id) + # Check access for each action menu for menu in action_menus: - action = menu.action - action = action.with_prefetch(prefetch_ids[action._name]) + action = menu.action.with_prefetch(prefetch_ids[action._name]) model_name = action._name in MODEL_BY_TYPE and action[MODEL_BY_TYPE[action._name]] - if not model_name or access.check(model_name, 'read', False): - # make menu visible, and its folder ancestors, too - visible += menu - menu = menu.parent_id - while menu and menu in folder_menus and menu not in visible: - visible += menu - menu = menu.parent_id - return set(visible.ids) \ No newline at end of file + if not model_name or access.check(model_name, 'read', False): + # Make menu visible and its folder ancestors + visible += menu + parent = menu.parent_id + while parent and parent in folder_menus and parent not in visible: + visible += parent + parent = parent.parent_id + + return visible \ No newline at end of file diff --git a/addons_extensions/menu_control_center/models/models.py b/addons_extensions/menu_control_center/models/models.py index 4d21b244b..a33ec72a0 100644 --- a/addons_extensions/menu_control_center/models/models.py +++ b/addons_extensions/menu_control_center/models/models.py @@ -28,11 +28,12 @@ class MenuAccessControl(models.Model): _rec_name = 'control_unit' _sql_constraints = [ - ('unique_control_unit', 'UNIQUE(control_unit)', "Only one service can exist with a specific control_unit. Please don't confuse me ðŸĪŠ.") + ('unique_control_unit', 'UNIQUE(control_unit, master_control)', "Only one service can exist with a specific control_unit & Master. Please don't confuse me ðŸĪŠ.") ] control_unit = fields.Many2one('menu.control.units',required=True) user_ids = fields.Many2many('res.users', string="Users", related='control_unit.user_ids') + master_control = fields.Many2one('master.control') access_menu_line_ids = fields.One2many( diff --git a/addons_extensions/menu_control_center/views/groups.xml b/addons_extensions/menu_control_center/views/groups.xml new file mode 100644 index 000000000..a6e2deabe --- /dev/null +++ b/addons_extensions/menu_control_center/views/groups.xml @@ -0,0 +1,19 @@ + + + + + Roles + res.groups + [('is_visible_for_master', '=', True)] + + + + + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/menu_control_center/views/menu_access_control_views.xml b/addons_extensions/menu_control_center/views/menu_access_control_views.xml index 63bc7381c..3460bb2ac 100644 --- a/addons_extensions/menu_control_center/views/menu_access_control_views.xml +++ b/addons_extensions/menu_control_center/views/menu_access_control_views.xml @@ -69,6 +69,7 @@ + @@ -80,6 +81,7 @@ +
diff --git a/addons_extensions/project_task_timesheet_extended/__manifest__.py b/addons_extensions/project_task_timesheet_extended/__manifest__.py index bfc57f567..f9fa63cda 100644 --- a/addons_extensions/project_task_timesheet_extended/__manifest__.py +++ b/addons_extensions/project_task_timesheet_extended/__manifest__.py @@ -32,13 +32,17 @@ Key Features: 'security/security.xml', 'security/ir.model.access.csv', 'data/data.xml', + 'data/project_roles_data.xml', 'wizards/project_user_assign_wizard.xml', + 'wizards/roles_user_assign_wizard.xml', 'wizards/internal_team_members_wizard.xml', 'wizards/project_stage_update_wizard.xml', 'wizards/task_reject_reason_wizard.xml', 'view/teams.xml', + 'view/project_roles_master.xml', 'view/project_stages.xml', 'view/task_stages.xml', + 'view/deployment_log.xml', 'view/project.xml', 'view/project_task.xml', 'view/timesheets.xml', @@ -47,6 +51,9 @@ Key Features: # 'view/project_task_gantt.xml', ], 'assets': { + 'web.assets_backend':{ + 'project_task_timesheet_extended/static/src/css/delopyment.css' + } }, 'installable': True, 'application': False, diff --git a/addons_extensions/project_task_timesheet_extended/data/project_roles_data.xml b/addons_extensions/project_task_timesheet_extended/data/project_roles_data.xml new file mode 100644 index 000000000..900ff3485 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/data/project_roles_data.xml @@ -0,0 +1,111 @@ + + + + + + Admin + administrative + Full system access +Create and manage projects +Configure applications and integrations +Validate submitted work when required +Override approvals when necessary +Manage user access and permissions + 10 + + + + Program Manager + administrative + Oversee multiple projects +Review progress and performance +Coordinate resource distribution +Support project-level decision-making + 11 + + + + + Project Manager + leadership + Lead one or more projects +Approve development, QA, and deployment stages +Manage scope, timeline, budget, and deliverables +Validate final project closure +Coordinate communication with stakeholders + 5 + + + + Project Lead + leadership + Manage technical execution of the project +Validate development output +Review and merge code +Coordinate with QA teams +Support Team Leads in execution + 6 + + + + Team Lead + leadership + Lead module-level or team-level execution +Assign tasks to Developers and QA +Validate development work +Monitor daily progress +Ensure delivery quality and adherence to process + 7 + + + + Financial Manager + leadership + Manage project budgets +Validate financial feasibility +Approve expense-related considerations + 8 + + + + + QA Lead + execution + Oversees all QA activity +Coordinates test cycles +Validates QA results +Ensures quality standards are met + 3 + + + + QA Engineer / Tester + execution + Executes test cases +Logs defects +Validates fixes +Supports QA lead in test management + 4 + + + + Developer + execution + Executes assigned development tasks +Updates task progress and logs timesheets +Submits completed work for approval + 2 + + + + IT + execution + Performs generic tasks +Provides inputs as required + 1 + + + + + + \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/__init__.py b/addons_extensions/project_task_timesheet_extended/models/__init__.py index cb910db8e..671e43486 100644 --- a/addons_extensions/project_task_timesheet_extended/models/__init__.py +++ b/addons_extensions/project_task_timesheet_extended/models/__init__.py @@ -1,4 +1,5 @@ from . import teams +from . import project_roles_master from . import project_sprint from . import task_documents from . import project_architecture_design @@ -8,6 +9,7 @@ from . import project_costings from . import project_code_commit from . import project_stages from . import task_stages +from . import deployment_log from . import project from . import project_task from . import timesheets diff --git a/addons_extensions/project_task_timesheet_extended/models/deployment_log.py b/addons_extensions/project_task_timesheet_extended/models/deployment_log.py new file mode 100644 index 000000000..e666fbe65 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/deployment_log.py @@ -0,0 +1,30 @@ +from odoo import models, fields, api + +class DeploymentLog(models.Model): + _name = 'project.deployment.log' + _description = "Project Deployment Log" + _order = 'deployment_date desc' + + project_id = fields.Many2one( + 'project.project', + string="Project", + required=True, + ondelete="cascade" + ) + + deployment_date = fields.Datetime(string="Deployment Date", required=True) + + deployment_ready = fields.Boolean(string="Deployment Ready?") + qa_signoff = fields.Boolean(string="QA Signoff") + client_signoff = fields.Boolean(string="Client Signoff") + backup_completed = fields.Boolean(string="Backup Completed?") + + deployment_version = fields.Char(string="Version") + deployed_by = fields.Char(string="Deployed By") + + deployment_notes = fields.Text(string="Notes") + + deployment_files_ids = fields.Many2many( + 'ir.attachment', + string="Deployment Files" + ) diff --git a/addons_extensions/project_task_timesheet_extended/models/project.py b/addons_extensions/project_task_timesheet_extended/models/project.py index 9d7b1a893..4b6381ad1 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project.py +++ b/addons_extensions/project_task_timesheet_extended/models/project.py @@ -1,6 +1,8 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError - +from markupsafe import Markup +from datetime import datetime, timedelta +import pytz class ProjectProject(models.Model): _inherit = 'project.project' @@ -25,6 +27,634 @@ class ProjectProject(models.Model): string="Default Projects Channel" ) + + project_stages = fields.One2many('project.stages.approval.flow', 'project_id') + assign_approval_flow = fields.Boolean(default=False) + project_sponsor = fields.Many2one('res.users') + show_project_chatter = fields.Boolean(default=False) + project_vision = fields.Text( + string="Project Vision", + help="Concise statement describing the project's ultimate goal and purpose" + ) + + # Requirement Documentation + description = fields.Html("Requirement Description") + requirement_file = fields.Binary("Requirement Document") + requirement_file_name = fields.Char("Requirement File Name") + + # Feasibility Assessment + feasibility_html = fields.Html("Feasibility Assessment") + feasibility_file = fields.Binary("Feasibility Document") + feasibility_file_name = fields.Char("Feasibility File Name") + + manager_level_edit_access = fields.Boolean(compute="_compute_has_manager_level_edit_access") + approval_status = fields.Selection([ + ('submitted', 'Submitted'), + ('reject', 'Rejected') + ]) + show_submission_button = fields.Boolean(compute="_compute_access_check") + show_approval_button = fields.Boolean(compute="_compute_access_check") + show_refuse_button = fields.Boolean(compute="_compute_access_check") + show_back_button = fields.Boolean(compute="_compute_access_check") + project_activity_log = fields.Html(string="Project Activity Log") + project_scope = fields.Html(string="Scope", default=lambda self: """ +

Scope Description



+

1. In Scope Items?


+

2. Out Scope Items?


+ """) + risk_ids = fields.One2many( + "project.risk", + "project_id", + string="Project Risks" + ) + # stage_id = fields.Many2one(domain="[('id','in',showable_stage_ids or [])]") + + showable_stage_ids = fields.Many2many('project.project.stage',compute='_compute_project_project_stages') + + # fields: + estimated_amount = fields.Float(string="Estimated Amount") + total_budget_amount = fields.Float(string="Total Budget Amount", compute="_compute_total_budget", store=True) + + # Manpower + resource_cost_ids = fields.One2many( + "project.resource.cost", + "project_id", + string="Resource Costs" + ) + + # Material + material_cost_ids = fields.One2many( + "project.material.cost", + "project_id", + string="Material Costs" + ) + + # Equipment + equipment_cost_ids = fields.One2many( + "project.equipment.cost", + "project_id", + string="Equipment Costs" + ) + + architecture_design_ids = fields.One2many( + "project.architecture.design", + "project_id", + string="Architecture & Design" + ) + + require_sprint = fields.Boolean( + string="Require Sprints?", + default=False, + help="Enable sprint-based planning for this project." + ) + + sprint_ids = fields.One2many( + "project.sprint", + "project_id", + string="Project Sprints" + ) + + commit_step_ids = fields.One2many( + 'project.commit.step', + 'project_id', + string="Commit Steps" + ) + + development_document_ids = fields.One2many( + "task.development.document", + "project_id", + string="Development Documents" + ) + + testing_document_ids = fields.One2many( + "task.testing.document", + "project_id", + string="Testing Documents" + ) + development_notes = fields.Html() + testing_notes = fields.Html() + deployment_log_ids = fields.One2many( + 'project.deployment.log', + 'project_id', + string="Deployment Logs" + ) + + @api.depends('require_sprint','project_stages','assign_approval_flow') + def _compute_project_project_stages(self): + for rec in self: + project_stages = self.env['project.project.stage'].sudo().search([]) + if rec.assign_approval_flow: + project_stages = rec.project_stages.filtered(lambda x: x.activate).stage_id + self.env.ref("project_task_timesheet_extended.project_project_stage_sprint_planning") + + stage_ids = self.env['project.project.stage'].sudo().search([('id','in',project_stages.ids),('active', '=', True), ('company_id','in',[self.env.company.id,False])]).ids + + if not rec.require_sprint: + stage_ids = self.env['project.project.stage'].sudo().search([ + ('id', 'in', project_stages.ids), + ('active', '=', True),('company_id','in',[self.env.company.id,False]), ('id', '!=', self.env.ref( + "project_task_timesheet_extended.project_project_stage_sprint_planning").id) + ]).ids + + rec.showable_stage_ids = stage_ids + + @api.depends("resource_cost_ids.total_cost", "material_cost_ids.total_cost", "equipment_cost_ids.total_cost") + def _compute_total_budget(self): + for project in self: + project.total_budget_amount = ( + sum(project.resource_cost_ids.mapped("total_cost")) + + sum(project.material_cost_ids.mapped("total_cost")) + + sum(project.equipment_cost_ids.mapped("total_cost")) + ) + + def fetch_project_task_stage_users(self): + for project in self: + users_list = list() + if project.assign_approval_flow: + users_list.extend(project.project_stages.involved_users.ids) + else: + users_list.extend(project.showable_stage_ids.user_ids.ids) + + if project.project_sponsor: + users_list.append(project.project_sponsor.id) + if project.user_id: + users_list.append(project.user_id.id) + if project.project_lead: + users_list.append(project.project_lead.id) + if project.type_ids: + for task_stage in project.type_ids: + if task_stage.team_id: + users_list.append(task_stage.team_id.team_lead.id) + if task_stage.involved_user_ids: + users_list.extend(task_stage.involved_user_ids.ids) + users_list = list(set(users_list)) + base_users = project.members_ids.ids + removed_ids = set(base_users).difference(users_list) + newly_added_ids = set(users_list).difference(base_users) + project.update({ + "members_ids": [(6, 0, users_list)], + "message_partner_ids": [(4, user_id.partner_id.id) for user_id in + self.env['res.users'].sudo().search([('id', 'in', list(newly_added_ids))])], + }) + project.update({ + "message_partner_ids": [(3, user_id.partner_id.id) for user_id in + self.env['res.users'].sudo().search([('id', 'in', list(removed_ids))])], + }) + + + # -------------------------------------------------------- + # Fetch Resource Data Button + # -------------------------------------------------------- + def action_fetch_resource_data(self): + """Fetch all members' employee records and create manpower cost lines with full auto-fill.""" + for project in self: + + # Project users = members + project manager + project lead + users = project.members_ids | project.user_id | project.project_lead + + # Fetch employees linked to those users + employees = self.env["hr.employee"].search([("user_id", "in", users.ids)]) + + for emp in employees: + + # Avoid duplicate manpower lines + existing = project.resource_cost_ids.filtered(lambda r: r.employee_id == emp) + if existing: + continue + + # Get active contract for salary details + contract = emp.contract_id + + monthly_salary = contract.wage if contract else 0.0 + daily_rate = (contract.wage / 30) if contract and contract.wage else 0.0 + + # Project Dates + start_date = project.date_start or fields.Date.today() + end_date = project.date or start_date + + # Duration + duration = (end_date - start_date).days + 1 if start_date and end_date else 0 + + # Total Cost + total_cost = daily_rate * duration if daily_rate else 0 + + # Create manpower line + self.env["project.resource.cost"].create({ + "project_id": project.id, + "employee_id": emp.id, + "monthly_salary": monthly_salary, + "daily_rate": daily_rate, + "start_date": start_date, + "end_date": end_date, + "duration_days": duration, + "total_cost": total_cost, + }) + + @api.depends("project_stages", "stage_id", "approval_status") + def _compute_access_check(self): + """Compute visibility of action buttons based on user permissions and project state""" + for project in self: + project.show_submission_button = False + project.show_approval_button = False + project.show_refuse_button = False + project.show_back_button = False + + if not project.assign_approval_flow: + continue + + user = self.env.user + project_manager = project.user_id + project_sponsor = project.project_sponsor + + # Current approval timeline for this stage + current_approval_timeline = project.project_stages.filtered( + lambda s: s.stage_id == project.stage_id + ) + + # Compute button visibility based on approval flow + if current_approval_timeline: + line = current_approval_timeline[0] + assigned_to = line.assigned_to + responsible_lead = line.approval_by + + # Submission button for assigned users + if (assigned_to == user and + project.approval_status != "submitted" and + assigned_to != responsible_lead): + project.show_submission_button = True + + # Approval/refusal buttons for responsible leads + if (project.approval_status == "submitted" and + responsible_lead == user): + project.show_approval_button = True + project.show_refuse_button = True + + # Direct approval when no assigned user + if not assigned_to and responsible_lead == user: + project.show_approval_button = True + + # Direct approval when assigned user is also responsible + if (assigned_to == responsible_lead and + user == assigned_to): + project.show_approval_button = True + else: + # Managers can approve without specific flow + if user.has_group("project.group_project_manager"): + project.show_approval_button = True + + # Managers get additional permissions + if user in [project_manager] or user.has_group("project.group_project_manager"): + project.show_back_button = True + if user.has_group("project.group_project_manager"): + project.show_approval_button = True + + # Stage-specific button visibility + if project.stage_id: + stages = self.env['project.project.stage'].search([('id','in',project.showable_stage_ids.ids), + ('id', '!=', self.env.ref("project.project_project_stage_3").id) + ]) + if stages: + first_stage = stages.sorted('sequence')[0] + last_stage = stages.sorted('sequence')[-1] + + if project.stage_id == first_stage: + project.show_back_button = False + if project.stage_id == last_stage: + project.show_submission_button = False + project.show_approval_button = False + project.show_refuse_button = False + + @api.depends("user_id") + def _compute_has_manager_level_edit_access(self): + """Determine if current user has manager-level edit permissions""" + for rec in self: + rec.manager_level_edit_access = ( + rec.user_id == self.env.user or + self.env.user.has_group("project.group_project_manager") + ) + + def action_show_project_chatter(self): + """Toggle visibility of project chatter""" + for project in self: + project.show_project_chatter = not project.show_project_chatter + + def action_assign_approval_flow(self): + """Configure approval flow for project stages""" + for project in self: + if not project.project_sponsor or not project.user_id: + raise ValidationError(_("Sponsor and Manager are required to assign Stage Approvals")) + + project.assign_approval_flow = not project.assign_approval_flow + + if project.assign_approval_flow: + # Clear existing records + project.project_stages.unlink() + + # Fetch all project stages + stages = self.env['project.project.stage'].sudo().search([('active', '=', True), + ('company_id', 'in', [self.env.company.id, False])]) + + for stage in stages: + # Determine approval authority based on stage configuration + approval_by = ( + project.user_id.id if stage.sudo().approval_by == 'project_manager' else + project.project_sponsor.id if stage.sudo().approval_by == 'project_sponsor' else + False + ) + assigned_to = (approval_by if approval_by in stage.user_ids.ids else + min(stage.user_ids, key=lambda u: u.id).id if stage.user_ids else False + ) + + self.env['project.stages.approval.flow'].sudo().create({ + 'project_id': project.id, + 'stage_id': stage.id, + 'approval_by': approval_by, + 'assigned_to': assigned_to, + 'involved_users': [(6, 0, stage.user_ids.ids)], + 'assigned_date': fields.Datetime.now() if stage == project.stage_id else False, + 'submission_date': False, + }) + + # Log approval flow assignment + self.sudo()._add_activity_log("Approval flow assigned by %s" % self.env.user.name) + self.sudo()._post_to_project_channel( + _("Approval flow configured for project %s") % project.name + ) + else: + project.sudo().project_stages.unlink() + self.sudo()._add_activity_log("Approval flow removed by %s" % self.env.user.name) + self.sudo()._post_to_project_channel( + _("Approval flow removed for project %s") % project.name + ) + + def submit_project_for_approval(self): + """Submit project for current stage approval""" + for project in self: + project.sudo().approval_status = "submitted" + current_stage = project.sudo().stage_id + current_approval_timeline = project.sudo().project_stages.filtered( + lambda s: s.stage_id == project.sudo().stage_id + ) + + if current_approval_timeline: + current_approval_timeline.sudo().submission_date = fields.Datetime.now() + + stage_line = project.sudo().project_stages.filtered(lambda s: s.stage_id == current_stage) + responsible_user = stage_line.sudo().approval_by if stage_line else False + + # Create activity log + activity_log = "%s : %s submitted for approval to %s" % ( + current_stage.sudo().name, + self.env.user.name, + responsible_user.sudo().name if responsible_user else "N/A" + ) + project.sudo()._add_activity_log(activity_log) + + # Post to project channel + if responsible_user: + channel_message = _("Project %s submitted for approval at stage %s. %s please review.") % ( + project.sudo().name, + current_stage.sudo().name, + project.sudo()._create_odoo_mention(responsible_user.partner_id) + ) + else: + channel_message = _("Project %s submitted for approval at stage %s") % ( + project.sudo().name, + current_stage.sudo().name + ) + project.sudo()._post_to_project_channel(channel_message) + + # Send notification + if responsible_user: + project.sudo().message_post( + body=activity_log, + partner_ids=[responsible_user.sudo().partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment' + ) + + def project_proceed_further(self): + """Advance project to next stage after approval""" + for project in self: + current_stage = project.stage_id + next_stage = self.env["project.project.stage"].search([ + ('sequence', '>', project.stage_id.sequence), + ('id', '!=', self.env.ref("project.project_project_stage_3").id), + ('id', 'in', project.showable_stage_ids.ids), + ], order="sequence asc", limit=1) + + current_approval_timeline = project.project_stages.filtered( + lambda s: s.stage_id == project.stage_id + ) + + if current_approval_timeline: + current_approval_timeline.submission_date = fields.Datetime.now() + if not current_approval_timeline.assigned_date: + current_approval_timeline.assigned_date = fields.Datetime.now() + + if next_stage: + next_approval_timeline = project.project_stages.filtered( + lambda s: s.stage_id == next_stage + ) + if next_approval_timeline and not next_approval_timeline.assigned_date: + next_approval_timeline.assigned_date = fields.Datetime.now() + + project.stage_id = next_stage + project.approval_status = "" + + # Create activity log + activity_log = "%s approved by %s → moved to %s" % ( + current_stage.name, + self.env.user.name, + next_stage.name + ) + project._add_activity_log(activity_log) + + # Post to project channel + next_user = next_approval_timeline.assigned_to if next_approval_timeline else False + if next_user: + channel_message = _("Project %s approved at stage %s and moved to %s. %s please proceed.") % ( + project.name, + current_stage.name, + next_stage.name, + project._create_odoo_mention(next_user.partner_id) + ) + else: + channel_message = _("Project %s approved at stage %s and moved to %s") % ( + project.name, + current_stage.name, + next_stage.name + ) + project._post_to_project_channel(channel_message) + + # Send notification + if next_user: + project.message_post( + body=activity_log, + partner_ids=[next_user.partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment' + ) + else: + # Last stage completed + project.approval_status = "" + activity_log = "%s fully approved and completed" % project.name + project._add_activity_log(activity_log) + project._post_to_project_channel( + _("Project %s completed and fully approved") % project.name + ) + project.message_post(body=activity_log) + + def reject_and_return(self, reason=None): + """Reject project at current stage with optional reason""" + for project in self: + reason = reason or "" + current_stage = project.stage_id + project.approval_status = "reject" + + # Create activity log + activity_log = "%s rejected by %s — %s" % ( + current_stage.name, + self.env.user.name, + reason + ) + project._add_activity_log(activity_log) + + # Update approval timeline + current_approval_timeline = project.project_stages.filtered( + lambda s: s.stage_id == project.stage_id + ) + if current_approval_timeline: + current_approval_timeline.note = f"Reject Reason: {reason}" + + # Post to project channel + channel_message = _("Project %s rejected at stage %s. Reason: %s") % ( + project.name, + current_stage.name, + reason + ) + project._post_to_project_channel(channel_message) + + # Send notification + project.message_post(body=activity_log) + + # Notify responsible users + if current_approval_timeline: + responsible_user = ( + current_approval_timeline.assigned_to or + current_approval_timeline.approval_by + ) + if responsible_user: + project.message_post( + body=_("Project %s has been rejected and returned to you") % project.name, + partner_ids=[responsible_user.partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment' + ) + + def project_back_button(self): + """Revert project to previous stage""" + for project in self: + prev_stage = self.env["project.project.stage"].search([ + ('sequence', '<', project.stage_id.sequence), + ('id', 'in', project.showable_stage_ids.ids) + ], order="sequence desc", limit=1) + + if not prev_stage: + raise ValidationError(_("No previous stage available.")) + + # Create activity log + activity_log = "%s reverted back to %s by %s" % ( + project.stage_id.name, + prev_stage.name, + self.env.user.name + ) + project._add_activity_log(activity_log) + + # Post to project channel + channel_message = _("Project %s reverted from %s back to %s") % ( + project.name, + project.stage_id.name, + prev_stage.name + ) + project._post_to_project_channel(channel_message) + + # Update stage + project.stage_id = prev_stage + project.message_post(body=activity_log) + + def action_open_reject_wizard(self): + """Open rejection wizard for projects""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Reject Project"), + "res_model": "project.reject.reason.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_project_id": self.id}, + } + + # Activity Log Helper Methods + def _get_current_datetime_formatted(self): + """Get current datetime in 'DD-MMM-YYYY HH:MM AM/PM' format""" + now = fields.Datetime.context_timestamp(self, fields.datetime.now()) + formatted_date = now.strftime('%d-%b-%Y %I:%M %p').upper() + return formatted_date[1:] if formatted_date.startswith('0') else formatted_date + + def _add_activity_log(self, activity_text): + """Add formatted entry to project activity log""" + formatted_datetime = self._get_current_datetime_formatted() + for project in self: + log_entry = f"[{formatted_datetime}] {activity_text}" + if project.project_activity_log: + project.project_activity_log = Markup(project.project_activity_log) + Markup('
') + Markup(log_entry) + else: + project.project_activity_log = Markup(log_entry) + + def _post_to_project_channel(self, message_body, mention_partners=None): + """Post message to project's discuss channel with proper mentions""" + for project in self: + if not project.id: + continue + + # Get project channel + channel = ( + project.discuss_channel_id or + project.default_projects_channel_id + ) + + if channel: + formatted_message = self._format_message_with_odoo_mentions( + message_body, + mention_partners + ) + channel.message_post( + body=Markup(formatted_message), + message_type='comment', + subtype_xmlid='mail.mt_comment', + author_id=self.env.user.partner_id.id + ) + + def _format_message_with_odoo_mentions(self, message_body, mention_partners=None): + """Format message with proper Odoo @mentions""" + if not mention_partners: + return f'
{message_body}
' + + message_parts = ['
', message_body] + for partner in mention_partners: + if partner and partner.name: + mention_html = f'@{partner.name}' + message_parts.append(mention_html) + message_parts.append('
') + return ' '.join(message_parts) + + def _create_odoo_mention(self, partner): + """Create Odoo mention link for a partner""" + if not partner: + return "" + return f'@{partner.name}' + + @api.model def _get_default_projects_channel(self): """Get or create the default Projects Channel""" @@ -148,14 +778,16 @@ class ProjectProject(models.Model): # self.env.ref('project_task_timesheet_extended.task_type_hold').id, return self.env['project.task.type'].browse(default_stage_ids) - project_lead = fields.Many2one("res.users", string="Project Lead") + + project_lead = fields.Many2one("res.users", string="Project Lead", + domain=lambda self: [('id','in',self.env.ref('project_task_timesheet_extended.role_project_lead').user_ids.ids)]) members_ids = fields.Many2many('res.users', 'project_user_rel', 'project_id', 'user_id', 'Project Members', help="""Project's members are users who can have an access to the tasks related to this project.""" ) user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user, tracking=True, - domain=lambda self: [('groups_id', 'in', [self.env.ref('project.group_project_manager').id,self.env.ref('project_task_timesheet_extended.group_project_supervisor').id]),('share','=',False)],) + domain=lambda self: [('id','in',self.env.ref('project_task_timesheet_extended.role_project_manager').user_ids.ids),('groups_id', 'in', [self.env.ref('project.group_project_manager').id,self.env.ref('project_task_timesheet_extended.group_project_supervisor').id]),('share','=',False)],) type_ids = fields.Many2many(default=lambda self: self._default_type_ids()) @@ -186,3 +818,69 @@ class ProjectProject(models.Model): }, } + + +class ProjectTask(models.Model): + _inherit = 'project.task' + + + def _default_sprint_id(self): + """Return the current active (in-progress) sprint of the project.""" + if 'project_id' in self._context: + project_id = self._context.get('project_id') + sprint = self.env['project.sprint'].search([ + ('project_id', '=', project_id), + ('status', '=', 'in_progress') + ], limit=1) + return sprint.id + return False + + sprint_id = fields.Many2one( + "project.sprint", + string="Sprint", + default=_default_sprint_id + ) + + require_sprint = fields.Boolean( + related="project_id.require_sprint", + store=False + ) + + commit_step_ids = fields.One2many( + 'project.commit.step', + 'task_id', + string="Commit Steps" + ) + + show_task_chatter = fields.Boolean(default=False) + + development_document_ids = fields.One2many( + "task.development.document", + "task_id", + string="Development Documents" + ) + + testing_document_ids = fields.One2many( + "task.testing.document", + "task_id", + string="Testing Documents" + ) + + @api.onchange("project_id") + def _onchange_project_id_sprint_required(self): + for task in self: + if task.project_id and not task.project_id.require_sprint: + task.sprint_id = False + else: + if task.project_id and task.project_id.require_sprint: + sprint = self.env['project.sprint'].search([ + ('project_id', '=', task.project_id.id), + ('status', '=', 'in_progress') + ], limit=1) + task.sprint_id = sprint.id + + + def action_show_project_task_chatter(self): + """Toggle visibility of project chatter""" + for project in self: + project.show_task_chatter = not project.show_task_chatter \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py b/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py index 96a4b9b95..875644f03 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py +++ b/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py @@ -25,8 +25,8 @@ class ProjectResourceCost(models.Model): """Auto-fill start_date and end_date from project.""" if self.project_id: # If project has dates → use them - self.start_date = self.project_id.start_date or date.today() - self.end_date = self.project_id.end_date or self.start_date + self.start_date = self.project_id.date_start or date.today() + self.end_date = self.project_id.date or self.start_date # ------------------------------------------------------------- # 2. If employee selected → load salary diff --git a/addons_extensions/project_task_timesheet_extended/models/project_roles_master.py b/addons_extensions/project_task_timesheet_extended/models/project_roles_master.py new file mode 100644 index 000000000..00f6ae093 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_roles_master.py @@ -0,0 +1,99 @@ +from odoo import fields, models + + +class ProjectRole(models.Model): + _name = 'project.role' + _description = 'Project Role Management' + _order = 'role_level, name' + + ROLE_LEVELS = [ + ('administrative', 'Administrative'), + ('leadership', 'Project Leadership'), + ('execution', 'Execution'), + ] + + name = fields.Char( + string='Role Name', + required=True, + help="Name of the project role" + ) + description = fields.Text( + string='Description', + help="Detailed description of the role responsibilities" + ) + user_ids = fields.Many2many( + 'res.users', + 'project_role_user_rel', + 'role_id', + 'user_id', + string='Assigned Users', + help="Users assigned to this role" + ) + active = fields.Boolean( + string='Active', + default=True, + help="If unchecked, the role will be hidden but not deleted" + ) + color = fields.Integer( + string='Color Index', + default=0, + help="Color index for kanban view" + ) + role_level = fields.Selection( + ROLE_LEVELS, + string='Authority Level', + required=True, + help="Structured authority level of the role" + ) + + def action_update_users(self): + return { + 'type': 'ir.actions.act_window', + 'name': 'Add Users', + 'res_model': 'roles.user.assign.wizard', + 'view_mode': 'form', + 'view_id': self.env.ref('project_task_timesheet_extended.roles_user_assignment_form_view').id, + 'target': 'new', + 'context': {'default_members_ids': [(6, 0, self.user_ids.ids)], + }, + } + def action_view_users(self): + """Open users assigned to this role""" + self.ensure_one() + action = { + 'name': f'Users in {self.name} Role', + 'type': 'ir.actions.act_window', + 'res_model': 'res.users', + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.user_ids.ids)], + 'context': {'default_role_id': self.id}, + } + if len(self.user_ids) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': self.user_ids.id, + }) + return action + +class ProjectStage(models.Model): + _inherit = 'project.project.stage' + + role_ids = fields.Many2many( + 'project.role', + 'project_stage_role_rel', + 'stage_id', + 'role_id', + string='Default Access By Roles', + help="Roles assigned to this project stage" + ) + user_ids = fields.Many2many( + 'res.users', + compute='_compute_user_ids', + string='Related Role Users', + help="Users assigned to the roles of this stage" + ) + + def _compute_user_ids(self): + """Compute users from assigned roles""" + for stage in self: + stage.user_ids = stage.role_ids.mapped('user_ids') \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/project_stages.py b/addons_extensions/project_task_timesheet_extended/models/project_stages.py index 9ec1f4eb9..8545094b2 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project_stages.py +++ b/addons_extensions/project_task_timesheet_extended/models/project_stages.py @@ -17,13 +17,17 @@ class projectStagesApprovalFlow(models.Model): stage_id = fields.Many2one('project.project.stage') stage_approval_by = fields.Selection(related='stage_id.approval_by') approval_by = fields.Many2one("res.users", domain="[('id','in',approval_by_users)]") - assigned_to = fields.Many2one("res.users") + assigned_to = fields.Many2one("res.users", domain="[('id','in',related_stage_users)]") + related_stage_users = fields.Many2many("res.users",related="stage_id.user_ids") assigned_date = fields.Datetime() submission_date = fields.Datetime() project_id = fields.Many2one('project.project') approval_by_users = fields.Many2many('res.users', compute="_compute_all_project_managers") note = fields.Text() manager_level_edit_access = fields.Boolean(compute="_compute_manager_level_edit_access") + activate = fields.Boolean(default=True) + involved_users = fields.Many2many('res.users', 'project_stage_approval_user_rel', 'project_stage_approval_id', + 'user_id',string="Related Users",domain="[('id','in',related_stage_users)]") def _compute_manager_level_edit_access(self): for rec in self: @@ -50,647 +54,3 @@ class projectStagesApprovalFlow(models.Model): rec.approval_by_users = [(6, 0, pm_users)] - -class ProjectProject(models.Model): - _inherit = 'project.project' - - project_stages = fields.One2many('project.stages.approval.flow', 'project_id') - assign_approval_flow = fields.Boolean(default=False) - project_sponsor = fields.Many2one('res.users') - show_project_chatter = fields.Boolean(default=False) - project_vision = fields.Text( - string="Project Vision", - help="Concise statement describing the project's ultimate goal and purpose" - ) - - # Requirement Documentation - description = fields.Html("Requirement Description") - requirement_file = fields.Binary("Requirement Document") - requirement_file_name = fields.Char("Requirement File Name") - - # Feasibility Assessment - feasibility_html = fields.Html("Feasibility Assessment") - feasibility_file = fields.Binary("Feasibility Document") - feasibility_file_name = fields.Char("Feasibility File Name") - - manager_level_edit_access = fields.Boolean(compute="_compute_has_manager_level_edit_access") - approval_status = fields.Selection([ - ('submitted', 'Submitted'), - ('reject', 'Rejected') - ]) - show_submission_button = fields.Boolean(compute="_compute_access_check") - show_approval_button = fields.Boolean(compute="_compute_access_check") - show_refuse_button = fields.Boolean(compute="_compute_access_check") - show_back_button = fields.Boolean(compute="_compute_access_check") - project_activity_log = fields.Html(string="Project Activity Log") - project_scope = fields.Html(string="Scope", default=lambda self: """ -

Scope Description



-

1. In Scope Items?


-

2. Out Scope Items?


- """) - risk_ids = fields.One2many( - "project.risk", - "project_id", - string="Project Risks" - ) - # stage_id = fields.Many2one(domain="[('id','in',showable_stage_ids or [])]") - - showable_stage_ids = fields.Many2many('project.project.stage',compute='_compute_project_project_stages') - - # fields: - estimated_amount = fields.Float(string="Estimated Amount") - total_budget_amount = fields.Float(string="Total Budget Amount", compute="_compute_total_budget", store=True) - - # Manpower - resource_cost_ids = fields.One2many( - "project.resource.cost", - "project_id", - string="Resource Costs" - ) - - # Material - material_cost_ids = fields.One2many( - "project.material.cost", - "project_id", - string="Material Costs" - ) - - # Equipment - equipment_cost_ids = fields.One2many( - "project.equipment.cost", - "project_id", - string="Equipment Costs" - ) - - architecture_design_ids = fields.One2many( - "project.architecture.design", - "project_id", - string="Architecture & Design" - ) - - require_sprint = fields.Boolean( - string="Require Sprints?", - default=False, - help="Enable sprint-based planning for this project." - ) - - sprint_ids = fields.One2many( - "project.sprint", - "project_id", - string="Project Sprints" - ) - - commit_step_ids = fields.One2many( - 'project.commit.step', - 'project_id', - string="Commit Steps" - ) - - development_document_ids = fields.One2many( - "task.development.document", - "project_id", - string="Development Documents" - ) - - testing_document_ids = fields.One2many( - "task.testing.document", - "project_id", - string="Testing Documents" - ) - development_notes = fields.Html() - testing_notes = fields.Html() - - @api.depends('require_sprint') - def _compute_project_project_stages(self): - for rec in self: - stage_ids = self.env['project.project.stage'].sudo().search([('active', '=', True), ('company_id','in',[self.env.company.id,False])]).ids - - if not rec.require_sprint: - stage_ids = self.env['project.project.stage'].sudo().search([ - ('active', '=', True),('company_id','in',[self.env.company.id,False]), ('id', '!=', self.env.ref( - "project_task_timesheet_extended.project_project_stage_sprint_planning").id) - ]).ids - rec.showable_stage_ids = stage_ids - - @api.depends("resource_cost_ids.total_cost", "material_cost_ids.total_cost", "equipment_cost_ids.total_cost") - def _compute_total_budget(self): - for project in self: - project.total_budget_amount = ( - sum(project.resource_cost_ids.mapped("total_cost")) - + sum(project.material_cost_ids.mapped("total_cost")) - + sum(project.equipment_cost_ids.mapped("total_cost")) - ) - - # -------------------------------------------------------- - # Fetch Resource Data Button - # -------------------------------------------------------- - def action_fetch_resource_data(self): - """Fetch all members' employee records and create manpower cost lines with full auto-fill.""" - for project in self: - - # Project users = members + project manager + project lead - users = project.members_ids | project.user_id | project.project_lead - - # Fetch employees linked to those users - employees = self.env["hr.employee"].search([("user_id", "in", users.ids)]) - - for emp in employees: - - # Avoid duplicate manpower lines - existing = project.resource_cost_ids.filtered(lambda r: r.employee_id == emp) - if existing: - continue - - # Get active contract for salary details - contract = emp.contract_id - - monthly_salary = contract.wage if contract else 0.0 - daily_rate = (contract.wage / 30) if contract and contract.wage else 0.0 - - # Project Dates - start_date = project.date_start or fields.Date.today() - end_date = project.date or start_date - - # Duration - duration = (end_date - start_date).days + 1 if start_date and end_date else 0 - - # Total Cost - total_cost = daily_rate * duration if daily_rate else 0 - - # Create manpower line - self.env["project.resource.cost"].create({ - "project_id": project.id, - "employee_id": emp.id, - "monthly_salary": monthly_salary, - "daily_rate": daily_rate, - "start_date": start_date, - "end_date": end_date, - "duration_days": duration, - "total_cost": total_cost, - }) - - @api.depends("project_stages", "stage_id", "approval_status") - def _compute_access_check(self): - """Compute visibility of action buttons based on user permissions and project state""" - for project in self: - project.show_submission_button = False - project.show_approval_button = False - project.show_refuse_button = False - project.show_back_button = False - - if not project.assign_approval_flow: - continue - - user = self.env.user - project_manager = project.user_id - project_sponsor = project.project_sponsor - - # Current approval timeline for this stage - current_approval_timeline = project.project_stages.filtered( - lambda s: s.stage_id == project.stage_id - ) - - # Compute button visibility based on approval flow - if current_approval_timeline: - line = current_approval_timeline[0] - assigned_to = line.assigned_to - responsible_lead = line.approval_by - - # Submission button for assigned users - if (assigned_to == user and - project.approval_status != "submitted" and - assigned_to != responsible_lead): - project.show_submission_button = True - - # Approval/refusal buttons for responsible leads - if (project.approval_status == "submitted" and - responsible_lead == user): - project.show_approval_button = True - project.show_refuse_button = True - - # Direct approval when no assigned user - if not assigned_to and responsible_lead == user: - project.show_approval_button = True - - # Direct approval when assigned user is also responsible - if (assigned_to == responsible_lead and - user == assigned_to): - project.show_approval_button = True - else: - # Managers can approve without specific flow - if user.has_group("project.group_project_manager"): - project.show_approval_button = True - - # Managers get additional permissions - if user in [project_manager] or user.has_group("project.group_project_manager"): - project.show_back_button = True - if user.has_group("project.group_project_manager"): - project.show_approval_button = True - - # Stage-specific button visibility - if project.stage_id: - stages = self.env['project.project.stage'].search([('id','in',project.showable_stage_ids.ids), - ('id', '!=', self.env.ref("project.project_project_stage_3").id) - ]) - if stages: - first_stage = stages.sorted('sequence')[0] - last_stage = stages.sorted('sequence')[-1] - - if project.stage_id == first_stage: - project.show_back_button = False - if project.stage_id == last_stage: - project.show_submission_button = False - project.show_approval_button = False - project.show_refuse_button = False - - @api.depends("user_id") - def _compute_has_manager_level_edit_access(self): - """Determine if current user has manager-level edit permissions""" - for rec in self: - rec.manager_level_edit_access = ( - rec.user_id == self.env.user or - self.env.user.has_group("project.group_project_manager") - ) - - def action_show_project_chatter(self): - """Toggle visibility of project chatter""" - for project in self: - project.show_project_chatter = not project.show_project_chatter - - def action_assign_approval_flow(self): - """Configure approval flow for project stages""" - for project in self: - if not project.project_sponsor or not project.user_id: - raise ValidationError(_("Sponsor and Manager are required to assign Stage Approvals")) - - project.assign_approval_flow = not project.assign_approval_flow - - if project.assign_approval_flow: - # Clear existing records - project.project_stages.unlink() - - # Fetch all project stages - stages = self.env['project.project.stage'].sudo().search([('id','in',project.showable_stage_ids.ids)]) - - for stage in stages: - # Determine approval authority based on stage configuration - approval_by = ( - project.user_id.id if stage.sudo().approval_by == 'project_manager' else - project.project_sponsor.id if stage.sudo().approval_by == 'project_sponsor' else - False - ) - - self.env['project.stages.approval.flow'].sudo().create({ - 'project_id': project.id, - 'stage_id': stage.id, - 'approval_by': approval_by, - 'assigned_to': approval_by, - 'assigned_date': fields.Datetime.now() if stage == project.stage_id else False, - 'submission_date': False, - }) - - # Log approval flow assignment - self.sudo()._add_activity_log("Approval flow assigned by %s" % self.env.user.name) - self.sudo()._post_to_project_channel( - _("Approval flow configured for project %s") % project.name - ) - else: - project.sudo().project_stages.unlink() - self.sudo()._add_activity_log("Approval flow removed by %s" % self.env.user.name) - self.sudo()._post_to_project_channel( - _("Approval flow removed for project %s") % project.name - ) - - def submit_project_for_approval(self): - """Submit project for current stage approval""" - for project in self: - project.sudo().approval_status = "submitted" - current_stage = project.sudo().stage_id - current_approval_timeline = project.sudo().project_stages.filtered( - lambda s: s.stage_id == project.sudo().stage_id - ) - - if current_approval_timeline: - current_approval_timeline.sudo().submission_date = fields.Datetime.now() - - stage_line = project.sudo().project_stages.filtered(lambda s: s.stage_id == current_stage) - responsible_user = stage_line.sudo().approval_by if stage_line else False - - # Create activity log - activity_log = "%s : %s submitted for approval to %s" % ( - current_stage.sudo().name, - self.env.user.name, - responsible_user.sudo().name if responsible_user else "N/A" - ) - project.sudo()._add_activity_log(activity_log) - - # Post to project channel - if responsible_user: - channel_message = _("Project %s submitted for approval at stage %s. %s please review.") % ( - project.sudo().name, - current_stage.sudo().name, - project.sudo()._create_odoo_mention(responsible_user.partner_id) - ) - else: - channel_message = _("Project %s submitted for approval at stage %s") % ( - project.sudo().name, - current_stage.sudo().name - ) - project.sudo()._post_to_project_channel(channel_message) - - # Send notification - if responsible_user: - project.sudo().message_post( - body=activity_log, - partner_ids=[responsible_user.sudo().partner_id.id], - message_type='notification', - subtype_xmlid='mail.mt_comment' - ) - - def project_proceed_further(self): - """Advance project to next stage after approval""" - for project in self: - current_stage = project.stage_id - next_stage = self.env["project.project.stage"].search([ - ('sequence', '>', project.stage_id.sequence), - ('id', '!=', self.env.ref("project.project_project_stage_3").id), - ('id', 'in', project.showable_stage_ids.ids), - ], order="sequence asc", limit=1) - - current_approval_timeline = project.project_stages.filtered( - lambda s: s.stage_id == project.stage_id - ) - - if current_approval_timeline: - current_approval_timeline.submission_date = fields.Datetime.now() - if not current_approval_timeline.assigned_date: - current_approval_timeline.assigned_date = fields.Datetime.now() - - if next_stage: - next_approval_timeline = project.project_stages.filtered( - lambda s: s.stage_id == next_stage - ) - if next_approval_timeline and not next_approval_timeline.assigned_date: - next_approval_timeline.assigned_date = fields.Datetime.now() - - project.stage_id = next_stage - project.approval_status = "" - - # Create activity log - activity_log = "%s approved by %s → moved to %s" % ( - current_stage.name, - self.env.user.name, - next_stage.name - ) - project._add_activity_log(activity_log) - - # Post to project channel - next_user = next_approval_timeline.assigned_to if next_approval_timeline else False - if next_user: - channel_message = _("Project %s approved at stage %s and moved to %s. %s please proceed.") % ( - project.name, - current_stage.name, - next_stage.name, - project._create_odoo_mention(next_user.partner_id) - ) - else: - channel_message = _("Project %s approved at stage %s and moved to %s") % ( - project.name, - current_stage.name, - next_stage.name - ) - project._post_to_project_channel(channel_message) - - # Send notification - if next_user: - project.message_post( - body=activity_log, - partner_ids=[next_user.partner_id.id], - message_type='notification', - subtype_xmlid='mail.mt_comment' - ) - else: - # Last stage completed - project.approval_status = "" - activity_log = "%s fully approved and completed" % project.name - project._add_activity_log(activity_log) - project._post_to_project_channel( - _("Project %s completed and fully approved") % project.name - ) - project.message_post(body=activity_log) - - def reject_and_return(self, reason=None): - """Reject project at current stage with optional reason""" - for project in self: - reason = reason or "" - current_stage = project.stage_id - project.approval_status = "reject" - - # Create activity log - activity_log = "%s rejected by %s — %s" % ( - current_stage.name, - self.env.user.name, - reason - ) - project._add_activity_log(activity_log) - - # Update approval timeline - current_approval_timeline = project.project_stages.filtered( - lambda s: s.stage_id == project.stage_id - ) - if current_approval_timeline: - current_approval_timeline.note = f"Reject Reason: {reason}" - - # Post to project channel - channel_message = _("Project %s rejected at stage %s. Reason: %s") % ( - project.name, - current_stage.name, - reason - ) - project._post_to_project_channel(channel_message) - - # Send notification - project.message_post(body=activity_log) - - # Notify responsible users - if current_approval_timeline: - responsible_user = ( - current_approval_timeline.assigned_to or - current_approval_timeline.approval_by - ) - if responsible_user: - project.message_post( - body=_("Project %s has been rejected and returned to you") % project.name, - partner_ids=[responsible_user.partner_id.id], - message_type='notification', - subtype_xmlid='mail.mt_comment' - ) - - def project_back_button(self): - """Revert project to previous stage""" - for project in self: - prev_stage = self.env["project.project.stage"].search([ - ('sequence', '<', project.stage_id.sequence), - ('id', 'in', project.showable_stage_ids.ids) - ], order="sequence desc", limit=1) - - if not prev_stage: - raise ValidationError(_("No previous stage available.")) - - # Create activity log - activity_log = "%s reverted back to %s by %s" % ( - project.stage_id.name, - prev_stage.name, - self.env.user.name - ) - project._add_activity_log(activity_log) - - # Post to project channel - channel_message = _("Project %s reverted from %s back to %s") % ( - project.name, - project.stage_id.name, - prev_stage.name - ) - project._post_to_project_channel(channel_message) - - # Update stage - project.stage_id = prev_stage - project.message_post(body=activity_log) - - def action_open_reject_wizard(self): - """Open rejection wizard for projects""" - self.ensure_one() - return { - "type": "ir.actions.act_window", - "name": _("Reject Project"), - "res_model": "project.reject.reason.wizard", - "view_mode": "form", - "target": "new", - "context": {"default_project_id": self.id}, - } - - # Activity Log Helper Methods - def _get_current_datetime_formatted(self): - """Get current datetime in 'DD-MMM-YYYY HH:MM AM/PM' format""" - now = fields.Datetime.context_timestamp(self, fields.datetime.now()) - formatted_date = now.strftime('%d-%b-%Y %I:%M %p').upper() - return formatted_date[1:] if formatted_date.startswith('0') else formatted_date - - def _add_activity_log(self, activity_text): - """Add formatted entry to project activity log""" - formatted_datetime = self._get_current_datetime_formatted() - for project in self: - log_entry = f"[{formatted_datetime}] {activity_text}" - if project.project_activity_log: - project.project_activity_log = Markup(project.project_activity_log) + Markup('
') + Markup(log_entry) - else: - project.project_activity_log = Markup(log_entry) - - def _post_to_project_channel(self, message_body, mention_partners=None): - """Post message to project's discuss channel with proper mentions""" - for project in self: - if not project.id: - continue - - # Get project channel - channel = ( - project.discuss_channel_id or - project.default_projects_channel_id - ) - - if channel: - formatted_message = self._format_message_with_odoo_mentions( - message_body, - mention_partners - ) - channel.message_post( - body=Markup(formatted_message), - message_type='comment', - subtype_xmlid='mail.mt_comment', - author_id=self.env.user.partner_id.id - ) - - def _format_message_with_odoo_mentions(self, message_body, mention_partners=None): - """Format message with proper Odoo @mentions""" - if not mention_partners: - return f'
{message_body}
' - - message_parts = ['
', message_body] - for partner in mention_partners: - if partner and partner.name: - mention_html = f'@{partner.name}' - message_parts.append(mention_html) - message_parts.append('
') - return ' '.join(message_parts) - - def _create_odoo_mention(self, partner): - """Create Odoo mention link for a partner""" - if not partner: - return "" - return f'@{partner.name}' - - -class ProjectTask(models.Model): - _inherit = 'project.task' - - - def _default_sprint_id(self): - """Return the current active (in-progress) sprint of the project.""" - if 'project_id' in self._context: - project_id = self._context.get('project_id') - sprint = self.env['project.sprint'].search([ - ('project_id', '=', project_id), - ('status', '=', 'in_progress') - ], limit=1) - return sprint.id - return False - - sprint_id = fields.Many2one( - "project.sprint", - string="Sprint", - default=_default_sprint_id - ) - - require_sprint = fields.Boolean( - related="project_id.require_sprint", - store=False - ) - - commit_step_ids = fields.One2many( - 'project.commit.step', - 'task_id', - string="Commit Steps" - ) - - show_task_chatter = fields.Boolean(default=False) - - development_document_ids = fields.One2many( - "task.development.document", - "task_id", - string="Development Documents" - ) - - testing_document_ids = fields.One2many( - "task.testing.document", - "task_id", - string="Testing Documents" - ) - - @api.onchange("project_id") - def _onchange_project_id_sprint_required(self): - for task in self: - if task.project_id and not task.project_id.require_sprint: - task.sprint_id = False - else: - if task.project_id and task.project_id.require_sprint: - sprint = self.env['project.sprint'].search([ - ('project_id', '=', task.project_id.id), - ('status', '=', 'in_progress') - ], limit=1) - task.sprint_id = sprint.id - - - def action_show_project_task_chatter(self): - """Toggle visibility of project chatter""" - for project in self: - project.show_task_chatter = not project.show_task_chatter \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/task_stages.py b/addons_extensions/project_task_timesheet_extended/models/task_stages.py index 17b1ec630..c95f2f6f5 100644 --- a/addons_extensions/project_task_timesheet_extended/models/task_stages.py +++ b/addons_extensions/project_task_timesheet_extended/models/task_stages.py @@ -6,6 +6,13 @@ class TaskStages(models.Model): team_id = fields.Many2one('internal.teams','Assigned to') approval_by = fields.Selection([('assigned_team_lead','Assigned Team Lead'),('project_manager','Project Manager'),('project_lead','Project Lead / Manager')]) + involved_user_ids = fields.Many2many('res.users') + + @api.onchange('team_id') + def onchange_team_id(self): + for rec in self: + if rec.team_id and rec.team_id.all_members_ids: + rec.involved_user_ids = [(6,0,rec.team_id.all_members_ids.ids)] def create_or_update_data(self): """Open wizard for updating this stage inside a project context.""" diff --git a/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv b/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv index 4f6a086a0..5b618eb20 100644 --- a/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv +++ b/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv @@ -3,6 +3,9 @@ internal_teams_admin,internal.teams.admin,model_internal_teams,project.group_pro internal_teams_manager,internal.teams.manager,model_internal_teams,project.group_project_user,1,1,1,0 internal_teams_user,internal.teams.user,model_internal_teams,base.group_user,1,0,0,0 +access_project_role_user,project.role.user,model_project_role,base.group_user,1,0,0,0 +access_project_role_manager,project.role.manager,model_project_role,project.group_project_manager,1,1,1,1 + access_project_sprint_user,access.project.sprint.user,model_project_sprint,project.group_project_user,1,1,1,1 access_project_sprint_manager,access.project.sprint.manager,model_project_sprint,project.group_project_manager,1,1,1,1 @@ -30,7 +33,10 @@ access_task_testing_document,access_task_testing_document,model_task_testing_doc project_user_assign_wizard_manager,project.user.assign.wizard,model_project_user_assign_wizard,project_task_timesheet_extended.group_project_supervisor,1,1,1,1 project_user_assign_wizard_admin,project.user.assign.wizard.admin,model_project_user_assign_wizard,project.group_project_manager,1,1,1,1 -project_user_assign_wizard_user,project.user.assign.wizard.user,model_project_user_assign_wizard,project.group_project_manager,1,0,0,0 +project_user_assign_wizard_user,project.user.assign.wizard.user,model_project_user_assign_wizard,base.group_user,1,0,0,0 + +roles_user_assign_wizard_user,roles.user.assign.wizard.user,model_roles_user_assign_wizard,base.group_user,1,1,1,1 + project_user_project_reject_reason_wizard,project.reject.reason.wizard.user,model_project_reject_reason_wizard,base.group_user,1,1,1,1 project_user_task_reject_reason_wizard,task.reject.reason.wizard.user,model_task_reject_reason_wizard,base.group_user,1,1,1,1 @@ -50,4 +56,6 @@ access_project_tags_supervisor,project.project_tags_supervisor,project.model_pro access_project_task_time_lines_user,access_project_task_time_lines_user,model_project_task_time_lines,base.group_user,1,1,1,1 access_project_task_time_lines_manager,access_project_task_time_lines_manager,model_project_task_time_lines,project.group_project_manager,1,1,1,1 -access_user_task_availability,user.task.availability.access,model_user_task_availability,base.group_user,1,0,0,0 \ No newline at end of file +access_user_task_availability,user.task.availability.access,model_user_task_availability,base.group_user,1,0,0,0 + +access_project_deployment_log_user,access.project.deployment.log.user,model_project_deployment_log,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/security/security.xml b/addons_extensions/project_task_timesheet_extended/security/security.xml index a438c0acd..1b1cdbb2a 100644 --- a/addons_extensions/project_task_timesheet_extended/security/security.xml +++ b/addons_extensions/project_task_timesheet_extended/security/security.xml @@ -6,6 +6,12 @@ + + Project Lead + + + + diff --git a/addons_extensions/project_task_timesheet_extended/static/src/css/delopyment.css b/addons_extensions/project_task_timesheet_extended/static/src/css/delopyment.css new file mode 100644 index 000000000..da2e9ae91 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/static/src/css/delopyment.css @@ -0,0 +1,52 @@ +.deployment_card { + border-radius: 12px; + padding: 15px; + background: white; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + margin-bottom: 12px; +} + +.deployment_header { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.deployment_version { + font-size: 18px; + font-weight: bold; + color: #4a4a4a; +} + +.deployment_date { + font-size: 13px; + color: #888; +} + +.deployment_status_container { + border-top: 1px solid #eee; + padding-top: 10px; + margin-top: 10px; +} + +.deployment_status { + display: flex; + justify-content: space-between; + margin: 3px 0; + font-size: 14px; +} + +.deployment_notes { + margin-top: 12px; + background: #f8f8ff; + padding: 8px; + border-radius: 6px; + font-size: 13px; + color: #333; + white-space: normal; +} + +.badge { + font-weight: bold; + color: #2d8f2d; +} diff --git a/addons_extensions/project_task_timesheet_extended/view/deployment_log.xml b/addons_extensions/project_task_timesheet_extended/view/deployment_log.xml new file mode 100644 index 000000000..1519d770a --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/view/deployment_log.xml @@ -0,0 +1,60 @@ + + + + + project.deployment.log.kanban + project.deployment.log + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ +
+
    +
  • Deployment Ready: + +
  • +
  • QA Signoff: + +
  • +
  • Client Signoff: + +
  • +
  • Backup: + +
  • +
+
+ + +
+
+
+
+
+
+
diff --git a/addons_extensions/project_task_timesheet_extended/view/project.xml b/addons_extensions/project_task_timesheet_extended/view/project.xml index 38ddf6185..823f6eab8 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project.xml @@ -1,6 +1,6 @@ - + project.project.inherit.form.view project.project @@ -31,7 +31,7 @@ string="Create Project Channel" type="object" class="btn-primary" - invisible = "discuss_channel_id"/> + invisible="discuss_channel_id"/>
@@ -39,29 +39,60 @@ - - - - - - - - - - - - + + + + + +
+
+
+

+ +

+

+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + project.role.kanban + project.role + + + + + + + + + +
+
+
+
+ + + + + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + project.role.search + project.role + + + + + + + + + + + + + + + + + + + Project Roles + project.role + list,kanban,form + {'search_default_active': 1} + +

+ Create your first project role +

+

+ Define roles for your projects and assign users to them. +

+
+
+ + + + + + + + project.project.stage.list.inherit + project.project.stage + + + + + + + + + + + + project.project.stage.form.inherit + project.project.stage + + + + + + + + +
+ + + + + + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/view/project_stages.xml b/addons_extensions/project_task_timesheet_extended/view/project_stages.xml index c283b2c1a..fa8066efd 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project_stages.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project_stages.xml @@ -10,531 +10,4 @@
- - - project.project.inherit.form.view - project.project - - - -