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
-
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