From 4634f2e72c74e5f284081264a47a502d29e8ecc9 Mon Sep 17 00:00:00 2001 From: Antonio David Ruban Espinal Date: Thu, 26 Sep 2024 14:00:30 -0400 Subject: [PATCH 1/4] [16.0][ADD] hr_planning_resources --- hr_planning_resources/README.rst | 70 ++ hr_planning_resources/__init__.py | 2 + hr_planning_resources/__manifest__.py | 34 + .../data/hr_task_ir_cron.xml | 22 + hr_planning_resources/models/__init__.py | 9 + .../models/helpdesk_ticket.py | 26 + hr_planning_resources/models/hr_leave.py | 181 +++++ hr_planning_resources/models/hr_task.py | 759 ++++++++++++++++++ hr_planning_resources/models/hr_task_mixin.py | 25 + .../models/hr_task_recurrency.py | 196 +++++ .../models/project_project.py | 27 + hr_planning_resources/models/project_task.py | 27 + .../models/res_config_settings.py | 20 + hr_planning_resources/models/resource.py | 7 + hr_planning_resources/readme/DESCRIPTION.rst | 0 hr_planning_resources/readme/USAGE.rst | 0 .../security/ir.model.access.csv | 4 + .../static/description/icon.png | Bin 0 -> 3471 bytes .../static/description/index.html | 415 ++++++++++ .../static/description/planning_icon.png | Bin 0 -> 26565 bytes .../static/src/js/timeline_controller.esm.js | 27 + .../static/src/js/timeline_renderer.js | 15 + .../src/scss/hr_planning_resources.scss | 8 + .../static/src/xml/web_timeline.xml | 18 + hr_planning_resources/tests/__init_.py | 0 hr_planning_resources/tests/common.py | 87 ++ hr_planning_resources/tests/test_hr_task.py | 41 + .../tests/test_hr_task_avg.py | 15 + hr_planning_resources/views/hr_task_views.xml | 345 ++++++++ hr_planning_resources/views/menus.xml | 72 ++ hr_planning_resources/views/project_views.xml | 54 ++ .../views/res_config_settings_views.xml | 54 ++ hr_planning_resources/wizard/__init__.py | 1 + .../wizard/create_hr_task.py | 60 ++ .../wizard/create_hr_task_views.xml | 27 + setup/.setuptools-odoo-make-default-ignore | 2 + setup/README | 2 + .../odoo/addons/hr_planning_resources | 1 + setup/hr_planning_resources/setup.py | 6 + 39 files changed, 2659 insertions(+) create mode 100644 hr_planning_resources/README.rst create mode 100644 hr_planning_resources/__init__.py create mode 100644 hr_planning_resources/__manifest__.py create mode 100644 hr_planning_resources/data/hr_task_ir_cron.xml create mode 100644 hr_planning_resources/models/__init__.py create mode 100644 hr_planning_resources/models/helpdesk_ticket.py create mode 100644 hr_planning_resources/models/hr_leave.py create mode 100644 hr_planning_resources/models/hr_task.py create mode 100644 hr_planning_resources/models/hr_task_mixin.py create mode 100644 hr_planning_resources/models/hr_task_recurrency.py create mode 100644 hr_planning_resources/models/project_project.py create mode 100644 hr_planning_resources/models/project_task.py create mode 100644 hr_planning_resources/models/res_config_settings.py create mode 100644 hr_planning_resources/models/resource.py create mode 100644 hr_planning_resources/readme/DESCRIPTION.rst create mode 100644 hr_planning_resources/readme/USAGE.rst create mode 100644 hr_planning_resources/security/ir.model.access.csv create mode 100644 hr_planning_resources/static/description/icon.png create mode 100644 hr_planning_resources/static/description/index.html create mode 100644 hr_planning_resources/static/description/planning_icon.png create mode 100644 hr_planning_resources/static/src/js/timeline_controller.esm.js create mode 100644 hr_planning_resources/static/src/js/timeline_renderer.js create mode 100644 hr_planning_resources/static/src/scss/hr_planning_resources.scss create mode 100644 hr_planning_resources/static/src/xml/web_timeline.xml create mode 100644 hr_planning_resources/tests/__init_.py create mode 100644 hr_planning_resources/tests/common.py create mode 100644 hr_planning_resources/tests/test_hr_task.py create mode 100644 hr_planning_resources/tests/test_hr_task_avg.py create mode 100644 hr_planning_resources/views/hr_task_views.xml create mode 100644 hr_planning_resources/views/menus.xml create mode 100644 hr_planning_resources/views/project_views.xml create mode 100644 hr_planning_resources/views/res_config_settings_views.xml create mode 100644 hr_planning_resources/wizard/__init__.py create mode 100644 hr_planning_resources/wizard/create_hr_task.py create mode 100644 hr_planning_resources/wizard/create_hr_task_views.xml create mode 100644 setup/.setuptools-odoo-make-default-ignore create mode 100644 setup/README create mode 120000 setup/hr_planning_resources/odoo/addons/hr_planning_resources create mode 100644 setup/hr_planning_resources/setup.py diff --git a/hr_planning_resources/README.rst b/hr_planning_resources/README.rst new file mode 100644 index 0000000..db6b302 --- /dev/null +++ b/hr_planning_resources/README.rst @@ -0,0 +1,70 @@ +=================== +HR Resource Planner +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f8c267bf44bdd476c5d1521e536405cf86ddbed1c66d84d65fd1df159b50805d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fresource-lightgray.png?logo=github + :target: https://github.com/OCA/resource/tree/16.0/hr_planning_resources + :alt: OCA/resource +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/resource-16-0/resource-16-0-hr_planning_resources + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/resource&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Binhex + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/resource `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_planning_resources/__init__.py b/hr_planning_resources/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/hr_planning_resources/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/hr_planning_resources/__manifest__.py b/hr_planning_resources/__manifest__.py new file mode 100644 index 0000000..fe4a47f --- /dev/null +++ b/hr_planning_resources/__manifest__.py @@ -0,0 +1,34 @@ +{ + "name": "HR Resource Planner", + "summary": "", + "version": "16.0.1.0.5", + "category": "Human Resources", + "website": "https://github.com/OCA/resource", + "author": "Binhex, Odoo Community Association (OCA)", + "depends": [ + "hr", + "project", + "web_timeline", + "helpdesk_mgmt", + "hr_holidays", + ], + "data": [ + "security/ir.model.access.csv", + "data/hr_task_ir_cron.xml", + "views/hr_task_views.xml", + "views/project_views.xml", + "views/res_config_settings_views.xml", + "wizard/create_hr_task_views.xml", + "views/menus.xml", + ], + "license": "AGPL-3", + "application": False, + "installable": True, + "assets": { + "web.assets_backend": [ + "hr_planning_resources/static/src/scss/hr_planning_resources.scss", + "hr_planning_resources/static/src/js/*.js", + "hr_planning_resources/static/src/xml/*.xml", + ], + }, +} diff --git a/hr_planning_resources/data/hr_task_ir_cron.xml b/hr_planning_resources/data/hr_task_ir_cron.xml new file mode 100644 index 0000000..97c50ee --- /dev/null +++ b/hr_planning_resources/data/hr_task_ir_cron.xml @@ -0,0 +1,22 @@ + + + + Update Task + + code + model.cron_update_task_state() + 1 + days + -1 + False + True + + + HR Planning Resources: generate next recurring shifts + + code + model._cron_schedule_next() + weeks + -1 + + diff --git a/hr_planning_resources/models/__init__.py b/hr_planning_resources/models/__init__.py new file mode 100644 index 0000000..7618cc7 --- /dev/null +++ b/hr_planning_resources/models/__init__.py @@ -0,0 +1,9 @@ +from . import hr_task +from . import hr_leave +from . import hr_task_mixin +from . import project_project +from . import project_task +from . import helpdesk_ticket +from . import hr_task_recurrency +from . import res_config_settings +from . import resource diff --git a/hr_planning_resources/models/helpdesk_ticket.py b/hr_planning_resources/models/helpdesk_ticket.py new file mode 100644 index 0000000..38cf37c --- /dev/null +++ b/hr_planning_resources/models/helpdesk_ticket.py @@ -0,0 +1,26 @@ +from odoo import api, models + + +class HelpdeskTicket(models.Model): + _name = "helpdesk.ticket" + _inherit = ["helpdesk.ticket", "hr.task.mixin"] + + @api.model_create_multi + def create(self, vals_list): + if self.env.context.get("default_user_id", False): + for vals in vals_list: + vals["user_id"] = self.env.context["default_user_id"] + return super().create(vals_list) + + def action_create_hr_task(self): + res = super().action_create_hr_task() + + res.update( + { + "context": { + **res.get("context", {}), + "default_user_ids": [self.user_id.id], + } + } + ) + return res diff --git a/hr_planning_resources/models/hr_leave.py b/hr_planning_resources/models/hr_leave.py new file mode 100644 index 0000000..4955501 --- /dev/null +++ b/hr_planning_resources/models/hr_leave.py @@ -0,0 +1,181 @@ +from collections import defaultdict +from datetime import timedelta +from itertools import groupby + +from pytz import timezone, utc + +from odoo import _, api, models +from odoo.tools.misc import get_lang + + +def format_time(env, time): + return time.strftime(get_lang(env).time_format) + + +def format_date(env, date): + return date.strftime(get_lang(env).date_format) + + +class HrLeave(models.Model): + _inherit = "hr.leave" + + @api.model + def _get_leave_interval(self, date_from, date_to, employee_ids): + # Validated hr.leave create a resource.calendar.leaves + calendar_leaves = self.env["resource.calendar.leaves"].search( + [ + ("time_type", "=", "leave"), + "|", + ("company_id", "in", employee_ids.mapped("company_id").ids), + ("company_id", "=", False), + "|", + ("resource_id", "in", employee_ids.mapped("resource_id").ids), + ("resource_id", "=", False), + ("date_from", "<=", date_to), + ("date_to", ">=", date_from), + ], + order="date_from", + ) + + leaves = defaultdict(list) + for leave in calendar_leaves: + for employee in employee_ids: + if ( + (not leave.company_id or leave.company_id == employee.company_id) + and ( + not leave.resource_id + or leave.resource_id == employee.resource_id + ) + and ( + not leave.calendar_id + or leave.calendar_id == employee.resource_calendar_id + ) + ): + leaves[employee.id].append(leave) + + # Get non-validated time off + leaves_query = self.env["hr.leave"].search( + [ + ("employee_id", "in", employee_ids.ids), + ("state", "in", ["confirm", "validate1"]), + ("date_from", "<=", date_to), + ("date_to", ">=", date_from), + ], + order="date_from", + ) + for leave in leaves_query: + leaves[leave.employee_id.id].append(leave) + return leaves + + def _get_leave_warning(self, leaves, employee, date_from, date_to): + loc_cache = {} + + def localize(date): + if date not in loc_cache: + loc_cache[date] = ( + utc.localize(date) + .astimezone(timezone(self.env.user.tz or "UTC")) + .replace(tzinfo=None) + ) + return loc_cache.get(date) + + warning = "" + periods = self._group_leaves(leaves, employee, date_from, date_to) + periods_by_states = [ + list(b) for a, b in groupby(periods, key=lambda x: x["is_validated"]) + ] + + for periods in periods_by_states: + period_leaves = "" + for period in periods: + dfrom = period["from"] + dto = period["to"] + prefix = "" + if period != periods[0]: + if period == periods[-1]: + prefix = _(" and") + else: + prefix = "," + + if period.get("show_hours", False): + period_leaves += _( + "%(prefix)s from the %(dfrom_date)s at %(dfrom)s to the %(dto_date)s at %(dto)s", + prefix=prefix, + dfrom_date=format_date(self.env, localize(dfrom)), + dfrom=format_time(self.env, localize(dfrom)), + dto_date=format_date(self.env, localize(dto)), + dto=format_time(self.env, localize(dto)), + ) + else: + period_leaves += _( + "%(prefix)s from the %(dfrom)s to the %(dto)s", + prefix=prefix, + dfrom=format_date(self.env, localize(dfrom)), + dto=format_date(self.env, localize(dto)), + ) + + time_off_type = ( + _("is on time off") + if periods[0].get("is_validated") + else _("has requested time off") + ) + warning += _( + "%(employee)s %(time_off_type)s%(period_leaves)s. \n", + employee=employee.name, + period_leaves=period_leaves, + time_off_type=time_off_type, + ) + return warning + + def _group_leaves(self, leaves, employee_id, date_from, date_to): + """ + Returns all the leaves happening between `planned_date_begin` and `planned_date_end` + """ + work_times = { + wk[0]: wk[1] + for wk in employee_id.list_work_time_per_day(date_from, date_to) + } + + def has_working_hours(start_dt, end_dt): + """ + Returns `True` if there are any working days between `start_dt` and `end_dt`. + """ + diff_days = (end_dt - start_dt).days + all_dates = [ + start_dt.date() + timedelta(days=delta) + for delta in range(diff_days + 1) + ] + return any(d in work_times for d in all_dates) + + periods = [] + for leave in leaves: + if leave.date_from > date_to or leave.date_to < date_from: + continue + + # Can handle both hr.leave and resource.calendar.leaves + number_of_days = 0 + is_validated = True + if isinstance(leave, self.pool["hr.leave"]): + number_of_days = leave.number_of_days + is_validated = False + else: + dt_delta = leave.date_to - leave.date_from + number_of_days = dt_delta.days + ((dt_delta.seconds / 3600) / 24) + + if not periods or has_working_hours(periods[-1]["from"], leave.date_to): + periods.append( + { + "is_validated": is_validated, + "from": leave.date_from, + "to": leave.date_to, + "show_hours": number_of_days <= 1, + } + ) + else: + periods[-1]["is_validated"] = is_validated + if periods[-1]["to"] < leave.date_to: + periods[-1]["to"] = leave.date_to + periods[-1]["show_hours"] = ( + periods[-1].get("show_hours") or number_of_days <= 1 + ) + return periods diff --git a/hr_planning_resources/models/hr_task.py b/hr_planning_resources/models/hr_task.py new file mode 100644 index 0000000..4e51a33 --- /dev/null +++ b/hr_planning_resources/models/hr_task.py @@ -0,0 +1,759 @@ +from collections import defaultdict +from datetime import datetime, time + +import pytz +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models + +from odoo.addons.resource.models.resource import Intervals, sum_intervals +from odoo.addons.resource.models.resource_mixin import timezone_datetime + +TASK_TYPES = [ + ("task", _("Task")), + ("project", _("Project")), + ("ticket", _("Ticket")), +] + + +class HrTask(models.Model): + _name = "hr.task" + _description = "HR Planning Resource" + _inherit = ["mail.thread.cc", "mail.activity.mixin"] + _sql_constraints = [ + ( + "date_check", + "CHECK (date_start <= date_end)", + "Error: End date must be greater than start date!", + ), + ] + _order = "date_start desc, id desc" + + def _default_date_start(self): + return datetime.combine(fields.Date.context_today(self), time.min) + + def _default_date_end(self): + return datetime.combine(fields.Date.context_today(self), time.max) + + def _get_default_employee(self): + return self.env["hr.employee"].search([("user_id", "=", self.env.uid)], limit=1) + + name = fields.Char(compute="_compute_name", store=True) + title = fields.Char(compute="_compute_title", store=True) + type = fields.Selection(selection=TASK_TYPES, required=True, tracking=True) + employee_id = fields.Many2one( + "hr.employee", + required=True, + tracking=True, + default=lambda self: self._get_default_employee(), + ) + resource_id = fields.Many2one( + "resource.resource", related="employee_id.resource_id" + ) + user_id = fields.Many2one("res.users", related="employee_id.user_id") + department_id = fields.Many2one( + "hr.department", + related="employee_id.department_id", + ) + employee_parent_id = fields.Many2one(related="employee_id.parent_id", store=True) + member_of_department = fields.Boolean(related="employee_id.member_of_department") + company_id = fields.Many2one( + "res.company", + default=lambda self: self.env.user.company_id.id, + required=True, + ) + state = fields.Selection( + [ + ("planified", "Planified"), + ("in_progress", "In Progress"), + ("finished", "Finished"), + ("cancel", "Cancelled"), + ], + default="planified", + tracking=True, + ) + date_start = fields.Datetime( + string="Start Date", + required=True, + tracking=True, + ) + + date_end = fields.Datetime(string="End Date", required=True, tracking=True) + planned_hours = fields.Float( + string="Duration", + compute="_compute_planned_hours", + store=True, + tracking=True, + ) + + allocated_hours = fields.Float( + "Allocated Time", + compute="_compute_allocated_hours", + store=True, + readonly=False, + ) + allocated_percentage = fields.Float( + "Allocated Time %", + default=100, + compute="_compute_allocated_percentage", + store=True, + readonly=False, + group_operator="avg", + ) + working_days_count = fields.Float( + "Working Days", compute="_compute_working_days_count", store=True + ) + duration = fields.Float(compute="_compute_task_duration") + + project_id = fields.Many2one("project.project", string="Project") + filtered_project_id = fields.Many2one("project.project") + task_id = fields.Many2one("project.task", string="Task") + ticket_id = fields.Many2one("helpdesk.ticket", string="Ticket") + + leave_warning = fields.Char(compute="_compute_leave_warning") + is_absent = fields.Boolean( + "Employees on Time Off", + compute="_compute_leave_warning", + search="_search_is_absent", + compute_sudo=True, + readonly=True, + ) + + # Recurrency + recurrency_id = fields.Many2one("hr.task.recurrency", string="Recurrency") + repeat = fields.Boolean( + compute="_compute_repeat", + inverse="_inverse_repeat", + copy=True, + help="Modifications made to a shift only impact the current shift and not the other ones that are part of the recurrence. The same goes when deleting a recurrent shift. Disable this option to stop the recurrence.\n" + "To avoid polluting your database and performance issues, shifts are only created for the next 6 months. They are then gradually created as time passes by in order to always get shifts 6 months ahead. This value can be modified from the settings of Planning, in debug mode.", + ) + repeat_interval = fields.Integer( + "Repeat every", + default=1, + compute="_compute_repeat_interval", + inverse="_inverse_repeat", + copy=True, + ) + repeat_unit = fields.Selection( + [ + ("day", "Days"), + ("week", "Weeks"), + ("month", "Months"), + ("year", "Years"), + ], + default="week", + compute="_compute_repeat_unit", + inverse="_inverse_repeat", + required=True, + ) + repeat_type = fields.Selection( + [ + ("forever", "Forever"), + ("until", "Until"), + ("x_times", "Number of Repetitions"), + ], + default="forever", + compute="_compute_repeat_type", + inverse="_inverse_repeat", + copy=True, + ) + repeat_until = fields.Date( + compute="_compute_repeat_until", + inverse="_inverse_repeat", + copy=True, + ) + repeat_number = fields.Integer( + "Repetitions", + default=1, + compute="_compute_repeat_number", + inverse="_inverse_repeat", + copy=True, + ) + confirm_delete = fields.Boolean( + "Confirm tasks Deletion", compute="_compute_confirm_delete" + ) + + @api.onchange("filtered_project_id") + def _onchange_filtered_project_id(self): + res = {"domain": {"task_id": []}} + if self.filtered_project_id: + res["domain"].update( + {"task_id": [("project_id", "=", self.filtered_project_id.id)]} + ) + return res + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + + if "date_start" in fields_list: + date_start = ( + fields.Datetime.from_string(res.get("date_start")) + if res.get("date_start") + else self._default_date_start() + ) + date_end = ( + fields.Datetime.from_string(res.get("date_end")) + if res.get("date_end") + else self._default_date_end() + ) + start = pytz.utc.localize(date_start) + end = pytz.utc.localize(date_end) if date_end else self._default_date_end() + opening_hours = self._company_working_hours(start, end) + res["date_start"] = ( + opening_hours[0].astimezone(pytz.utc).replace(tzinfo=None) + ) + + if "date_end" in fields_list: + res["date_end"] = ( + opening_hours[1].astimezone(pytz.utc).replace(tzinfo=None) + ) + + return res + + def _company_working_hours(self, start, end): + company = self.company_id or self.env.company + work_interval = company.resource_calendar_id._work_intervals_batch(start, end)[ + False + ] + intervals = [ + (date_start, date_stop) + for date_start, date_stop, attendance in work_interval + ] + date_start, date_end = (start, end) + if ( + intervals and (date_end - date_start).days == 0 + ): # Then we want the first working day and keep the end hours of this day + date_start = intervals[0][0] + date_end = [ + stop for start, stop in intervals if stop.date() == date_start.date() + ][-1] + elif intervals and (date_end - date_start).days >= 0: + date_start = intervals[0][0] + date_end = intervals[-1][1] + + return (date_start, date_end) + + def _calculate_task_duration(self): + self.ensure_one() + period = self.date_end - self.date_start + task_duration = period.total_seconds() / 3600 + max_duration = ( + period.days + (1 if period.seconds else 0) + ) * self.company_id.resource_calendar_id.hours_per_day + if not max_duration or max_duration >= task_duration: + return task_duration + return max_duration + + def _get_working_hours_over_period( + self, start_utc, end_utc, work_intervals, calendar_intervals + ): + start = max(start_utc, pytz.utc.localize(self.date_start)) + end = min(end_utc, pytz.utc.localize(self.date_end)) + task_interval = Intervals( + [(start, end, self.env["resource.calendar.attendance"])] + ) + working_intervals = ( + work_intervals[self.resource_id.id] + if self.resource_id + else calendar_intervals[self.company_id.resource_calendar_id.id] + ) + return sum_intervals(task_interval & working_intervals) + + @api.depends( + "date_start", + "date_end", + "employee_id.resource_calendar_id", + "allocated_hours", + ) + def _compute_allocated_percentage(self): + allocated_hours_field = self._fields["allocated_hours"] + tasks = self.filtered( + lambda task: not self.env.is_to_compute(allocated_hours_field, task) + and task.date_start + and task.date_end + and task.date_start != task.date_end + ) + if not tasks: + return + for task in tasks: + task.allocated_percentage = ( + 100 * task.allocated_hours / task._calculate_task_duration() + ) + + @api.depends( + "date_start", + "date_end", + "resource_id.calendar_id", + "company_id.resource_calendar_id", + "allocated_percentage", + ) + def _compute_allocated_hours(self): + percentage_field = self._fields["allocated_percentage"] + self.env.remove_to_compute(percentage_field, self) + planning_tasks = self.filtered(lambda s: not s.company_id and not s.resource_id) + tasks_with_calendar = self - planning_tasks + for task in planning_tasks: + # for each planning task, compute the duration + ratio = task.allocated_percentage / 100.0 + task.allocated_hours = task._calculate_task_duration() * ratio + if tasks_with_calendar: + unplanned_tasks_with_calendar = tasks_with_calendar.filtered_domain( + [ + "|", + ("date_start", "=", False), + ("date_end", "=", False), + ] + ) + for task in unplanned_tasks_with_calendar: + task.allocated_hours = 0.0 + planned_tasks_with_calendar = ( + tasks_with_calendar - unplanned_tasks_with_calendar + ) + if not planned_tasks_with_calendar: + return + start_utc = pytz.utc.localize( + min(planned_tasks_with_calendar.mapped("date_start")) + ) + end_utc = pytz.utc.localize( + max(planned_tasks_with_calendar.mapped("date_end")) + ) + ( + resource_work_intervals, + calendar_work_intervals, + ) = tasks_with_calendar.resource_id._get_valid_work_intervals( + start_utc, + end_utc, + calendars=tasks_with_calendar.company_id.resource_calendar_id, + ) + for task in planned_tasks_with_calendar: + task.allocated_hours = task._get_duration_over_period( + pytz.utc.localize(task.date_start), + pytz.utc.localize(task.date_end), + resource_work_intervals, + calendar_work_intervals, + has_allocated_hours=False, + ) + + def _get_duration_over_period( + self, + start_utc, + stop_utc, + work_intervals, + calendar_intervals, + has_allocated_hours=True, + ): + assert start_utc.tzinfo and stop_utc.tzinfo + self.ensure_one() + start, stop = start_utc.replace(tzinfo=None), stop_utc.replace(tzinfo=None) + if has_allocated_hours and self.date_start >= start and self.date_end <= stop: + return self.allocated_hours + # if the task goes over the gantt period, compute the duration only within + # the gantt period + ratio = self.allocated_percentage / 100.0 + working_hours = self._get_working_hours_over_period( + start_utc, stop_utc, work_intervals, calendar_intervals + ) + return working_hours * ratio + + @api.depends("date_start", "date_end", "resource_id") + def _compute_working_days_count(self): + tasks_per_calendar = defaultdict(set) + planned_dates_per_calendar_id = defaultdict( + lambda: (datetime.max, datetime.min) + ) + for task in self: + if not task.employee_id or not task.date_start or not task.date_end: + task.working_days_count = 0 + continue + tasks_per_calendar[task.resource_id.calendar_id].add(task.id) + datetime_begin, datetime_end = planned_dates_per_calendar_id[ + task.resource_id.calendar_id.id + ] + datetime_begin = min(datetime_begin, task.date_start) + datetime_end = max(datetime_end, task.date_end) + planned_dates_per_calendar_id[task.resource_id.calendar_id.id] = ( + datetime_begin, + datetime_end, + ) + for calendar, task_ids in tasks_per_calendar.items(): + tasks = self.env["hr.task"].browse(list(task_ids)) + if not calendar: + tasks.working_days_count = 0 + continue + datetime_begin, datetime_end = planned_dates_per_calendar_id[calendar.id] + datetime_begin = timezone_datetime(datetime_begin) + datetime_end = timezone_datetime(datetime_end) + resources = tasks.resource_id + day_total = calendar._get_resources_day_total( + datetime_begin, datetime_end, resources + ) + intervals = calendar._work_intervals_batch( + datetime_begin, datetime_end, resources + ) + for task in tasks: + task.working_days_count = calendar._get_days_data( + intervals[task.resource_id.id] + & Intervals( + [ + ( + timezone_datetime(task.date_start), + timezone_datetime(task.date_end), + self.env["resource.calendar.attendance"], + ) + ] + ), + day_total[task.resource_id.id], + )["days"] + + @api.depends("date_start", "date_end") + def _compute_task_duration(self): + for task in self: + if not self.date_start or not self.date_end: + task.duration = 0.0 + else: + task.duration = (task.date_end - task.date_start).total_seconds() / 3600 + + @api.depends("repeat_until", "repeat_number") + def _compute_confirm_delete(self): + for task in self: + if task.recurrency_id and task.repeat_until and task.repeat_number: + recurrence_end_dt = ( + task.repeat_until + or task.recurrency_id._get_recurrence_last_datetime() + ) + task.confirm_delete = ( + fields.Date.to_date(recurrence_end_dt) > task.repeat_until + ) + else: + task.confirm_delete = False + + @api.depends("recurrency_id") + def _compute_repeat(self): + for task in self: + if task.recurrency_id: + task.repeat = True + else: + task.repeat = False + + @api.depends("recurrency_id.repeat_interval") + def _compute_repeat_interval(self): + recurrency_tasks = self.filtered("recurrency_id") + for task in recurrency_tasks: + if task.recurrency_id: + task.repeat_interval = task.recurrency_id.repeat_interval + (self - recurrency_tasks).update(self.default_get(["repeat_interval"])) + + @api.depends("recurrency_id.repeat_until", "repeat", "repeat_type") + def _compute_repeat_until(self): + for task in self: + repeat_until = False + if task.repeat and task.repeat_type == "until": + if task.recurrency_id and task.recurrency_id.repeat_until: + repeat_until = task.recurrency_id.repeat_until + elif task.date_start: + repeat_until = task.date_start + relativedelta(weeks=1) + task.repeat_until = repeat_until + + @api.depends("recurrency_id.repeat_number", "repeat_type") + def _compute_repeat_number(self): + recurrency_tasks = self.filtered("recurrency_id") + for task in recurrency_tasks: + task.repeat_number = task.recurrency_id.repeat_number + (self - recurrency_tasks).update(self.default_get(["repeat_number"])) + + @api.depends("recurrency_id.repeat_unit") + def _compute_repeat_unit(self): + non_recurrent_tasks = self.env["hr.task"] + for task in self: + if task.recurrency_id: + task.repeat_unit = task.recurrency_id.repeat_unit + else: + non_recurrent_tasks += task + non_recurrent_tasks.update(self.default_get(["repeat_unit"])) + + @api.depends("recurrency_id.repeat_type") + def _compute_repeat_type(self): + recurrency_tasks = self.filtered("recurrency_id") + for task in recurrency_tasks: + if task.recurrency_id: + task.repeat_type = task.recurrency_id.repeat_type + (self - recurrency_tasks).update(self.default_get(["repeat_type"])) + + def _inverse_repeat(self): + for task in self: + if task.repeat and not task.recurrency_id.id: # create the recurrence + repeat_until = False + repeat_number = 0 + if task.repeat_type == "until": + repeat_until = datetime.combine( + fields.Date.to_date(task.repeat_until), + datetime.max.time(), + ) + repeat_until = ( + repeat_until.replace( + tzinfo=pytz.timezone( + task.company_id.resource_calendar_id.tz or "UTC" + ) + ) + .astimezone(pytz.utc) + .replace(tzinfo=None) + ) + if task.repeat_type == "x_times": + repeat_number = task.repeat_number + recurrency_values = { + "repeat_interval": task.repeat_interval, + "repeat_unit": task.repeat_unit, + "repeat_until": repeat_until, + "repeat_number": repeat_number, + "repeat_type": task.repeat_type, + "company_id": task.company_id.id, + } + recurrence = self.env["hr.task.recurrency"].create(recurrency_values) + task.recurrency_id = recurrence + task.recurrency_id._repeat_task() + elif not task.repeat and task.recurrency_id.id: + task.recurrency_id._delete_task(task.date_end) + task.recurrency_id.unlink() # will set recurrency_id to NULL + + @api.depends("date_start", "date_end", "employee_id") + def _compute_overlap_task_count(self): + for rec in self: + rec.overlap_task_count = self.search_count( + [ + ("employee_id", "=", rec.employee_id.id), + ("date_start", "<", rec.date_end), + ("date_end", ">", rec.date_start), + ] + ) + + @api.depends("date_start", "date_end", "employee_id") + def _compute_leave_warning(self): + assigned_tasks = self.filtered(lambda s: s.employee_id and s.date_start) + (self - assigned_tasks).leave_warning = False + (self - assigned_tasks).is_absent = False + + if not assigned_tasks: + return + + min_date = min(assigned_tasks.mapped("date_start")) + date_from = ( + min_date if min_date > fields.Datetime.today() else fields.Datetime.today() + ) + leaves = self.env["hr.leave"]._get_leave_interval( + date_from=date_from, + date_to=max(assigned_tasks.mapped("date_end")), + employee_ids=assigned_tasks.mapped("employee_id"), + ) + + for task in assigned_tasks: + warning = False + task_leaves = leaves.get(task.employee_id.id) + if task_leaves: + warning = self.env["hr.leave"]._get_leave_warning( + leaves=task_leaves, + employee=task.employee_id, + date_from=task.date_start, + date_to=task.date_end, + ) + task.leave_warning = warning + task.is_absent = bool(warning) + + def _compute_title(self): + for record in self: + if record.name: + record.title = record.name + else: + record.title = "" + + @api.depends("type", "task_id", "project_id", "ticket_id") + def _compute_name(self): + for record in self: + if record.type == "task": + record.name = record.task_id.name + elif record.type == "project": + record.name = record.project_id.name + elif record.type == "ticket": + record.name = record.ticket_id.name + else: + record.name = "" + + @api.depends( + "date_start", + "date_end", + "employee_id", + "employee_id.resource_calendar_id", + ) + def _compute_planned_hours(self): + for record in self: + if record.date_start and record.date_end and record.employee_id: + work_days_data = record.employee_id._get_work_days_data_batch( + record.date_start, record.date_end + )[record.employee_id.id] + record.planned_hours = work_days_data["hours"] + else: + record.planned_hours = 0.0 + + def _get_tz(self): + return ( + self.env.user.tz + or self.employee_id.tz + or self.employee_id.tz + or self._context.get("tz") + or self.company_id.resource_calendar_id.tz + or "UTC" + ) + + def _add_delta_with_dst(self, start, delta): + try: + tz = pytz.timezone(self._get_tz()) + except pytz.UnknownTimeZoneError: + tz = pytz.UTC + start = start.replace(tzinfo=pytz.utc).astimezone(tz).replace(tzinfo=None) + result = start + delta + return tz.localize(result).astimezone(pytz.utc).replace(tzinfo=None) + + @api.model + def _search_is_absent(self, operator, value): + if operator not in ["=", "!="] or not isinstance(value, bool): + raise NotImplementedError(_("Operation not supported")) + + today = fields.Datetime.today() + tasks = self.search( + [ + ("employee_id", "!=", False), + ( + "date_end", + ">", + today, + ), # only fetch the tasks containing today in their period or shifts in the future + ] + ) + if not tasks: + return [] + + min_date = min(tasks.mapped("date_start")) + date_from = max(min_date, today) + mapped_leaves = self.env["hr.leave"]._get_leave_interval( + date_from=date_from, + date_to=max(tasks.mapped("date_end")), + employee_ids=tasks.mapped("employee_id"), + ) + + task_ids = [] + for task in tasks.filtered(lambda s: s.employee_id.id in mapped_leaves): + leaves = mapped_leaves[task.employee_id.id] + period = self.env["hr.leave"]._group_leaves( + leaves, task.employee_id, task.date_start, task.date_end + ) + if period: + task_ids.append(task.id) + if operator == "!=": + value = not value + return [("id", "in" if value else "not in", task_ids)] + + @api.onchange("type") + def _onchange_type(self): + if self.type == "task": + self.write({"project_id": False, "ticket_id": False}) + elif self.type == "project": + self.write({"task_id": False, "ticket_id": False}) + elif self.type == "ticket": + self.write({"project_id": False, "task_id": False}) + + def write(self, values): + result = super().write(values) + if any( + key + in ( + "repeat", + "repeat_unit", + "repeat_type", + "repeat_until", + "repeat_interval", + "repeat_number", + ) + for key in values + ): + # User is trying to change this record's recurrence so we delete future tasks belonging to recurrence A + # and we create recurrence B from now on w/ the new parameters + for task in self: + if task.recurrency_id and values.get("repeat") is None: + repeat_type = ( + values.get("repeat_type") or task.recurrency_id.repeat_type + ) + repeat_until = ( + values.get("repeat_until") or task.recurrency_id.repeat_until + ) + repeat_number = values.get("repeat_number", 0) or task.repeat_number + if repeat_type == "until": + repeat_until = datetime.combine( + fields.Date.to_date(repeat_until), + datetime.max.time(), + ) + repeat_until = ( + repeat_until.replace( + tzinfo=pytz.timezone( + task.company_id.resource_calendar_id.tz or "UTC" + ) + ) + .astimezone(pytz.utc) + .replace(tzinfo=None) + ) + recurrency_values = { + "repeat_interval": values.get("repeat_interval") + or task.recurrency_id.repeat_interval, + "repeat_unit": values.get("repeat_unit") + or task.recurrency_id.repeat_unit, + "repeat_until": ( + repeat_until if repeat_type == "until" else False + ), + "repeat_number": repeat_number, + "repeat_type": repeat_type, + "company_id": task.company_id.id, + } + task.recurrency_id.write(recurrency_values) + if task.repeat_type == "x_times": + recurrency_values[ + "repeat_until" + ] = task.recurrency_id._get_recurrence_last_datetime() + date_end = ( + task.date_end + if values.get("repeat_unit") + else recurrency_values.get("repeat_until") + ) + task.recurrency_id._delete_task(date_end) + task.recurrency_id._repeat_task() + return result + + def action_cancel(self): + self.write({"state": "cancel"}) + return True + + def action_planified(self): + self.write({"state": "planified"}) + return True + + def action_in_progress(self): + self.write({"state": "in_progress"}) + return True + + def action_finished(self): + self.write({"state": "finished"}) + return True + + def cron_update_task_state(self): + self.search( + [ + ("date_end", "<", fields.Datetime.now()), + ("state", "=", "in_progress"), + ] + ).write({"state": "finished"}) + + self.search( + [ + ("date_start", "<=", fields.Datetime.now()), + ("state", "=", "planified"), + ] + ).write({"state": "in_progress"}) diff --git a/hr_planning_resources/models/hr_task_mixin.py b/hr_planning_resources/models/hr_task_mixin.py new file mode 100644 index 0000000..f87df4c --- /dev/null +++ b/hr_planning_resources/models/hr_task_mixin.py @@ -0,0 +1,25 @@ +from odoo import models + + +class HrTaskMixin(models.AbstractModel): + _name = "hr.task.mixin" + + def action_create_hr_task(self): + self.ensure_one() + view_id = self.env.ref("hr_planning_resources.create_hr_task_view_form") + ctx = self.env.context.copy() + ctx.update( + { + "default_res_model": self._name, + } + ) + + return { + "type": "ir.actions.act_window", + "res_model": "create.hr.task", + "view_mode": "form", + "views": [[view_id.id, "form"]], + "res_id": self.id, + "context": ctx, + "target": "new", + } diff --git a/hr_planning_resources/models/hr_task_recurrency.py b/hr_planning_resources/models/hr_task_recurrency.py new file mode 100644 index 0000000..2636ab9 --- /dev/null +++ b/hr_planning_resources/models/hr_task_recurrency.py @@ -0,0 +1,196 @@ +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.date_utils import get_timedelta + +_logger = logging.getLogger(__name__) + + +class HrTaskRecurrency(models.Model): + _name = "hr.task.recurrency" + _description = "HrTaskRecurrency" + + task_ids = fields.One2many( + comodel_name="hr.task", + inverse_name="recurrency_id", + string="Related Planning Tasks", + ) + repeat_interval = fields.Integer("Repeat Every", default=1, required=True) + repeat_unit = fields.Selection( + [ + ("day", "Days"), + ("week", "Weeks"), + ("month", "Months"), + ("year", "Years"), + ], + default="week", + required=True, + ) + repeat_type = fields.Selection( + [ + ("forever", "Forever"), + ("until", "Until"), + ("x_times", "Number of Repetitions"), + ], + string="Weeks", + default="forever", + ) + repeat_until = fields.Datetime( + help="Up to which date should the plannings be repeated", + ) + repeat_number = fields.Integer( + string="Repetitions", help="No Of Repetitions of the plannings" + ) + last_generated_end_datetime = fields.Datetime( + "Last Generated End Date", readonly=True + ) + company_id = fields.Many2one( + "res.company", + string="Company", + readonly=True, + required=True, + default=lambda self: self.env.company, + ) + + @api.constrains("repeat_number", "repeat_type") + def _check_repeat_number(self): + if self.filtered(lambda t: t.repeat_type == "x_times" and t.repeat_number < 0): + raise ValidationError(_("The number of repetitions cannot be negative.")) + + @api.constrains("company_id", "task_ids") + def _check_multi_company(self): + for recurrency in self: + if any( + recurrency.company_id != planning.company_id + for planning in recurrency.task_ids + ): + raise ValidationError( + _("An shift must be in the same company as its recurrency.") + ) + + def name_get(self): + result = [] + for recurrency in self: + if recurrency.repeat_type == "forever": + name = _(f"Forever, every {recurrency.repeat_interval} week(s)") + else: + name = _( + f"Every {recurrency.repeat_interval} week(s) until {recurrency.repeat_until}" + ) + result.append([recurrency.id, name]) + return result + + @api.model + def _cron_schedule_next(self): + companies = self.env["res.company"].search([]) + now = fields.Datetime.now() + for company in companies: + delta = get_timedelta(company.task_generation_interval, "month") + + recurrencies = self.search( + [ + "&", + "&", + ("company_id", "=", company.id), + ("last_generated_end_datetime", "<", now + delta), + "|", + ("repeat_until", "=", False), + ("repeat_until", ">", now - delta), + ] + ) + recurrencies._repeat_task(now + delta) + + def _repeat_task(self, stop_datetime=False): + HrTask = self.env["hr.task"] + for recurrency in self: + task = HrTask.search( + [("recurrency_id", "=", recurrency.id)], + limit=1, + order="date_start DESC", + ) + + if task: + # find the end of the recurrence + recurrence_end_dt = False + if recurrency.repeat_type == "until": + recurrence_end_dt = recurrency.repeat_until + if recurrency.repeat_type == "x_times": + recurrence_end_dt = recurrency._get_recurrence_last_datetime() + + # find end of generation period (either the end of recurrence (if this one ends before the cron period), or the given `stop_datetime` (usually the cron period)) + if not stop_datetime: + stop_datetime = fields.Datetime.now() + get_timedelta( + recurrency.company_id.task_generation_interval, + "month", + ) + range_limit = min(dt for dt in [recurrence_end_dt, stop_datetime] if dt) + task_duration = task.date_end - task.date_start + + def get_all_next_starts(): + for i in range(1, 365 * 5): # 5 years if every day + next_start = HrTask._add_delta_with_dst( + task.date_start, + get_timedelta( + recurrency.repeat_interval * i, + recurrency.repeat_unit, + ), + ) + if next_start >= range_limit: + return + yield next_start + + task_values_list = [ + task.copy_data( + { + "date_start": start, + "date_end": start + task_duration, + "recurrency_id": recurrency.id, + "company_id": recurrency.company_id.id, + "repeat": True, + "state": "planified", + } + )[0] + for start in get_all_next_starts() + ] + if task_values_list: + HrTask.create(task_values_list) + recurrency.write( + { + "last_generated_end_datetime": task_values_list[-1][ + "date_start" + ] + } + ) + + else: + recurrency.unlink() + + def _delete_task(self, date_start): + tasks = self.env["hr.task"].search( + [ + ("recurrency_id", "in", self.ids), + ("date_start", ">=", date_start), + ("state", "=", "planified"), + ] + ) + tasks.unlink() + + def _get_recurrence_last_datetime(self): + self.ensure_one() + date_end = self.env["hr.task"].search_read( + [("recurrency_id", "=", self.id)], + ["date_end"], + order="date_end", + limit=1, + ) + timedelta = get_timedelta( + self.repeat_number * self.repeat_interval, self.repeat_unit + ) + if timedelta.days > 999: + raise ValidationError( + _( + "Recurring shifts cannot be planned further than 999 days in the future. If you need to schedule beyond this limit, please set the recurrence to repeat forever instead." + ) + ) + return date_end[0]["date_end"] + timedelta diff --git a/hr_planning_resources/models/project_project.py b/hr_planning_resources/models/project_project.py new file mode 100644 index 0000000..291ae3e --- /dev/null +++ b/hr_planning_resources/models/project_project.py @@ -0,0 +1,27 @@ +from odoo import api, models + + +class ProjectProject(models.Model): + _name = "project.project" + _inherit = ["project.project", "hr.task.mixin"] + + @api.model_create_multi + def create(self, vals_list): + if self.env.context.get("default_user_id", False): + vals_list[0]["user_id"] = [(4, self.env.context["default_user_id"])] + return super().create(vals_list) + + def action_create_hr_task(self): + res = super().action_create_hr_task() + + res.update( + { + "context": { + **res.get("context", {}), + "default_user_ids": [self.user_id.id], + "default_start_date": self.date_start, + "default_end_date": self.date, + } + } + ) + return res diff --git a/hr_planning_resources/models/project_task.py b/hr_planning_resources/models/project_task.py new file mode 100644 index 0000000..b31321e --- /dev/null +++ b/hr_planning_resources/models/project_task.py @@ -0,0 +1,27 @@ +from odoo import api, models + + +class ProjectTask(models.Model): + _name = "project.task" + _inherit = ["project.task", "hr.task.mixin"] + + @api.model_create_multi + def create(self, vals_list): + if self.env.context.get("default_user_id", False): + vals_list[0]["user_ids"] = [(4, self.env.context["default_user_id"])] + return super().create(vals_list) + + def action_create_hr_task(self): + res = super().action_create_hr_task() + + res.update( + { + "context": { + **res.get("context", {}), + "default_user_ids": self.user_ids.ids, + "default_start_date": self.date_assign, + "default_end_date": self.date_end, + } + } + ) + return res diff --git a/hr_planning_resources/models/res_config_settings.py b/hr_planning_resources/models/res_config_settings.py new file mode 100644 index 0000000..4e5c0f6 --- /dev/null +++ b/hr_planning_resources/models/res_config_settings.py @@ -0,0 +1,20 @@ +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + task_generation_interval = fields.Integer( + "Rate Of Shift Generation", default=1, required=True + ) + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + task_generation_interval = fields.Integer( + "Rate Of Shift Generation", + required=True, + related="company_id.task_generation_interval", + readonly=False, + ) diff --git a/hr_planning_resources/models/resource.py b/hr_planning_resources/models/resource.py new file mode 100644 index 0000000..14f6df0 --- /dev/null +++ b/hr_planning_resources/models/resource.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResourceResource(models.Model): + _inherit = "resource.resource" + + flexible_hours = fields.Boolean() diff --git a/hr_planning_resources/readme/DESCRIPTION.rst b/hr_planning_resources/readme/DESCRIPTION.rst new file mode 100644 index 0000000..e69de29 diff --git a/hr_planning_resources/readme/USAGE.rst b/hr_planning_resources/readme/USAGE.rst new file mode 100644 index 0000000..e69de29 diff --git a/hr_planning_resources/security/ir.model.access.csv b/hr_planning_resources/security/ir.model.access.csv new file mode 100644 index 0000000..4c1ede7 --- /dev/null +++ b/hr_planning_resources/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_task,hr.task.access,model_hr_task,base.group_user,1,1,1,1 +access_create_hr_task_manager,create_hr_task_manager,model_create_hr_task,,1,1,1,1 +access_hr_task_recurrency_manager,hr_task_recurrency_manager,model_hr_task_recurrency,,1,1,1,1 diff --git a/hr_planning_resources/static/description/icon.png b/hr_planning_resources/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6754ecdd1893d70e566050a1f9c2b44a93a59900 GIT binary patch literal 3471 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 + + + + +HR Resource Planner + + + +
+

HR Resource Planner

+ + +

Beta License: AGPL-3 OCA/resource Translate me on Weblate Try me on Runboat

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Binhex
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/resource project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/hr_planning_resources/static/description/planning_icon.png b/hr_planning_resources/static/description/planning_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..84dbdbaf8f3a28cf943add04f823e1313c74a4f1 GIT binary patch literal 26565 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 literal 0 HcmV?d00001 diff --git a/hr_planning_resources/static/src/js/timeline_controller.esm.js b/hr_planning_resources/static/src/js/timeline_controller.esm.js new file mode 100644 index 0000000..fb3deaa --- /dev/null +++ b/hr_planning_resources/static/src/js/timeline_controller.esm.js @@ -0,0 +1,27 @@ +/** @odoo-module */ + +import TimelineController from "web_timeline.TimelineController"; + +TimelineController.include({ + create_completed: function (id) { + const self = this; + return this._rpc({ + model: this.model.modelName, + method: "read", + args: [id, this.model.fieldNames], + context: this.context, + }).then((records) => { + const new_event = this.renderer.event_data_transform(records[0]); + const items = this.renderer.timeline.itemsData; + items.add(new_event); + + self.model.data.data.push(records[0]); + const params = { + domain: this.renderer.last_domains, + context: this.context, + groupBy: this.renderer.last_group_bys, + }; + this.update(params, {adjust_window: false}); + }); + }, +}); diff --git a/hr_planning_resources/static/src/js/timeline_renderer.js b/hr_planning_resources/static/src/js/timeline_renderer.js new file mode 100644 index 0000000..49f8e27 --- /dev/null +++ b/hr_planning_resources/static/src/js/timeline_renderer.js @@ -0,0 +1,15 @@ +odoo.define("hr_task_planner.timeline_renderer", function (require) { + "use strict"; + + const TimelineRenderer = require("web_timeline.TimelineRenderer"); + + TimelineRenderer.include({ + events: _.extend({}, TimelineRenderer.prototype.events, { + "click .oe_hr_task_planner_new_task": "_onNewTask", + }), + _onNewTask: function (ev) { + ev.preventDefault(); + this.on_add(ev, () => {}); + }, + }); +}); diff --git a/hr_planning_resources/static/src/scss/hr_planning_resources.scss b/hr_planning_resources/static/src/scss/hr_planning_resources.scss new file mode 100644 index 0000000..06a84ad --- /dev/null +++ b/hr_planning_resources/static/src/scss/hr_planning_resources.scss @@ -0,0 +1,8 @@ +.oe_timeline_buttons { + padding-left: 16px; + padding-right: 16px; +} + +.oe_timeline_view .vis-timeline .vis-item .vis-item-overflow { + overflow: hidden; +} diff --git a/hr_planning_resources/static/src/xml/web_timeline.xml b/hr_planning_resources/static/src/xml/web_timeline.xml new file mode 100644 index 0000000..3d83ef6 --- /dev/null +++ b/hr_planning_resources/static/src/xml/web_timeline.xml @@ -0,0 +1,18 @@ + + diff --git a/hr_planning_resources/tests/__init_.py b/hr_planning_resources/tests/__init_.py new file mode 100644 index 0000000..e69de29 diff --git a/hr_planning_resources/tests/common.py b/hr_planning_resources/tests/common.py new file mode 100644 index 0000000..4ef6973 --- /dev/null +++ b/hr_planning_resources/tests/common.py @@ -0,0 +1,87 @@ +from odoo.tests import common, tagged + + +@tagged("post_install", "-at_install") +class TestHrPlanningCommon(common.TransactionCase): + def setUp(self): + super().setUp() + + # create a new user + self.john_doe_user = self.env["res.users"].create( + { + "name": "John Doe", + "login": "test_user", + "email": "test_user@example.com", + "password": "test_password", + "company_id": self.env.ref("base.main_company").id, + } + ) + # create a new department + self.department = self.env["hr.department"].create( + { + "name": "Department", + "user_id": self.john_doe_user.id, + } + ) + # create a new employee + self.john_doe_employee = self.env["hr.employee"].create( + { + "name": "John Doe", + "user_id": self.john_doe_user.id, + "department_id": self.department.id, + } + ) + + # create a new project + self.project = self.env["project.project"].create( + { + "name": "Project", + "user_id": self.env.ref("base.user_admin").id, + "user_ids": [(4, self.john_doe_user.id)], + "date_start": "2024-09-01", + "date_end": "2022-10-31", + } + ) + + # create a new task + self.task = self.env["hr.task"].create( + { + "name": "Task", + "employee_id": self.john_doe_employee.id, + "project_id": self.project.id, + "date_start": "2024-09-01", + "date_end": "2024-10-31", + } + ) + + # create a new ticket + self.ticket = self.env["helpdesk.ticket"].create( + { + "name": "Ticket", + "user_id": self.john_doe_user.id, + "user_ids": [(4, self.john_doe_user.id)], + "date_start": "2024-09-01", + "date_end": "2022-10-31", + } + ) + + def create_hr_task(self, ttype="task"): + # Create a new hr.taskk + name = "Task" + if ttype == "project": + name = "Project" + elif ttype == "ticket": + name = "Ticket" + + return self.env["hr.task"].create( + { + "name": name, + "employee_id": self.john_doe_employee.id, + "project_id": (self.project.id if ttype == "project" else False), + "task_id": self.task.id if ttype == "task" else False, + "ticket_id": self.ticket.id if ttype == "ticket" else False, + "type": ttype, + "date_start": "2024-09-01", + "date_end": "2024-10-31", + } + ) diff --git a/hr_planning_resources/tests/test_hr_task.py b/hr_planning_resources/tests/test_hr_task.py new file mode 100644 index 0000000..35a1304 --- /dev/null +++ b/hr_planning_resources/tests/test_hr_task.py @@ -0,0 +1,41 @@ +from .common import TestHrPlanningCommon + + +class TestHrTask(TestHrPlanningCommon): + def test_00_hr_task_type_task(self): + # Create a new hr.task + hr_task_instance = self.create_hr_task() + self.assertEqual(hr_task_instance.name, "") + self.assertEqual(hr_task_instance.employee_id, False) + self.assertEqual(hr_task_instance.project_id, False) + self.assertEqual(hr_task_instance.type, "task") + self.assertEqual(hr_task_instance.task, self.task) + self.assertEqual(hr_task_instance.date_start, False) + self.assertEqual(hr_task_instance.date_end, False) + self.assertEqual(hr_task_instance.planned_hours, 0.0) + self.assertEqual(hr_task_instance.allocated_hours, 0.0) + self.assertEqual(hr_task_instance.allocated_percentage, 100.0) + self.assertEqual(hr_task_instance.working_days_count, 0.0) + self.assertEqual(hr_task_instance.duration, 0.0) + + def test_01_hr_task_type_project(self): + # Create a new hr.task + hr_task_project = self.create_hr_task("project") + self.assertEqual(hr_task_project.name, "Project") + self.assertEqual(hr_task_project.employee_id, self.john_doe_employee) + self.assertEqual(hr_task_project.project_id, self.project) + self.assertEqual(hr_task_project.task_id, False) + self.assertEqual(hr_task_project.ticket_id, False) + self.assertEqual(hr_task_project.date_start, "2024-09-01") + self.assertEqual(hr_task_project.date_end, "2024-10-31") + + def test_02_hr_task_type_ticket(self): + # Create a new hr.task + hr_task_ticket = self.create_hr_task("ticket") + self.assertEqual(hr_task_ticket.name, "Task") + self.assertEqual(hr_task_ticket.employee_id, self.john_doe_employee) + self.assertEqual(hr_task_ticket.project_id, False) + self.assertEqual(hr_task_ticket.task_id, False) + self.assertEqual(hr_task_ticket.ticket_id, self.ticket) + self.assertEqual(hr_task_ticket.date_start, "2024-09-01") + self.assertEqual(hr_task_ticket.date_end, "2024-10-31") diff --git a/hr_planning_resources/tests/test_hr_task_avg.py b/hr_planning_resources/tests/test_hr_task_avg.py new file mode 100644 index 0000000..fb35dce --- /dev/null +++ b/hr_planning_resources/tests/test_hr_task_avg.py @@ -0,0 +1,15 @@ +from . import common + + +class TestHrTaskAvg(common.HrPlanningResourcesCommon): + def setUp(self): + super().setUp() + + # create a new hr.task + self.hr_task = self.create_hr_task() + + def test_hr_task_avg(self): + self.hr_task.write({"allocated_hours": 100.0}) + self.assertEqual(self.hr_task.allocated_percentage, 100.0) + self.hr_task.write({"allocated_hours": 50.0}) + self.assertEqual(self.hr_task.allocated_percentage, 50.0) diff --git a/hr_planning_resources/views/hr_task_views.xml b/hr_planning_resources/views/hr_task_views.xml new file mode 100644 index 0000000..81545fe --- /dev/null +++ b/hr_planning_resources/views/hr_task_views.xml @@ -0,0 +1,345 @@ + + + + hr.task.view.tree + hr.task + + + + + + + + + + + + + + hr.task.form.view + hr.task + +
+
+
+ + + + + +
+ + + +
+ +
+
+ + hr.task.timeline.view + hr.task + timeline + + + + + + + + + + + +
+ + + + + + +
+
+
+
+
+
+ + hr.task.search.view + hr.task + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Planning resource + hr.task + timeline,tree,form,search + + + My Planning + hr.task + timeline,tree,form,search + {'search_default_my_shifts': 1} + + + My Department + hr.task + timeline,tree,form,search + {'search_default_my_department': 1} + + + +
diff --git a/hr_planning_resources/views/menus.xml b/hr_planning_resources/views/menus.xml new file mode 100644 index 0000000..a4371d3 --- /dev/null +++ b/hr_planning_resources/views/menus.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + diff --git a/hr_planning_resources/views/project_views.xml b/hr_planning_resources/views/project_views.xml new file mode 100644 index 0000000..6b9d8f1 --- /dev/null +++ b/hr_planning_resources/views/project_views.xml @@ -0,0 +1,54 @@ + + + + + view.edit.project.form + project.project + + + + + + @@ -25,11 +46,30 @@ project.task + + + + + - - - - - - - - view.project.task.form - project.task - - - - - - - - - - -