diff --git a/addons/hr/data/hr_data.xml b/addons/hr/data/hr_data.xml index fc9d9d5942c..a6487ffe2b7 100644 --- a/addons/hr/data/hr_data.xml +++ b/addons/hr/data/hr_data.xml @@ -109,5 +109,23 @@ days -1 + + + Home + home + + + + + Office + office + + + + + Other + other + + diff --git a/addons/hr/data/hr_demo.xml b/addons/hr/data/hr_demo.xml index 0ce3e518753..a42f208acc5 100644 --- a/addons/hr/data/hr_demo.xml +++ b/addons/hr/data/hr_demo.xml @@ -145,10 +145,11 @@ If you have development competencies, we can propose you specific traineeships - - Building 1, Second Floor - - + + Building 1, Second Floor + office + + diff --git a/addons/hr/models/hr_employee_base.py b/addons/hr/models/hr_employee_base.py index c89a72dec6c..9b6378038be 100644 --- a/addons/hr/models/hr_employee_base.py +++ b/addons/hr/models/hr_employee_base.py @@ -31,9 +31,7 @@ class HrEmployeeBase(models.AbstractModel): mobile_phone = fields.Char('Work Mobile', compute="_compute_work_contact_details", store=True, inverse='_inverse_work_contact_details') work_email = fields.Char('Work Email', compute="_compute_work_contact_details", store=True, inverse='_inverse_work_contact_details') work_contact_id = fields.Many2one('res.partner', 'Work Contact', copy=False) - work_location_id = fields.Many2one('hr.work.location', 'Work Location', compute="_compute_work_location_id", store=True, readonly=False, - check_company=True, - domain="[('address_id', '=', address_id)]") + work_location_id = fields.Many2one('hr.work.location', 'Work Location') user_id = fields.Many2one('res.users') resource_id = fields.Many2one('resource.resource') resource_calendar_id = fields.Many2one('resource.calendar', check_company=True) @@ -212,11 +210,6 @@ class HrEmployeeBase(models.AbstractModel): employee.hr_icon_display = icon employee.show_hr_icon_display = show_icon - @api.depends('address_id') - def _compute_work_location_id(self): - to_reset = self.filtered(lambda e: e.address_id != e.work_location_id.address_id) - to_reset.work_location_id = False - @api.model def _get_employee_working_now(self): working_now = [] diff --git a/addons/hr/models/hr_work_location.py b/addons/hr/models/hr_work_location.py index e576bfda468..bbba32028e2 100644 --- a/addons/hr/models/hr_work_location.py +++ b/addons/hr/models/hr_work_location.py @@ -12,5 +12,9 @@ class WorkLocation(models.Model): active = fields.Boolean(default=True) name = fields.Char(string="Work Location", required=True) company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company) + location_type = fields.Selection([ + ('home', 'Home'), + ('office', 'Office'), + ('other', 'Other')], string='Cover Image', default='office', required=True) address_id = fields.Many2one('res.partner', required=True, string="Work Address", check_company=True) location_number = fields.Char() diff --git a/addons/hr/populate/hr.py b/addons/hr/populate/hr.py index b0cc09926a8..c32f5eab19e 100644 --- a/addons/hr/populate/hr.py +++ b/addons/hr/populate/hr.py @@ -55,7 +55,6 @@ class HrWorkLocation(models.Model): _inherit = 'hr.work.location' _populate_sizes = {'small': 2, 'medium': 5, 'large': 20} - def _populate_factories(self): address_id = self.env.ref('base.main_partner').id diff --git a/addons/hr/static/src/components/radio_image_field/radio_image_field.js b/addons/hr/static/src/components/radio_image_field/radio_image_field.js new file mode 100644 index 00000000000..3733e37a2f5 --- /dev/null +++ b/addons/hr/static/src/components/radio_image_field/radio_image_field.js @@ -0,0 +1,17 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { RadioField, preloadRadio, radioField } from "@web/views/fields/radio/radio_field"; + +class RadioImageField extends RadioField {} +RadioImageField.template = "hr_homeworking.RadioImageField"; + +registry.category("fields").add("hr_homeworking_radio_image", { + ...radioField, + component: RadioImageField, +}); + +registry.category("preloadedData").add("hr_homeworking_radio_image", { + loadOnTypes: ["many2one"], + preload: preloadRadio, +}); diff --git a/addons/hr/static/src/components/radio_image_field/radio_image_field.xml b/addons/hr/static/src/components/radio_image_field/radio_image_field.xml new file mode 100644 index 00000000000..9c79b2ed3b3 --- /dev/null +++ b/addons/hr/static/src/components/radio_image_field/radio_image_field.xml @@ -0,0 +1,36 @@ + + + + +
+ + +
+ + + + + + + + + + +
+
+
+
+
+ + +
diff --git a/addons/hr/static/src/scss/hr.scss b/addons/hr/static/src/scss/hr.scss index 32c9599234f..f45a7deb9ab 100644 --- a/addons/hr/static/src/scss/hr.scss +++ b/addons/hr/static/src/scss/hr.scss @@ -52,6 +52,16 @@ } } +.o_hr_kanban_record { + .fa-2x { + font-size: 1.6em; + } + .fa-2x.fa-circle, + .fa-2x.fa-building { + font-size: 1.3em; + } +} + .o_hr_narrow_field { width: 8rem!important; max-width: 8rem!important; diff --git a/addons/hr/views/hr_employee_public_views.xml b/addons/hr/views/hr_employee_public_views.xml index ef7df51cd3b..8658473cd5f 100644 --- a/addons/hr/views/hr_employee_public_views.xml +++ b/addons/hr/views/hr_employee_public_views.xml @@ -140,47 +140,47 @@
- -
- - - -
-
- - - -
-
- - - -
- -
- - -
-
- +
  • -
  • -
  • +
  • + +
    + + + + + + + + +
    +
    +
  • +
  • + + +
  • +
  • + + +
diff --git a/addons/hr/views/hr_employee_views.xml b/addons/hr/views/hr_employee_views.xml index d58af7979b4..461a973c146 100644 --- a/addons/hr/views/hr_employee_views.xml +++ b/addons/hr/views/hr_employee_views.xml @@ -290,60 +290,51 @@
-
- -
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
  • -
  • -
  • +
  • + +
    + + + + + + + + +
    +
    +
  • +
  • + + +
  • +
  • + + +
diff --git a/addons/hr/views/hr_work_location_views.xml b/addons/hr/views/hr_work_location_views.xml index 4e22d8fdcc5..2b0c3504d7b 100644 --- a/addons/hr/views/hr_work_location_views.xml +++ b/addons/hr/views/hr_work_location_views.xml @@ -8,26 +8,24 @@ - + - - hr.work.location.view.form hr.work.location
- - - + @@ -37,7 +35,6 @@
- Work Locations hr.work.location diff --git a/addons/hr_attendance/models/hr_employee_base.py b/addons/hr_attendance/models/hr_employee_base.py index df3e8134e08..940c537f264 100644 --- a/addons/hr_attendance/models/hr_employee_base.py +++ b/addons/hr_attendance/models/hr_employee_base.py @@ -20,7 +20,7 @@ class HrEmployeeBase(models.AbstractModel): working_now_list = employee_to_check_working._get_employee_working_now() for employee in employees: if employee.attendance_state == "checked_out" and employee.hr_presence_state == "to_define" and \ - employee.id not in working_now_list: + employee.id in working_now_list: employee.hr_presence_state = "absent" elif employee.attendance_state == "checked_in": employee.hr_presence_state = "present" diff --git a/addons/hr_contract/views/hr_contract_views.xml b/addons/hr_contract/views/hr_contract_views.xml index 54b48f0094a..0e61117f9db 100644 --- a/addons/hr_contract/views/hr_contract_views.xml +++ b/addons/hr_contract/views/hr_contract_views.xml @@ -80,9 +80,10 @@ - + + diff --git a/addons/hr_holidays/views/hr_views.xml b/addons/hr_holidays/views/hr_views.xml index 922ef963e12..402afce9e0f 100644 --- a/addons/hr_holidays/views/hr_views.xml +++ b/addons/hr_holidays/views/hr_views.xml @@ -77,8 +77,10 @@ hr.employee - - + + + + @@ -92,21 +94,20 @@ - - -
- - -
- -
- -
+ + + + + + + On Time Off + @@ -123,29 +124,21 @@
- - - -
- - -
- - -
- -
-
- - - + + + + + + + diff --git a/addons/hr_homeworking/__init__.py b/addons/hr_homeworking/__init__.py new file mode 100644 index 00000000000..2ae6446f9dc --- /dev/null +++ b/addons/hr_homeworking/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/addons/hr_homeworking/__manifest__.py b/addons/hr_homeworking/__manifest__.py new file mode 100644 index 00000000000..c9ab3369586 --- /dev/null +++ b/addons/hr_homeworking/__manifest__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Homeworking', + 'version': '1.0', + 'category': 'Human Resources/Homeworking', + 'depends': ['hr', 'calendar'], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'views/hr_employee_views.xml', + 'views/hr_homeworking_views.xml', + 'views/res_users.xml', + 'wizard/calendar_popover_delete_wizard_homework.xml', + ], + 'demo': [ + 'data/hr_homeworking_demo.xml', + ], + 'installable': True, + 'assets': { + 'web.assets_backend': [ + 'hr_homeworking/static/src/**/*', + ], + 'web.qunit_suite_tests': [ + 'hr_homeworking/static/tests/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/hr_homeworking/data/hr_homeworking_demo.xml b/addons/hr_homeworking/data/hr_homeworking_demo.xml new file mode 100644 index 00000000000..0f1102b3ef0 --- /dev/null +++ b/addons/hr_homeworking/data/hr_homeworking_demo.xml @@ -0,0 +1,10 @@ + + + + + GR2 + office + + + + diff --git a/addons/hr_homeworking/models/__init__.py b/addons/hr_homeworking/models/__init__.py new file mode 100644 index 00000000000..f4dc23efd03 --- /dev/null +++ b/addons/hr_homeworking/models/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import hr_employee +from . import res_partner +from . import res_users +from . import hr_homeworking diff --git a/addons/hr_homeworking/models/hr_employee.py b/addons/hr_homeworking/models/hr_employee.py new file mode 100644 index 00000000000..20f444d5d39 --- /dev/null +++ b/addons/hr_homeworking/models/hr_employee.py @@ -0,0 +1,184 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict +from datetime import timedelta +from dateutil.rrule import rrule, WEEKLY + +from odoo import api, fields, models + +from .hr_homeworking import DAYS + + +class HrEmployeeBase(models.AbstractModel): + _inherit = "hr.employee.base" + + monday_location_id = fields.Many2one('hr.work.location', string='Monday') + tuesday_location_id = fields.Many2one('hr.work.location', string='Tuesday') + wednesday_location_id = fields.Many2one('hr.work.location', string='Wednesday') + thursday_location_id = fields.Many2one('hr.work.location', string='Thursday') + friday_location_id = fields.Many2one('hr.work.location', string='Friday') + saturday_location_id = fields.Many2one('hr.work.location', string='Saturday') + sunday_location_id = fields.Many2one('hr.work.location', string='Sunday') + hr_icon_display = fields.Selection(selection_add=[('presence_home', 'At Home'), + ('presence_office', 'At Office'), + ('presence_other', 'At Other')]) + name_work_location_display = fields.Char(compute="_compute_name_work_location_display") + today_location_name = fields.Char() + + @api.model + def _get_current_day_location_field(self): + return DAYS[fields.Date.today().weekday()] + + # hack to allow groupby on today's location. Since there are 7 different fields, we have to use a placeholder + # in the search view and replace it with the correct field every time the views are fetched. + @api.model + def get_views(self, views, options=None): + res = super().get_views(views, options) + dayfield = self._get_current_day_location_field() + if 'search' in res['views']: + res['views']['search']['arch'] = res['views']['search']['arch'].replace('today_location_name', dayfield) + if 'list' in res['views']: + res['views']['list']['arch'] = res['views']['list']['arch'].replace('name_work_location_display', dayfield) + return res + + def _compute_name_work_location_display(self): + dayfield = self._get_current_day_location_field() + for employee in self: + employee.name_work_location_display = employee[dayfield].name + + @api.depends(*DAYS) + def _compute_presence_icon(self): + super()._compute_presence_icon() + dayfield = self._get_current_day_location_field() + for employee in self: + today_employee_location_id = employee[dayfield] + if not today_employee_location_id or employee.hr_icon_display.startswith('presence_holiday'): + continue + employee.hr_icon_display = f'presence_{today_employee_location_id.location_type}' + employee.show_hr_icon_display = True + + def _get_worklocation(self, start_date, end_date): + worklocations = self.env['hr.employee.location'].search([ + ('employee_id', 'in', self.ids), + '|', + '&', '&', + ('start_date', '<=', end_date), + ('start_date', '>=', start_date), + ('weekly', '=', False), + '&', '&', + ('start_date', '<=', end_date), + '|', + ('end_date', '>=', start_date), + ('end_date', '=', False), + ('weekly', '=', True), + ], order='start_date ASC') + worklocation_default = worklocations.filtered('weekly') + worklocations_exception = worklocations - worklocation_default + week_start = int(self.env['res.lang']._lang_get(self.env.user.lang).week_start) - 1 # week_start is 1 indexed + + # 1. Get all exceptional dates in the range + worklocation_data = defaultdict(list) + date_exception = defaultdict(set) + for index, worklocation in enumerate(worklocations_exception): + if not worklocation.removed: + worklocation_data[worklocation.employee_id.id].append({ + 'resModel': "hr.employee.location", + 'idInDB': worklocation.id, + 'id': index, + 'title': worklocation.work_location_name, + 'date': worklocation.start_date, + 'location_id': worklocation.work_location_id.id, + 'weekly': False, + 'icon': worklocation.work_location_type, + 'userId': worklocation.employee_id.user_id.id, + 'partner_id': [worklocation.employee_id.work_contact_id.id] # frontend expects a list (google_calendar) + }) + date_exception[worklocation.employee_id.id].add(worklocation.start_date) + + # 2. Get all weekly (default) dates in the range + index = len(worklocations_exception.ids) + start_date = fields.Datetime.to_datetime(start_date).date() + end_date = fields.Datetime.to_datetime(end_date).date() + for worklocation in worklocation_default: + start = max(worklocation.start_date, start_date) # the recurring location might have been set before our date_range, discard locations before that + end = worklocation.end_date or end_date + days_list = list(rrule(WEEKLY, start, byweekday=worklocation.start_date.weekday(), wkst=week_start, until=end)) + if not days_list: + continue + days_list = [x.date() for x in days_list] + final_days_list = [] + for day in days_list: + if day not in date_exception[worklocation.employee_id.id]: + final_days_list.append(day) + for index_intern, final_day in enumerate(final_days_list): + worklocation_data[worklocation.employee_id.id].append({ + 'resModel': "hr.employee.location", + 'idInDB': worklocation.id, + 'id': index_intern + index, + 'title': worklocation.work_location_name, + 'date': final_day, + 'location_id': worklocation.work_location_id.id, + 'weekly': True, + 'icon': worklocation.work_location_type, + 'userId': worklocation.employee_id.user_id.id, + 'partner_id': [worklocation.employee_id.work_contact_id.id] + }) + index += len(final_days_list) + + for employee in worklocation_data: + worklocation_data[employee].sort(key=lambda wl: (wl['title'], wl['date'])) + return worklocation_data + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + today = fields.Date.today() + homeworking_vals = [] + for employee in res: + for day_index, field_name in enumerate(DAYS): + if not employee[field_name]: + continue + day_offset = (day_index - today.weekday() + 7) % 7 + date = today + timedelta(days=day_offset) + homeworking_vals.append({ + 'employee_id': employee.id, + 'work_location_id': employee[field_name].id, + 'weekly': True, + 'start_date': date + }) + self.env['hr.employee.location'].create(homeworking_vals) + return res + + def write(self, values): + if self.env.context.get('no_loop'): + return super().write(values) + homeworking_vals = [] + employee_locations_to_remove = defaultdict(set) + today = fields.Date.today() + for day_index, field_name in enumerate(DAYS): + if field_name not in values: + continue + if not values[field_name]: + for employee in self: + employee_locations_to_remove[employee.id].add(day_index) + else: + day_offset = (day_index - today.weekday() + 7) % 7 + date = today + timedelta(days=day_offset) + for employee in self: + homeworking_vals.append({ + 'employee_id': employee.id, + 'work_location_id': values[field_name], + 'weekly': True, + 'start_date': date + }) + if homeworking_vals: + self.env['hr.employee.location'].create(homeworking_vals) + if employee_locations_to_remove: + read_group = self.env['hr.employee.location']._read_group([ + ('current_location', '=', True), ('employee_id', 'in', list(employee_locations_to_remove.keys()))], + groupby=['employee_id'], aggregates=['id:recordset'] + ) + for employee, locations in read_group: + if employee.id in employee_locations_to_remove: + locations.filtered(lambda l: l.weekday in employee_locations_to_remove[employee.id]).unlink() + return super().write(values) diff --git a/addons/hr_homeworking/models/hr_homeworking.py b/addons/hr_homeworking/models/hr_homeworking.py new file mode 100644 index 00000000000..270ccf38a81 --- /dev/null +++ b/addons/hr_homeworking/models/hr_homeworking.py @@ -0,0 +1,305 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models +from datetime import timedelta +from odoo.addons.resource.models.utils import string_to_datetime, datetime_to_string +from odoo.exceptions import ValidationError +from dateutil.rrule import rrule, DAILY + +DAYS = ['monday_location_id', 'tuesday_location_id', 'wednesday_location_id', 'thursday_location_id', 'friday_location_id', 'saturday_location_id', 'sunday_location_id'] +DAYS_STRING = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + + +class HrEmployeeLocation(models.Model): + _name = "hr.employee.location" + _description = "Employee Location" + + work_location_id = fields.Many2one('hr.work.location', required=True, string="Location") + work_location_name = fields.Char(related='work_location_id.name', string="Location name") + work_location_type = fields.Selection(related="work_location_id.location_type") + employee_id = fields.Many2one('hr.employee', default=lambda self: self.env.user.employee_id, required=True, ondelete="cascade") + employee_name = fields.Char(related="employee_id.name") + weekday = fields.Integer(compute="_compute_weekday", store=True) + weekly = fields.Boolean(default=False) + start_date = fields.Date(string="Start Date") + today_next_date = fields.Date(compute="_compute_today_next_date") + day_week_string = fields.Char(compute="_compute_day_week_string") + end_date_create = fields.Date(help="used in computations on employee location pop-up in calendar view.") + removed = fields.Boolean(default=False, help="User removed worklocation on one day that has a weekly location") + parent_default_homeworking_id = fields.Many2one("hr.employee.location", help="original weekly worklocation on a removed worklocation", ondelete="cascade") + child_removed_ids = fields.One2many("hr.employee.location", 'parent_default_homeworking_id') + current_location = fields.Boolean(default=True, help="Whether the record is the most recent weekly worklocation") + end_date = fields.Date(help="end date of a previous weekly worklocation (as opposed to current worklocation)") + + _sql_constraints = [ + ('uniq_exceptional_per_day', 'unique(employee_id, weekly, start_date, current_location)', 'Only one default work location and one exceptional work location per day per employee.'), + ] + + @api.constrains("employee_id", "weekday", "current_location") + def _check_current(self): + for record in self: + if record.current_location: + if self.search_count([('employee_id', '=', record.employee_id.id), ('weekday', '=', record.weekday), ('current_location', '=', True)]) > 1: + raise ValidationError(_('Only one current default work location per day per employee. can have some previous default work location')) + + @api.depends('start_date', 'weekly') + def _compute_weekday(self): + for record in self: + if not record.weekly: + continue + if record.start_date: + record.weekday = record.start_date.weekday() + + @api.depends('start_date', 'end_date_create') + def _compute_today_next_date(self): + today = fields.Date().today() + for record in self: + if record.start_date < today: + if record.end_date_create >= today: + record.today_next_date = today + else: + if record.start_date.weekday() <= today.weekday() and today.weekday() <= record.end_date_create.weekday(): + record.today_next_date = today + else: + offset = (record.start_date.weekday() - fields.Date.today().weekday() + 7) % 7 + record.today_next_date = today + timedelta(days=offset) + else: + record.today_next_date = record.start_date + + @api.depends('start_date', 'end_date_create', 'weekly') + def _compute_day_week_string(self): + for record in self: + if record.weekly and record.end_date_create - record.start_date > timedelta(days=7): + raise ValidationError(_('A weekly location cannot have a duration of more than 7 days')) + if (record.start_date > record.end_date_create): + record.end_date_create = record.start_date + weekdays_name = "" + if record.start_date.weekday() > record.end_date_create.weekday(): + for i in range(0, record.end_date_create.weekday()): + weekdays_name = weekdays_name + ", " + DAYS_STRING[i] + for i in range(record.start_date.weekday(), 7): + weekdays_name = weekdays_name + ", " + DAYS_STRING[i] + else: + for i in range(record.start_date.weekday(), record.end_date_create.weekday() + 1): + weekdays_name = weekdays_name + ", " + DAYS_STRING[i] + record.day_week_string = weekdays_name + + def _save_previous_default_worklocation(self): + self.ensure_one() + if self.start_date >= fields.Date().today(): + self.unlink() + else: + self.current_location = False + self.end_date = fields.Date().today() - timedelta(days=1) + self.child_removed_ids.filtered(lambda child: child.start_date >= fields.Date.today()).unlink() + return False + + def _next_weekday_date_after_today(self, date): + today = fields.Date().today() + offset = (date.weekday() - fields.Date.today().weekday() + 7) % 7 + return today + timedelta(days=offset) + + def _removed_worklocation_to_active_worklocation(self, work_location_id): + self.update({ + "removed": False, + "parent_default_homeworking_id": False, + "work_location_id": work_location_id, + }) + + def _check_exceptional_work_location(self, vals): + date = vals.get("start_date", self.start_date) + work = vals.get("work_location_id", self.work_location_id.id) + employee_id = vals.get("employee_id", self.employee_id.id) + exception_work_location = self.env['hr.employee.location'].search([ + ('start_date', '=', date), + ('employee_id.id', '=', employee_id), + ('weekly', '=', False), + ('id', '!=', self.id), + ]) + return exception_work_location, date, work, employee_id + + def add_exceptional_worklocation(self, vals): + exceptional, date, work, employee_id = self._check_exceptional_work_location(vals) + exceptional.ensure_one() + if exceptional: + exceptional.unlink() + self.env['hr.employee.location'].create({ + 'work_location_id': work, + 'employee_id': employee_id, + 'start_date': date, + }) + return True + + def exceptional_worklocation_to_default_worklocation(self, vals, unlink=True): + default_date = vals.get("start_date") + if default_date: + default_date = string_to_datetime(default_date).date() + else: + default_date = self.start_date + date = default_date + default_work = self.env['hr.employee.location'].search([ + ("employee_id.id", "=", self.employee_id.id), + ("weekday", "=", default_date.weekday()), + ("weekly", "=", True), + ("current_location", "=", True) + ]) + self.env['hr.employee.location'].search([ + ("employee_id.id", "=", self.employee_id.id), + ("start_date", "=", date), + ("removed", "=", True), + ]).unlink() + if default_date < fields.Date().today(): + default_date = self._next_weekday_date_after_today(date) + if default_work: + default_work._save_previous_default_worklocation() + if date != default_date: + self.env['hr.employee.location'].create({ + 'employee_id': self.employee_id.id, + 'start_date': default_date, + 'work_location_id': vals.get("work_location_id", self.work_location_id.id), + 'weekly': True + }) + vals["weekly"] = False + if unlink: + self.unlink() + else: + if vals.get("work_location_id"): + self.work_location_id = vals["work_location_id"] + return vals + + def add_removed_work_location(self, date): + self.ensure_one() + self.env['hr.employee.location'].create({ + 'work_location_id': self.work_location_id.id, + 'employee_id': self.employee_id.id, + 'start_date': date, + 'removed': True, + 'parent_default_homeworking_id': self.id, + }) + + def delete_default_worklocation(self): + self.ensure_one() + self._save_previous_default_worklocation() + + def _get_new_worklocation_vals(self, vals): + date = vals.get("start_date") + except_vals = { + "start_date": date, + "work_location_id": vals.get("work_location_id", self.work_location_id.id), + "weekly": vals.get("weekly", self.weekly), + "employee_id": vals.get("employee_id", self.employee_id.id), + } + # to not rewrite the past + self._save_previous_default_worklocation() + values = vals + employeeLocation = self.env['hr.employee.location'] + if date < fields.Date().today(): + employeeLocation.add_exceptional_worklocation(except_vals) + values["start_date"] = employeeLocation._next_weekday_date_after_today(date) + return values + + # create a default worklocation with values in vals and manages the link between the different records + def create_default_worklocation(self, vals, from_create=False, unlink=True): + date = vals.get("start_date", self.start_date) + employee_id = vals.get("employee_id", self.employee_id.id) + work_location_id = vals.get("work_location_id", self.work_location_id.id) + default_work_location = self.env['hr.employee.location'].search([ + ("employee_id.id", "=", employee_id), + ("weekly", "=", True), + ("weekday", "=", date.weekday()), + ("current_location", "=", True), + ("id", "!=", self.id) + ]) + if default_work_location: + val = default_work_location._get_new_worklocation_vals({ + "start_date": date, + "employee_id": employee_id, + "work_location_id": work_location_id + }) + vals["start_date"] = val.get("start_date", date) + if from_create: + return vals + else: + values = { + "start_date": val.get("start_date", date), + "employee_id": employee_id, + "work_location_id": work_location_id, + "weekly": True, + } + return super().create(values) + else: + exceptions, dummy, dummy, dummy = self._check_exceptional_work_location(vals) + if exceptions.filtered(lambda excep: excep.id != self.id): + exceptions.unlink() + if date < fields.Date().today(): + new_values = { + 'start_date': date, + 'employee_id': employee_id, + 'work_location_id': work_location_id, + } + if from_create or (exceptions and exceptions.filtered(lambda excep: excep.id != self.id)): + super().create(new_values) + else: + super().write(new_values) + vals["start_date"] = self._next_weekday_date_after_today(date) + if from_create: + return vals + else: + vals.update({ + "work_location_id": work_location_id, + "weekly": True, + "start_date": date, + "employee_id": employee_id, + }) + return super().create(vals) + + # clean values in vals_list to manage the link between record + def _clean_values_to_create(self, vals_list): + clean_vals_list = [] + for vals in vals_list: + date = string_to_datetime(vals.get("start_date")).date() + employee_id = vals.get("employee_id") or self.env.context.get('default_employee_id') or self.env.user.employee_id.id + if vals.get("weekly"): + clean_vals_list.append(self.create_default_worklocation({ + 'start_date': date, + 'employee_id': employee_id, + 'work_location_id': vals["work_location_id"], + 'weekly': True + }, from_create=True)) + else: + exceptions_worklocation = self.env['hr.employee.location'].search([ + ("employee_id.id", "=", employee_id), + ("weekly", "=", False), + ("start_date", "=", date), + ]) + worklocation_exception = exceptions_worklocation.filtered(lambda a: not a.removed) + if worklocation_exception: + worklocation_exception.work_location_id = vals.get("work_location_id") + else: + removed_worklocation_exception = exceptions_worklocation.filtered(lambda a: a.removed) + if removed_worklocation_exception: + removed_worklocation_exception._removed_worklocation_to_active_worklocation(vals.get("work_location_id")) + else: + clean_vals_list.append(vals) + return clean_vals_list + + @api.model_create_multi + def create(self, vals_list): + extended_vals_list = [] + for vals in vals_list: + if vals.get("end_date_create"): + start_date = string_to_datetime(vals.get("start_date")).date() + end_date = string_to_datetime(vals.get("end_date_create")).date() + days_list = list(rrule(DAILY, start_date, until=end_date)) + vals.pop("end_date_create") + for day in days_list: + extended_vals_list.append({**vals, **{"start_date": datetime_to_string(day)}}) + else: + extended_vals_list.append(vals) + res = super().create(self._clean_values_to_create(extended_vals_list)) + for wl in res: + if wl.weekly: + wl.employee_id.sudo().with_context(no_loop=True).write({DAYS[wl.weekday]: wl.work_location_id}) + if len(vals_list) == 1 and len(extended_vals_list) != 1: + return res[0] + return res diff --git a/addons/hr_homeworking/models/res_partner.py b/addons/hr_homeworking/models/res_partner.py new file mode 100644 index 00000000000..99ab4c03195 --- /dev/null +++ b/addons/hr_homeworking/models/res_partner.py @@ -0,0 +1,25 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + +class ResPartner(models.Model): + _inherit = 'res.partner' + + def _compute_im_status(self): + super()._compute_im_status() + users = self.env["res.users"].search([ + ('partner_id.id', 'in', self.ids)]) + for user in users: + dayfield = self.env['hr.employee']._get_current_day_location_field() + location_type = user[dayfield].location_type + if not location_type: + continue + im_status = user.partner_id.im_status + if im_status == "online" or im_status == "away" or im_status == "offline": + user.partner_id.im_status = location_type + "_" + im_status + + def get_worklocation(self, start_date, end_date): + employee_id = self.env['hr.employee'].search([ + ('work_contact_id.id', 'in', self.ids), + ('company_id.id', '=', self.env.company.id)]) + return employee_id._get_worklocation(start_date, end_date) diff --git a/addons/hr_homeworking/models/res_users.py b/addons/hr_homeworking/models/res_users.py new file mode 100644 index 00000000000..620e177f7ed --- /dev/null +++ b/addons/hr_homeworking/models/res_users.py @@ -0,0 +1,26 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import models, fields + +from .hr_homeworking import DAYS + +class User(models.Model): + _inherit = ['res.users'] + + monday_location_id = fields.Many2one("hr.work.location", related="employee_id.monday_location_id", readonly=False, string='Monday') + tuesday_location_id = fields.Many2one("hr.work.location", related="employee_id.tuesday_location_id", readonly=False, string='Tuesday') + wednesday_location_id = fields.Many2one("hr.work.location", related="employee_id.wednesday_location_id", readonly=False, string='Wednesday') + thursday_location_id = fields.Many2one("hr.work.location", related="employee_id.thursday_location_id", readonly=False, string='Thursday') + friday_location_id = fields.Many2one("hr.work.location", related="employee_id.friday_location_id", readonly=False, string='Friday') + saturday_location_id = fields.Many2one("hr.work.location", related="employee_id.saturday_location_id", readonly=False, string='Saturday') + sunday_location_id = fields.Many2one("hr.work.location", related="employee_id.sunday_location_id", readonly=False, string='Sunday') + + def _get_employee_fields_to_sync(self): + return super()._get_employee_fields_to_sync() + DAYS + + @property + def SELF_READABLE_FIELDS(self): + return super().SELF_READABLE_FIELDS + DAYS + + @property + def SELF_WRITEABLE_FIELDS(self): + return super().SELF_WRITEABLE_FIELDS + DAYS diff --git a/addons/hr_homeworking/security/ir.model.access.csv b/addons/hr_homeworking/security/ir.model.access.csv new file mode 100644 index 00000000000..a356940e04a --- /dev/null +++ b/addons/hr_homeworking/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_employee_location,hr.employee.location,model_hr_employee_location,hr.group_hr_user,1,1,1,1 +access_user_employee_location,hr.employee.location,model_hr_employee_location,base.group_user,1,1,1,1 +acces_hr_popover_delete_wizard,calendar.popover.delete.wizard.homework,model_calendar_popover_delete_wizard_homework,hr.group_hr_user,1,1,1,1 +acces_user_popover_delete_wizard,calendar.popover.delete.wizard.homework,model_calendar_popover_delete_wizard_homework,base.group_user,1,1,1,1 diff --git a/addons/hr_homeworking/security/security.xml b/addons/hr_homeworking/security/security.xml new file mode 100644 index 00000000000..fe8f1fa9475 --- /dev/null +++ b/addons/hr_homeworking/security/security.xml @@ -0,0 +1,40 @@ + + + + + + + homeworking: own + + + [ + ('employee_id', '=', user.employee_id.id) + ] + + + + homeworking: admin + + + [(1, '=', 1)] + + + + homeworking wizard: own + + + [ + ('hr_employee_location_id.employee_id', '=', user.employee_id.id) + ] + + + + homeworking wizard: admin + + + [(1, '=', 1)] + + + + + diff --git a/addons/hr_homeworking/static/src/calendar/common/calendar_common_renderer.js b/addons/hr_homeworking/static/src/calendar/common/calendar_common_renderer.js new file mode 100644 index 00000000000..a61514b663b --- /dev/null +++ b/addons/hr_homeworking/static/src/calendar/common/calendar_common_renderer.js @@ -0,0 +1,204 @@ +/** @odoo-module **/ + +import { AttendeeCalendarCommonRenderer } from "@calendar/views/attendee_calendar/common/attendee_calendar_common_renderer" +import { patch } from "@web/core/utils/patch"; +import { renderToString } from "@web/core/utils/render"; +patch(AttendeeCalendarCommonRenderer.prototype, "hr_homeworking_calendar_common_renderer", { + get options(){ + let a = Object.assign(this._super(), { + columnHeaderHtml: function(date) { + if (this.props.model.scale === 'week'){ + return "
" + moment(date).format("ddd DD") +"
" + renderToString(this.constructor.HeaderCalendarTemplate, {date: date, today : new Date()}); + } + if (this.props.model.scale === 'day'){ + return "
" + moment(date).format("MMMM DD, YYYY") + "
" + renderToString(this.constructor.HeaderCalendarTemplate, {date: date, today : new Date()}); + } + return "
" + moment(date).format("dddd") + "
"; + } + }); + return Object.assign(a, { + eventOrder: function(event1, event2){ + if (event1.extendedProps.worklocation){ + return -1; + } else { + if(event2.extendedProps.worklocation){ + return 1; + } else { + return event1.title.localeCompare(event2.title); + } + } + } + }) + }, + fcEventToRecord(event) { + const res = this._super(...arguments); + res.homework = event.homework; + return res; + }, + filterWorkRecord(arr, modelUserId){ + let events = [] + if (arr.length === 1){ + events.push(this.convertWorkRecordToEvent(arr[0])) + return events; + } + // sort ASC by title location. If it's equals put the event of the current user at first + arr.sort((a,b)=> (a.title > b.title) ? 1 : (a.title < b.title) ? -1 : a.userId === modelUserId ? -1 : b.userId === modelUserId ? 1 : 0) + let previous_title = ""; + for (const event of arr) { + if (previous_title !== event.title) { + events.push(this.convertWorkRecordToEvent(event)) + previous_title = event.title + } + } + return events; + }, + mapRecordsToEvents() { + let event = this._super(...arguments); + let work = []; + if (this.props.model.multiCalendar) { + for (const day in this.props.model.worklocations) { + let events = []; + const home = this.props.model.worklocations[day].home; + const office = this.props.model.worklocations[day].office; + const other = this.props.model.worklocations[day].other; + const modelUserId = this.env.searchModel.userService.userId; + if (home && home.length > 0) { + events = events.concat(this.filterWorkRecord(home,modelUserId)) + } + if (office && office.length > 0) { + events = events.concat(this.filterWorkRecord(office,modelUserId)) + } + if (other && other.length > 0) { + events = events.concat(this.filterWorkRecord(other,modelUserId)) + } + work.push(...events); + } + } else { + work = Object.values(this.props.model.worklocations) + .filter((wl) => wl.display) + .map((wl) => this.convertWorkRecordToEvent(wl)); + } + return event.concat(work); + }, + convertWorkRecordToEvent(record) { + return { + id: record.id, + title: record.title, + start: record.start.toISO(), + end: record.end.plus({ days: 1}).toISO(), + allDay: record.isAllDay, + icon: record.icon, + colorIndex: record.colorIndex, + worklocation: true, + // to avoid to drag location event + editable: false, + }; + }, + onDayRender(info){ + if (this.props.model.scale === 'week' || this.props.model.scale === 'day'){ + var ParseDate = (date) => date.getFullYear() + "-" +(date.getMonth() <10 ? "0" + (date.getMonth() + 1) : (date.getMonth()+1)) + "-" + (date.getDate() <10 ?"0" + date.getDate() : date.getDate()); + let button = info.view.context.calendar.el.querySelector(".fc-day-header[data-date='" + ParseDate(info.date) + "'] .o_month") + let line = info.view.context.calendar.el.querySelector(".fc-day-header[data-date='" + ParseDate(info.date) + "'] .line") + if (!button || !line) + return; + info.homework = true; + button.onclick = () =>this.onDateClick(info) + line.onclick = () =>this.onDateClick(info) + } + if (this.props.model.scale === 'month'){ + var ParseDate = (date) => date.getFullYear() + "-" +(date.getMonth() <10 ? "0" + (date.getMonth() + 1) : (date.getMonth()+1)) + "-" + (date.getDate() <10 ?"0" + date.getDate() : date.getDate()); + let box = info.view.el.querySelector(".fc-day-top[data-date='" + ParseDate(info.date) + "']") + if (!box) + return; + const content = renderToString(this.constructor.ButtonWorklocationTemplate, {}) + const {children } = new DOMParser().parseFromString(content, "text/html").body + box.appendChild(...children) + } + this._super(...arguments); + }, + onEventRender(info) { + const { el, event } = info; + if (event.extendedProps.worklocation) { + el.classList.add("o_homework_event"); + const multiCalendar = this.props.model.multiCalendar; + let injectedContentStr = ""; + const icon = event.extendedProps.icon; + if (multiCalendar) { + const records = this.props.model.worklocations[info.event.start][icon].filter((rec) => rec.title === event.title); + let iconStr; + if (icon === "home") { + iconStr = "fa-home"; + } + else if (icon === "office") { + iconStr = "fa-building"; + } + else { + iconStr = "fa-map-marker"; + } + if (records) { + const obj = {records: records, iconStr: iconStr, multiCalendar: multiCalendar}; + injectedContentStr = renderToString(this.constructor.WorklocationTemplate, obj); + } + } else { + const record = this.props.model.worklocations[info.event.id]; + if (record) { + injectedContentStr = renderToString(this.constructor.WorklocationTemplate, {...record}); + } + } + const domParser = new DOMParser(); + const { children } = domParser.parseFromString(injectedContentStr, "text/html").body; + el.querySelector(".fc-content").replaceWith(...children); + } else { + this._super(...arguments); + } + }, + onDblClick(info) { + if (info.event.extendedProps.worklocation) { + this.onClick(info); + } else { + this._super(...arguments); + } + }, + onClick(info){ + if (info.event.extendedProps.worklocation){ + const elems = document.elementsFromPoint(info.jsEvent.x, info.jsEvent.y) + const day_elem = elems.find((elem) => elem.classList.contains("fc-day","fc-widget-content")) + const day_elem_date = day_elem.getAttribute("data-date") + let wl; + if (this.props.model.multiCalendar) { + const elem = elems.find((elem) => elem.classList.contains("o_homework_content")) + if (!elem){ + return; + } + const id = parseInt(elem.getAttribute("data-id")); + const icon = info.event.extendedProps.icon; + const date = new Date(day_elem_date + " ") // the empty string set's the time to 0 + wl = this.props.model.worklocations[date][icon].find((location) => location.id === id); + } else { + for (const location of Object.values(this.props.model.worklocations)) { + if (location.start.toISODate() === day_elem_date) { + wl = location; + continue; + } + } + } + this.openPopover(info.el, wl); + this.highlightEvent(info.event, "o_cw_custom_highlight"); + } else { + this._super(...arguments); + } + }, + onDateClick(info){ + if (info.jsEvent && info.jsEvent.target.tagName ==="BUTTON") { + info.homework = true + this.props.createRecord(this.fcEventToRecord(info)); + } else { + this._super(...arguments) + } + } +}, + +AttendeeCalendarCommonRenderer.WorklocationTemplate = "hr.homeworking.CalendarCommonRenderer.worklocation", +AttendeeCalendarCommonRenderer.HeaderCalendarTemplate = "hr.homeworking.CalendarCommonRenderer.buttonWorklocation", +AttendeeCalendarCommonRenderer.ButtonWorklocationTemplate = "hr.homeworking.CalendarCommonRenderer.buttonWorklocation", +) diff --git a/addons/hr_homeworking/static/src/calendar/common/calendar_common_renderer.scss b/addons/hr_homeworking/static/src/calendar/common/calendar_common_renderer.scss new file mode 100644 index 00000000000..e764d799b24 --- /dev/null +++ b/addons/hr_homeworking/static/src/calendar/common/calendar_common_renderer.scss @@ -0,0 +1,89 @@ +.btnWorklocation{ + display:flex ; + align-items: center; + opacity: 0; + &:hover { + opacity: 100; + } + .o_month{ + box-shadow: 0.1em 0.1em 0.1em rgb(131, 126, 126); + border-radius: 100px; + font-size: 0.75em; + width: 50%; + border-color: transparent; + } + + .line{ + box-shadow: 0.1em 0.1em 0.1em rgb(131, 126, 126); + border: none; + border-color: transparent; + height: 0.3px; + width:100%; + } +} + +.fc-event { + box-shadow: none !important; +} + +.fc-event.o_homework_event { + background-color: transparent; + border-color: transparent; + box-shadow: none; + cursor: pointer; + .o_homework_single { + display:flex; + align-items: center; + width: 100%; + .fc-content { + margin: 0 !important; + padding: 3px 6px 3px 6px; + border-radius: 100px; + width: auto; + overflow: visible; + opacity: 1; + font-weight: 600; + } + .line{ + height: 0.3em; + width: 100%; + } + } + + .o_homework_multi { + display: flex; + .fc-content .fa-2x { + display: flex; + align-content: center; + flex-wrap: wrap; + justify-content: center; + height: 20px; + width: 20px; + border-radius: 100%; + font-size: 1.2em; + &.fa-building { + font-size: 1.0em; + } + } + } +} + +@for $i from 1 through length($o-colors-complete) { + $color: nth($o-colors-complete, $i); + .o_popover{ + .wl_color_#{$i - 1} { + color: $color; + } + } + .o_homework_multi { + .wl_color_#{$i - 1} { + color: $color; + background-color: scale-color($color, $lightness: 85%); + } + } + .o_homework_single { + .wl_color_#{$i - 1} { + background-color: scale-color($color, $lightness: 50%); + } + } +} \ No newline at end of file diff --git a/addons/hr_homeworking/static/src/calendar/common/calendar_model.js b/addons/hr_homeworking/static/src/calendar/common/calendar_model.js new file mode 100644 index 00000000000..ce5b449e4c7 --- /dev/null +++ b/addons/hr_homeworking/static/src/calendar/common/calendar_model.js @@ -0,0 +1,99 @@ +/** @odoo-module **/ + +import { AttendeeCalendarModel } from "@calendar/views/attendee_calendar/attendee_calendar_model"; +import { serializeDateTime, deserializeDate } from "@web/core/l10n/dates"; +import { patch } from "@web/core/utils/patch"; + +patch(AttendeeCalendarModel.prototype, "calendar_model_calendarApp", { + fetchEventLocation(data) { + let attendeeIds; + const filters = data.filterSections.partner_ids.filters; + if (filters[filters.length - 1].type === "all" && filters[filters.length - 1].active) { + attendeeIds = Object.keys(this.partnerColorMap); + } else { + attendeeIds = data.filterSections.partner_ids.filters + .filter(filter => filter.type !== "all" && filter.value && filter.active) + .map(filter => filter.value) + } + return this.orm.call('res.partner', "get_worklocation", [ + attendeeIds, + serializeDateTime(data.range.start), + serializeDateTime(data.range.end), + ]); + }, + + // returns a map of worklocations, display is used to mark the events that are to be shown in the view. + async loadWorkLocations(data) { + const res = await this.fetchEventLocation(data) + this.multiCalendar = Object.keys(res).length > 1; + const merged = this.multiCalendar ? {} : []; + let previousIcon; + let previousTitle; + let previous = 0; + for (const key in res) { + const rawRecords = res[key]; + for (const rawRecord of rawRecords) { + const normalizedRecord = this.normalizeLocalisationRecord(rawRecord) + if (this.multiCalendar) { + const startDate = normalizedRecord.start.toJSDate() + if (!merged[startDate]) { + merged[startDate] = {} + } + if (!merged[startDate][normalizedRecord.icon]) { + merged[startDate][normalizedRecord.icon] = [] + } + merged[startDate][normalizedRecord.icon].push(normalizedRecord); + } else { + if (rawRecord.icon === previousIcon && rawRecord.title === previousTitle && normalizedRecord.start.diff(merged[previous].end, "days").days == 1) { + merged[previous].end = merged[previous].end.plus({days: 1}); + normalizedRecord.display = false; + } else { + previousIcon = rawRecord.icon; + previousTitle = rawRecord.title; + previous = merged.length; + } + merged.push(normalizedRecord); + } + } + } + return this.multiCalendar ? merged : Object.assign({}, ...merged.map((x) => ({[x.id]: x}))); + }, + + normalizeLocalisationRecord(rawRecord) { + return { + id : rawRecord['id'], + title : rawRecord['title'], + isAllDay: true, + start : deserializeDate(rawRecord['date']), + end : deserializeDate(rawRecord['date']), + duration : 1, + isHatched: false, + isStriked: false, + display: true, + multiCalendar: this.multiCalendar, + icon: rawRecord.icon, + userId: rawRecord.userId, + partner_id: rawRecord.partner_id[0], + colorIndex: this.partnerColorMap[rawRecord.partner_id], + rawRecord, + }; + }, + + get worklocations() { + return this.data.worklocations; + }, + mapPartnersToColor(data) { + return data.filterSections.partner_ids.filters + .filter(filter => filter.type !== "all" && filter.value) + .reduce((map, partner) => ({ ...map, [partner.value]: partner.colorIndex}), {}) + }, + + /** + * @override + */ + async updateData(data){ + await this._super(...arguments) + this.partnerColorMap = this.mapPartnersToColor(data); + data.worklocations = await this.loadWorkLocations(data); + } +}) diff --git a/addons/hr_homeworking/static/src/calendar/common/calender_common_renderer.xml b/addons/hr_homeworking/static/src/calendar/common/calender_common_renderer.xml new file mode 100644 index 00000000000..b7337f66ec9 --- /dev/null +++ b/addons/hr_homeworking/static/src/calendar/common/calender_common_renderer.xml @@ -0,0 +1,43 @@ + + + + + +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ + +
+ + +
+ + +
+ +
+
+ + + +
+ +
+
+ \ No newline at end of file diff --git a/addons/hr_homeworking/static/src/calendar/common/popover/calendar_common_popover.js b/addons/hr_homeworking/static/src/calendar/common/popover/calendar_common_popover.js new file mode 100644 index 00000000000..24cada5a218 --- /dev/null +++ b/addons/hr_homeworking/static/src/calendar/common/popover/calendar_common_popover.js @@ -0,0 +1,50 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import { AttendeeCalendarCommonPopover } from "@calendar/views/attendee_calendar/common/attendee_calendar_common_popover"; +import { Field } from "@web/views/fields/field" + +patch(AttendeeCalendarCommonPopover.prototype, "hr_homeworking_calendar_common_popover",{ + setup() { + this.fieldNames = ["work_location_id", "work_location_name", "work_location_type", "employee_id", "weekday", "weekly", "start_date", "employee_name"]; + this._super(...arguments) + }, + isWorkLocationEvent(){ + return this.props.record.rawRecord['resModel'] === 'hr.employee.location'; + }, + get hasFooter() { + return !this.isWorkLocationEvent() || this.props.record.userId === this.user.userId + }, + isCurrentUserIsOwnerWorklocation(){ + return this.isWorkLocationEvent() && this.props.record.userId === this.slot.record.model.user.userId; + }, + get isEventEditable() { + return ('resModel' in this.props.record.rawRecord) || this._super(...arguments) + }, + get isEventViewable() { + return !('resModel' in this.props.record.rawRecord) || this._super(...arguments) + }, + get isEventDeletable() { + return ('resModel' in this.props.record.rawRecord) || this._super(...arguments) + }, + get displayAttendeeAnswerChoice() { + return !('resModel' in this.props.record.rawRecord) && this._super(...arguments) + }, + get isCurrentUserAttendee() { + return !('resModel' in this.props.record.rawRecord) && this._super(...arguments) + } +}) + +AttendeeCalendarCommonPopover.template = "homework.AttendeeCalendarCommonPopover" + + +AttendeeCalendarCommonPopover.subTemplates = { + ...AttendeeCalendarCommonPopover.subTemplates, + body: "homework.AttendeeCalendarCommonPopover.body", + footer: "homework.AttendeeCalendarCommonPopover.footer", +} + +AttendeeCalendarCommonPopover.components = { + ...AttendeeCalendarCommonPopover.components, + Field +} diff --git a/addons/hr_homeworking/static/src/calendar/common/popover/calendar_common_popover.xml b/addons/hr_homeworking/static/src/calendar/common/popover/calendar_common_popover.xml new file mode 100644 index 00000000000..96623bca081 --- /dev/null +++ b/addons/hr_homeworking/static/src/calendar/common/popover/calendar_common_popover.xml @@ -0,0 +1,75 @@ + + + + + +
+

+ + + + + + + + + + + + +

+ + + +
+
+ + +
+
+
+ + + + + + + +
    +
    + + + - + + + + + +
    +
    + + +
    +
+
+
+ + + + + + + + + + + + + + + + + +
diff --git a/addons/hr_homeworking/static/src/calendar/hr_homeworking_calendar_controller.js b/addons/hr_homeworking/static/src/calendar/hr_homeworking_calendar_controller.js new file mode 100644 index 00000000000..46ad4db6e34 --- /dev/null +++ b/addons/hr_homeworking/static/src/calendar/hr_homeworking_calendar_controller.js @@ -0,0 +1,82 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { AttendeeCalendarController } from "@calendar/views/attendee_calendar/attendee_calendar_controller" +import { serializeDate} from "@web/core/l10n/dates"; + +patch(AttendeeCalendarController.prototype, "hr_homeworking_calendar_controller",{ + setup() { + this._super() + this.action = useService("action"); + }, + async editRecord(record, context = {}, shouldFetchFormViewId = true) { + if ('rawRecord' in record && 'date' in record.rawRecord) { + return this.action.doAction('hr_homeworking.hr_employee_location_action',{ + name: this.env._t("Edit Record"), + additionalContext: { + 'default_start_date': serializeDate(record.start), + 'default_end_date_create': serializeDate(record.end), + 'default_work_location_id' : record.rawRecord.location_id, + 'dialog_size': 'medium', + }, + onClose: async (closeInfo) => { + this.model.load() + }, + }); + } + return this._super(...arguments) + }, + createRecord(record) { + if (record.homework) { + return this.action.doAction('hr_homeworking.hr_employee_location_action',{ + name: this.env._t("Create Record"), + additionalContext: { + 'default_start_date': serializeDate(record.start), + 'default_end_date_create': serializeDate(record.start), + 'dialog_size': 'medium', + }, + onClose: async (closeInfo) => { + this.model.load() + }, + }); + } else { + return this._super(record); + } + }, + deleteRecord(record) { + if ('rawRecord' in record && 'date' in record.rawRecord) { + if (record.rawRecord.weekly) { + this.openRecurringDeletionWizardForHomework(record); + } else { + this.displayDialog(ConfirmationDialog, { + title: this.env._t("Confirmation"), + body: this.env._t("Are you sure you want to delete this work location?"), + confirm: () => { + this.orm.call('hr.employee.location', "unlink", [ + record.rawRecord.idInDB, + ]); + this.model.load(); + }, + cancel: () => { + }, + }); + } + } else { + this._super(...arguments) + } + }, + + openRecurringDeletionWizardForHomework(record) { + this.action.doAction('hr_homeworking.hr_popover_delete_homework_action',{ + additionalContext: { + 'default_hr_employee_location_id': record.rawRecord.idInDB, + 'default_start_date': serializeDate(record.start) + }, + onClose: async (closeInfo) => { + this.model.load() + }, + }); + } +}) diff --git a/addons/hr_homeworking/static/src/im_status_patch.xml b/addons/hr_homeworking/static/src/im_status_patch.xml new file mode 100644 index 00000000000..ee2fe7a83a0 --- /dev/null +++ b/addons/hr_homeworking/static/src/im_status_patch.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + $0 + + $0 + + $0 + + + + + + + + + + + + + + + + + $0 + + $0 + + $0 + + + \ No newline at end of file diff --git a/addons/hr_homeworking/static/tests/hr_org_chart_tests.js b/addons/hr_homeworking/static/tests/hr_org_chart_tests.js new file mode 100644 index 00000000000..e01d7c24a83 --- /dev/null +++ b/addons/hr_homeworking/static/tests/hr_org_chart_tests.js @@ -0,0 +1,13 @@ +/** @odoo-module */ + +import { patch } from "@web/core/utils/patch"; +import { MockServer } from "@web/../tests/helpers/mock_server"; + +patch(MockServer.prototype, "hr_homeworking", { + async _performRPC(route, args) { + if (args.method === "get_worklocation") { + return Promise.resolve() + } + return this._super(...arguments); + }, +}); \ No newline at end of file diff --git a/addons/hr_homeworking/tests/__init__.py b/addons/hr_homeworking/tests/__init__.py new file mode 100644 index 00000000000..bc9a0e215dc --- /dev/null +++ b/addons/hr_homeworking/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hr_employee_location diff --git a/addons/hr_homeworking/tests/common.py b/addons/hr_homeworking/tests/common.py new file mode 100644 index 00000000000..0e946e55f68 --- /dev/null +++ b/addons/hr_homeworking/tests/common.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.tests import common + + +class TestHrHomeworkingCommon(common.TransactionCase): + + @classmethod + def setUpClass(cls): + super(TestHrHomeworkingCommon, cls).setUpClass() + cls.env.user.tz = 'Europe/Brussels' + + # Test users to use through the various tests + cls.user_hruser = mail_new_test_user(cls.env, login='armande', groups='base.group_user,hr.group_hr_user') + cls.user_hruser_id = cls.user_hruser.id + + cls.user_hrmanager = mail_new_test_user(cls.env, login='bastien', groups='base.group_user,hr.group_hr_manager') + cls.user_hrmanager_id = cls.user_hrmanager.id + cls.user_hrmanager.tz = 'Europe/Brussels' + + cls.user_employee = mail_new_test_user(cls.env, login='david', groups='base.group_user') + cls.user_employee_id = cls.user_employee.id + + # Hr Data + Department = cls.env['hr.department'].with_context(tracking_disable=True) + WorkLocation = cls.env['hr.work.location'].with_context(tracking_disable=True) + main_partner_id = cls.env.ref('base.main_partner') + cls.hr_dept = Department.create({ + 'name': 'Human Resources', + }) + cls.rd_dept = Department.create({ + 'name': 'Research and devlopment', + }) + + cls.work_office_1 = WorkLocation.create({ + 'name': "Bureau 1", + 'location_type': "office", + 'address_id': main_partner_id.id, + }) + + cls.work_office_2 = WorkLocation.create({ + 'name': "Bureau 2", + 'location_type': "office", + 'address_id': main_partner_id.id, + }) + + cls.work_home = WorkLocation.create({ + 'name': "Maison", + 'location_type': "home", + 'address_id': main_partner_id.id, + }) + + cls.employee_emp = cls.env['hr.employee'].create({ + 'name': 'David Employee', + 'user_id': cls.user_employee_id, + 'department_id': cls.rd_dept.id, + }) + cls.employee_emp_id = cls.employee_emp.id + + cls.employee_hruser = cls.env['hr.employee'].create({ + 'name': 'Armande HrUser', + 'user_id': cls.user_hruser_id, + 'department_id': cls.rd_dept.id, + }) + cls.employee_hruser_id = cls.employee_hruser.id + + cls.employee_hrmanager = cls.env['hr.employee'].create({ + 'name': 'Bastien HrManager', + 'user_id': cls.user_hrmanager_id, + 'department_id': cls.hr_dept.id, + 'parent_id': cls.employee_hruser_id, + }) + cls.employee_hrmanager_id = cls.employee_hrmanager.id + + cls.rd_dept.write({'manager_id': cls.employee_hruser_id}) diff --git a/addons/hr_homeworking/tests/test_hr_employee_location.py b/addons/hr_homeworking/tests/test_hr_employee_location.py new file mode 100644 index 00000000000..0f17378492a --- /dev/null +++ b/addons/hr_homeworking/tests/test_hr_employee_location.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.hr_homeworking.tests.common import TestHrHomeworkingCommon + +from odoo.tests import tagged +from freezegun import freeze_time +from datetime import datetime + +@tagged('post_install', '-at_install', "homeworking_tests") +class TestHrHomeworkingHrEmployeeLocation(TestHrHomeworkingCommon): + @classmethod + def setUpClass(cls): + super(TestHrHomeworkingHrEmployeeLocation, cls).setUpClass() + cls.HrEmployeeLocation = cls.env['hr.employee.location'] + + @freeze_time('2023-05-16') + def test_register_location_in_the_futur_with_a_previous_worklocation(self): + with freeze_time('2023-05-01'): + self.HrEmployeeLocation.create({ + # tuesday + 'start_date': '2023-05-02', + 'end_date_create': '2023-05-02', + 'work_location_id': self.work_office_1.id, + 'employee_id': self.employee_hruser_id, + 'weekly': True, + }) + self.HrEmployeeLocation.create({ + # tuesday + 'start_date': '2023-05-16', + 'end_date_create': '2023-05-16', + 'work_location_id': self.work_office_1.id, + 'employee_id': self.employee_hruser_id, + 'weekly': True, + }) + self.env['hr.employee.location'].flush_model(['weekday']) + location_for_emp = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_hruser_id), + ]) + self.assertEqual(len(location_for_emp.ids), 2) + location_for_emp_weekly = location_for_emp.filtered(lambda wl: wl.weekly) + self.assertEqual(len(location_for_emp_weekly.ids), 2) + @freeze_time('2023-05-16') + def test_register_location_in_the_futur(self): + # register a location for just one day (wednesday > today) + self.HrEmployeeLocation.create({ + # wednesday + 'start_date': '2023-05-17', + 'end_date_create': '2023-05-17', + 'work_location_id': self.work_office_1.id, + 'employee_id': self.employee_emp_id, + }) + self.env['hr.employee.location'].flush_model(['weekday']) + location_for_emp = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_emp_id), + ]) + self.assertEqual(len(location_for_emp.ids), 1) + + # register a location for just two das (thursday, friday > today) + self.HrEmployeeLocation.create({ + # thursday + 'start_date': '2023-05-18', + # friday + 'end_date_create': '2023-05-19', + 'work_location_id': self.work_office_2.id, + 'employee_id': self.employee_emp_id, + }) + self.env['hr.employee.location'].flush_model(['weekday']) + location_for_emp = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_emp_id), + ]) + self.assertEqual(len(location_for_emp.ids), 3) + + # register a location for every saturday (saturday > today) + self.HrEmployeeLocation.create({ + # saturday + 'start_date': '2023-05-20', + 'end_date_create': '2023-05-20', + 'work_location_id': self.work_office_1.id, + 'employee_id': self.employee_emp_id, + 'weekly': True, + }) + self.env['hr.employee.location'].flush_model(['weekday']) + location_for_emp = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_emp_id), + ]) + self.assertEqual(len(location_for_emp.ids), 4) + self.assertFalse(self.employee_emp.monday_location_id) + self.assertFalse(self.employee_emp.tuesday_location_id) + self.assertFalse(self.employee_emp.wednesday_location_id) + self.assertFalse(self.employee_emp.thursday_location_id) + self.assertFalse(self.employee_emp.friday_location_id) + self.assertEqual(self.employee_emp.saturday_location_id, self.work_office_1) + self.assertFalse(self.employee_hruser.sunday_location_id) + + @freeze_time('2023-05-05') + def test_register_location_for_today(self): + # register a location for just one day (friday == today) + record_N_weekly_one_day = self.HrEmployeeLocation.create({ + # friday + 'start_date': '2023-05-05', + 'end_date_create': '2023-05-05', + 'work_location_id': self.work_office_1.id, + 'employee_id': self.employee_hruser_id, + }) + self.env['hr.employee.location'].flush_model(['weekday']) + self.assertFalse(self.employee_hruser.monday_location_id) + self.assertFalse(self.employee_hruser.tuesday_location_id) + self.assertFalse(self.employee_hruser.wednesday_location_id) + self.assertFalse(self.employee_hruser.thursday_location_id) + self.assertFalse(self.employee_hruser.friday_location_id) + self.assertFalse(self.employee_hruser.saturday_location_id) + self.assertFalse(self.employee_hruser.sunday_location_id) + self.assertFalse(record_N_weekly_one_day.weekday) + self.assertFalse(record_N_weekly_one_day.weekly) + + # register a location for 3 days (friday == today) + self.HrEmployeeLocation.create({ + # friday + 'start_date': '2023-05-05', + # sunday + 'end_date_create': '2023-05-07', + 'work_location_id': self.work_office_2.id, + 'employee_id': self.employee_hruser_id, + }) + self.env['hr.employee.location'].flush_model(['weekday']) + location_for_emp_hruser = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_hruser_id), + ]) + friday_location = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_hruser_id), + ("start_date", "=", datetime(2023, 5, 5)), + ]).work_location_id + self.assertEqual(friday_location, self.work_office_2) + self.assertEqual(len(location_for_emp_hruser.ids), 3) + + # register a location for every friday (friday == today) + self.HrEmployeeLocation.create({ + # friday + 'start_date': '2023-05-05', + 'end_date_create': '2023-05-05', + 'employee_id': self.employee_hruser_id, + 'work_location_id': self.work_home.id, + 'weekly': True + }) + self.env['hr.employee.location'].flush_model(['weekday']) + self.assertFalse(self.employee_hruser.monday_location_id) + self.assertFalse(self.employee_hruser.tuesday_location_id) + self.assertFalse(self.employee_hruser.wednesday_location_id) + self.assertFalse(self.employee_hruser.thursday_location_id) + self.assertEqual(self.employee_hruser.friday_location_id, self.work_home) + self.assertFalse(self.employee_hruser.saturday_location_id) + self.assertFalse(self.employee_hruser.sunday_location_id) + location_for_emp_hruser = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_hruser_id), + ]) + friday_locations = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_hruser_id), + ("start_date", "=", datetime(2023, 5, 5)), + ]) + # the exceptional worlocation is replaced by the default worklocation + self.assertEqual(len(location_for_emp_hruser.ids), 3) + self.assertEqual(len(friday_locations.ids), 1) + + # register a location for every wednesday, thursday and friday (the start of wednesday and thursday are in the past) + # expected behavior: + # 3 weekly record (for wednesday, thurday, friday) + # 4 exceptionnal record (wednesday and thursday before today) + (saturday and sunday) + self.HrEmployeeLocation.create({ + # wednesday + 'start_date': '2023-05-03', + # friday + 'end_date_create': '2023-05-05', + 'employee_id': self.employee_hruser_id, + 'work_location_id': self.work_office_1.id, + 'weekly': True + }) + self.env['hr.employee.location'].flush_model(['weekday']) + self.assertFalse(self.employee_hruser.monday_location_id) + self.assertFalse(self.employee_hruser.tuesday_location_id) + self.assertEqual(self.employee_hruser.wednesday_location_id, self.work_office_1) + self.assertEqual(self.employee_hruser.thursday_location_id, self.work_office_1) + self.assertEqual(self.employee_hruser.friday_location_id, self.work_office_1) + self.assertFalse(self.employee_hruser.saturday_location_id) + self.assertFalse(self.employee_hruser.sunday_location_id) + location_for_emp_hruser = self.HrEmployeeLocation.search([ + ("employee_id", "=", self.employee_hruser_id), + ]) + location_exceptional = location_for_emp_hruser.filtered(lambda wl: not wl.weekly) + self.assertEqual(len(location_for_emp_hruser.ids), 7) + self.assertEqual(len(location_exceptional), 4) diff --git a/addons/hr_homeworking/views/hr_employee_views.xml b/addons/hr_homeworking/views/hr_employee_views.xml new file mode 100644 index 00000000000..15341767ca7 --- /dev/null +++ b/addons/hr_homeworking/views/hr_employee_views.xml @@ -0,0 +1,98 @@ + + + + view.employee.form.inherit.hr + hr.employee + + + + + + + + + + view.employee.form.inherit.hr + hr.employee + + + + 1 + + + + Specify your default work location for each day of the week. This schedules will repeat itself each week. + + + + + + + + + + + + + + hr.employee.tree.timesheet + hr.employee + + + + + + + 1 + + + + + + hr.employee.kanban.inherit.homeworking + hr.employee + + + + + + + + + + + + + + + + + + + + + hr.employee.kanban.inherit.homeworking + hr.employee.public + + + + + + + + + + + + + + + + + diff --git a/addons/hr_homeworking/views/hr_homeworking_views.xml b/addons/hr_homeworking/views/hr_homeworking_views.xml new file mode 100644 index 00000000000..9f1a28ef57a --- /dev/null +++ b/addons/hr_homeworking/views/hr_homeworking_views.xml @@ -0,0 +1,40 @@ + + + + hr.employee.location.view.form + hr.employee.location + +
+ + +
+
+
+
+
+ + + hr.employee.location + form + + + new + +
diff --git a/addons/hr_homeworking/views/res_users.xml b/addons/hr_homeworking/views/res_users.xml new file mode 100644 index 00000000000..74bb1836203 --- /dev/null +++ b/addons/hr_homeworking/views/res_users.xml @@ -0,0 +1,24 @@ + + + + + res.users.preferences.form.inherit + res.users + + + + + Specify your default work location for each day of the week. This schedules will repeat itself each week. + + + + + + + + + + + + + \ No newline at end of file diff --git a/addons/hr_homeworking/wizard/__init__.py b/addons/hr_homeworking/wizard/__init__.py new file mode 100644 index 00000000000..db9df116b87 --- /dev/null +++ b/addons/hr_homeworking/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import calendar_popover_delete_wizard_homework diff --git a/addons/hr_homeworking/wizard/calendar_popover_delete_wizard_homework.py b/addons/hr_homeworking/wizard/calendar_popover_delete_wizard_homework.py new file mode 100644 index 00000000000..8d078f7ba19 --- /dev/null +++ b/addons/hr_homeworking/wizard/calendar_popover_delete_wizard_homework.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class CalendarPopoverDeleteWizardHomework(models.TransientModel): + _name = 'calendar.popover.delete.wizard.homework' + _description = 'Calendar Popover Delete Wizard Homework' + + hr_employee_location_id = fields.Many2one('hr.employee.location', required=True) + start_date = fields.Date(required=True) + delete = fields.Selection([ + ('one', "Delete only this day's work location"), + ('all', "Delete this work location for everyweek")], + default='one', required=True) + + def remove_default_work_location(self): + self.ensure_one() + if self.delete == 'one': + self.hr_employee_location_id.add_removed_work_location(self.start_date) + else: + self.hr_employee_location_id.delete_default_worklocation() diff --git a/addons/hr_homeworking/wizard/calendar_popover_delete_wizard_homework.xml b/addons/hr_homeworking/wizard/calendar_popover_delete_wizard_homework.xml new file mode 100644 index 00000000000..e7f5697b1d3 --- /dev/null +++ b/addons/hr_homeworking/wizard/calendar_popover_delete_wizard_homework.xml @@ -0,0 +1,25 @@ + + + + calendar.popover.delete.wizard.homework.view.form + calendar.popover.delete.wizard.homework + +
+ +
+
+ +
+
+ + + Location wizard + calendar.popover.delete.wizard.homework + form + + + new + +
diff --git a/addons/hr_recruitment/views/hr_applicant_views.xml b/addons/hr_recruitment/views/hr_applicant_views.xml index ac346bafd88..5f98f59ae7d 100644 --- a/addons/hr_recruitment/views/hr_applicant_views.xml +++ b/addons/hr_recruitment/views/hr_applicant_views.xml @@ -582,7 +582,7 @@ - + diff --git a/addons/test_discuss_full/tests/test_performance.py b/addons/test_discuss_full/tests/test_performance.py index 0c691a9f844..bc396434783 100644 --- a/addons/test_discuss_full/tests/test_performance.py +++ b/addons/test_discuss_full/tests/test_performance.py @@ -1016,4 +1016,4 @@ class TestDiscussFullPerformance(TransactionCase): Returns the expected query count. The point of having a separate getter is to allow it to be overriden. """ - return 66 + return 68