19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:00 +01:00
parent a1137a1456
commit e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions

View file

@ -1,15 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import resource
from . import hr_employee
from . import hr_employee_public
from . import hr_department
from . import hr_leave
from . import hr_leave_allocation
from . import hr_leave_type
from . import hr_leave_accrual_plan_level
from . import hr_leave_accrual_plan
from . import hr_leave_stress_day
from . import hr_leave_mandatory_day
from . import hr_version
from . import mail_activity_type
from . import mail_message_subtype
from . import res_partner
from . import res_users
from . import calendar_event

View file

@ -0,0 +1,17 @@
from odoo import models
class CalendarEvent(models.Model):
_inherit = 'calendar.event'
def _need_video_call(self):
""" Determine if the event needs a video call or not depending
on the model of the event.
This method, implemented and invoked in google_calendar, is necessary
due to the absence of a bridge module between google_calendar and hr_holidays.
"""
self.ensure_one()
if self.res_model == 'hr.leave':
return False
return super()._need_video_call()

View file

@ -1,16 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.osv import expression
from odoo import fields, models
from odoo.fields import Domain
import ast
class Department(models.Model):
class HrDepartment(models.Model):
_inherit = 'hr.department'
absence_of_today = fields.Integer(
@ -23,26 +22,26 @@ class Department(models.Model):
def _compute_leave_count(self):
Requests = self.env['hr.leave']
Allocations = self.env['hr.leave.allocation']
today_date = datetime.datetime.utcnow().date()
today_date = datetime.now(timezone.utc).date()
today_start = fields.Datetime.to_string(today_date) # get the midnight of the current utc day
today_end = fields.Datetime.to_string(today_date + relativedelta(hours=23, minutes=59, seconds=59))
leave_data = Requests._read_group(
[('department_id', 'in', self.ids),
('state', '=', 'confirm')],
['department_id'], ['department_id'])
['department_id'], ['__count'])
allocation_data = Allocations._read_group(
[('department_id', 'in', self.ids),
('state', '=', 'confirm')],
['department_id'], ['department_id'])
['department_id'], ['__count'])
absence_data = Requests._read_group(
[('department_id', 'in', self.ids), ('state', 'not in', ['cancel', 'refuse']),
[('department_id', 'in', self.ids), ('state', '=', 'validate'),
('date_from', '<=', today_end), ('date_to', '>=', today_start)],
['department_id'], ['department_id'])
['department_id'], ['__count'])
res_leave = dict((data['department_id'][0], data['department_id_count']) for data in leave_data)
res_allocation = dict((data['department_id'][0], data['department_id_count']) for data in allocation_data)
res_absence = dict((data['department_id'][0], data['department_id_count']) for data in absence_data)
res_leave = {department.id: count for department, count in leave_data}
res_allocation = {department.id: count for department, count in allocation_data}
res_absence = {department.id: count for department, count in absence_data}
for department in self:
department.leave_to_approve_count = res_leave.get(department.id, 0)
@ -63,8 +62,7 @@ class Department(models.Model):
action['context'] = {
**self._get_action_context(),
'search_default_active_time_off': 3,
'hide_employee_name': 1,
'holiday_status_name_get': False
'hide_employee_name': 1
}
return action
@ -72,5 +70,5 @@ class Department(models.Model):
action = self.env["ir.actions.actions"]._for_xml_id("hr_holidays.hr_leave_allocation_action_approve_department")
action['context'] = self._get_action_context()
action['context']['search_default_second_approval'] = 3
action['domain'] = expression.AND([ast.literal_eval(action['domain']), [('state', '=', 'confirm')]])
action['domain'] = Domain.AND([ast.literal_eval(action['domain']), [('state', '=', 'confirm')]])
return action

View file

@ -1,87 +1,71 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from ast import literal_eval
from datetime import date, datetime, time, timedelta, timezone
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_round
from odoo.addons.resource.models.resource import HOURS_PER_DAY
import pytz
class HrEmployeeBase(models.AbstractModel):
_inherit = "hr.employee.base"
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.addons.resource.models.utils import HOURS_PER_DAY
from odoo.tools.float_utils import float_round
class HrEmployee(models.Model):
_inherit = 'hr.employee'
leave_manager_id = fields.Many2one(
'res.users', string='Time Off',
'res.users', string='Time Off Approver',
compute='_compute_leave_manager', store=True, readonly=False,
domain="[('share', '=', False), ('company_ids', 'in', company_id)]",
help='Select the user responsible for approving "Time Off" of this employee.\n'
'If empty, the approval is done by an Administrator or Approver (determined in settings/users).')
remaining_leaves = fields.Float(
compute='_compute_remaining_leaves', string='Remaining Paid Time Off',
help='Total number of paid time off allocated to this employee, change this value to create allocation/time off request. '
'Total based on all the time off types without overriding limit.')
current_leave_id = fields.Many2one('hr.leave.type', compute='_compute_current_leave', string="Current Time Off Type",
groups="hr.group_hr_user")
current_leave_state = fields.Selection(compute='_compute_leave_status', string="Current Time Off Status",
selection=[
('draft', 'New'),
('confirm', 'Waiting Approval'),
('refuse', 'Refused'),
('validate1', 'Waiting Second Approval'),
('validate', 'Approved'),
('cancel', 'Cancelled')
])
leave_date_from = fields.Date('From Date', compute='_compute_leave_status')
('cancel', 'Cancelled'),
], groups="hr.group_hr_user")
leave_date_from = fields.Date('From Date', compute='_compute_leave_status', groups="hr.group_hr_user")
leave_date_to = fields.Date('To Date', compute='_compute_leave_status')
leaves_count = fields.Float('Number of Time Off', compute='_compute_remaining_leaves')
allocation_count = fields.Float('Total number of days allocated.', compute='_compute_allocation_count')
allocations_count = fields.Integer('Total number of allocations', compute="_compute_allocation_count")
allocation_count = fields.Float('Total number of days allocated.', compute='_compute_allocation_count',
groups="hr.group_hr_user")
allocations_count = fields.Integer('Total number of allocations', compute="_compute_allocation_count",
groups="hr.group_hr_user")
show_leaves = fields.Boolean('Able to see Remaining Time Off', compute='_compute_show_leaves')
is_absent = fields.Boolean('Absent Today', compute='_compute_leave_status', search='_search_absent_employee')
allocation_display = fields.Char(compute='_compute_allocation_count')
allocation_display = fields.Char(compute='_compute_allocation_remaining_display')
allocation_remaining_display = fields.Char(compute='_compute_allocation_remaining_display')
hr_icon_display = fields.Selection(selection_add=[('presence_holiday_absent', 'On leave'),
('presence_holiday_present', 'Present but on leave')])
hr_icon_display = fields.Selection(selection_add=[
('presence_holiday_absent', 'On leave'),
('presence_holiday_present', 'Present but on leave')])
def _get_remaining_leaves(self):
""" Helper to compute the remaining leaves for the current employees
:returns dict where the key is the employee id, and the value is the remain leaves
"""
self._cr.execute("""
SELECT
sum(h.number_of_days) AS days,
h.employee_id
FROM
(
SELECT holiday_status_id, number_of_days,
state, employee_id
FROM hr_leave_allocation
UNION ALL
SELECT holiday_status_id, (number_of_days * -1) as number_of_days,
state, employee_id
FROM hr_leave
) h
join hr_leave_type s ON (s.id=h.holiday_status_id)
WHERE
s.active = true AND h.state='validate' AND
s.requires_allocation='yes' AND
h.employee_id in %s
GROUP BY h.employee_id""", (tuple(self.ids),))
return dict((row['employee_id'], row['days']) for row in self._cr.dictfetchall())
def _compute_current_leave(self):
self.current_leave_id = False
def _compute_remaining_leaves(self):
remaining = {}
if self.ids:
remaining = self._get_remaining_leaves()
for employee in self:
value = float_round(remaining.get(employee.id, 0.0), precision_digits=2)
employee.leaves_count = value
employee.remaining_leaves = value
holidays = self.env['hr.leave'].sudo().search([
('employee_id', 'in', self.ids),
('date_from', '<=', fields.Datetime.now()),
('date_to', '>=', fields.Datetime.now()),
('state', '=', 'validate'),
])
for holiday in holidays:
employee = self.filtered(lambda e: e.id == holiday.employee_id.id)
employee.current_leave_id = holiday.holiday_status_id.id
def _compute_presence_state(self):
super()._compute_presence_state()
employees = self.filtered(lambda employee: employee.hr_presence_state != 'present' and employee.is_absent)
employees.update({'hr_presence_state': 'absent'})
def _compute_allocation_count(self):
# Don't get allocations that are expired
current_date = datetime.date.today()
current_date = date.today()
data = self.env['hr.leave.allocation']._read_group([
('employee_id', 'in', self.ids),
('holiday_status_id.active', '=', True),
@ -91,45 +75,63 @@ class HrEmployeeBase(models.AbstractModel):
'|',
('date_to', '=', False),
('date_to', '>=', current_date),
], ['number_of_days:sum', 'employee_id'], ['employee_id'])
rg_results = dict((d['employee_id'][0], {"employee_id_count": d['employee_id_count'], "number_of_days": d['number_of_days']}) for d in data)
], ['employee_id'], ['__count', 'number_of_days:sum'])
rg_results = {employee.id: (count, days) for employee, count, days in data}
for employee in self:
result = rg_results.get(employee.id)
employee.allocation_count = float_round(result['number_of_days'], precision_digits=2) if result else 0.0
employee.allocation_display = "%g" % employee.allocation_count
employee.allocations_count = result['employee_id_count'] if result else 0.0
count, days = rg_results.get(employee.id, (0, 0))
employee.allocation_count = float_round(days, precision_digits=2)
employee.allocations_count = count
def _compute_allocation_remaining_display(self):
current_date = date.today()
allocations = self.env['hr.leave.allocation'].search([('employee_id', 'in', self.ids)])
leaves_taken = allocations.holiday_status_id._get_employees_days_per_allocation(self.ids)
leaves_taken = self._get_consumed_leaves(allocations.holiday_status_id)[0]
for employee in self:
employee_remaining_leaves = 0
for leave_type in leaves_taken[employee.id]:
if leave_type.requires_allocation == 'no':
employee_max_leaves = 0
for leave_type in leaves_taken[employee]:
if leave_type.requires_allocation == 'no' or leave_type.hide_on_dashboard or not leave_type.active:
continue
for allocation in leaves_taken[employee.id][leave_type]:
if allocation:
virtual_remaining_leaves = leaves_taken[employee.id][leave_type][allocation]['virtual_remaining_leaves']
for allocation in leaves_taken[employee][leave_type]:
if allocation and allocation.date_from <= current_date\
and (not allocation.date_to or allocation.date_to >= current_date):
virtual_remaining_leaves = leaves_taken[employee][leave_type][allocation]['virtual_remaining_leaves']
employee_remaining_leaves += virtual_remaining_leaves\
if leave_type.request_unit in ['day', 'half_day']\
else virtual_remaining_leaves / (employee.resource_calendar_id.hours_per_day or HOURS_PER_DAY)
employee_max_leaves += allocation.number_of_days
employee.allocation_remaining_display = "%g" % float_round(employee_remaining_leaves, precision_digits=2)
def _compute_presence_state(self):
super()._compute_presence_state()
employees = self.filtered(lambda employee: employee.hr_presence_state != 'present' and employee.is_absent)
employees.update({'hr_presence_state': 'absent'})
employee.allocation_display = "%g" % float_round(employee_max_leaves, precision_digits=2)
def _compute_presence_icon(self):
super()._compute_presence_icon()
employees_absent = self.filtered(lambda employee:
employee.hr_presence_state != 'present'
and employee.is_absent)
employees_absent.update({'hr_icon_display': 'presence_holiday_absent'})
employees_present = self.filtered(lambda employee:
employee.hr_presence_state == 'present'
and employee.is_absent)
employees_present.update({'hr_icon_display': 'presence_holiday_present'})
employees_absent = self.filtered(
lambda employee: employee.hr_presence_state != 'present' and employee.is_absent)
employees_absent.update({'hr_icon_display': 'presence_holiday_absent', 'show_hr_icon_display': True})
employees_present = self.filtered(
lambda employee: employee.hr_presence_state == 'present' and employee.is_absent)
employees_present.update({'hr_icon_display': 'presence_holiday_present', 'show_hr_icon_display': True})
def _get_first_working_interval(self, dt):
# find the first working interval after a given date
dt = dt.replace(tzinfo=timezone.utc)
lookahead_days = [7, 30, 90, 180, 365, 730]
work_intervals = None
for lookahead_day in lookahead_days:
periods = self._get_calendar_periods(dt, dt + timedelta(days=lookahead_day))
if not periods:
calendar = self.resource_calendar_id or self.company_id.resource_calendar_id
work_intervals = calendar._work_intervals_batch(
dt, dt + timedelta(days=lookahead_day), resources=self.resource_id)
else:
for period in periods[self]:
start, end, calendar = period
calendar = calendar or self.company_id.resource_calendar_id
work_intervals = calendar._work_intervals_batch(
start, end, resources=self.resource_id)
if work_intervals.get(self.resource_id.id) and work_intervals[self.resource_id.id]._items:
# return start time of the earliest interval
return work_intervals[self.resource_id.id]._items[0][0]
def _compute_leave_status(self):
# Used SUPERUSER_ID to forcefully get status of other user's leave, to bypass record rule
@ -137,20 +139,22 @@ class HrEmployeeBase(models.AbstractModel):
('employee_id', 'in', self.ids),
('date_from', '<=', fields.Datetime.now()),
('date_to', '>=', fields.Datetime.now()),
('holiday_status_id.time_type', '=', 'leave'),
('state', '=', 'validate'),
])
leave_data = {}
for holiday in holidays:
leave_data[holiday.employee_id.id] = {}
leave_data[holiday.employee_id.id]['leave_date_from'] = holiday.date_from.date()
leave_data[holiday.employee_id.id]['leave_date_to'] = holiday.date_to.date()
back_on = holiday.employee_id._get_first_working_interval(holiday.date_to)
leave_data[holiday.employee_id.id]['leave_date_to'] = back_on.date() if back_on else None
leave_data[holiday.employee_id.id]['current_leave_state'] = holiday.state
for employee in self:
employee.leave_date_from = leave_data.get(employee.id, {}).get('leave_date_from')
employee.leave_date_to = leave_data.get(employee.id, {}).get('leave_date_to')
employee.current_leave_state = leave_data.get(employee.id, {}).get('current_leave_state')
employee.is_absent = leave_data.get(employee.id) and leave_data.get(employee.id, {}).get('current_leave_state') in ['validate']
employee.is_absent = leave_data.get(employee.id) and leave_data.get(employee.id).get('current_leave_state') == 'validate'
@api.depends('parent_id')
def _compute_leave_manager(self):
@ -163,7 +167,7 @@ class HrEmployeeBase(models.AbstractModel):
employee.leave_manager_id = False
def _compute_show_leaves(self):
show_leaves = self.env['res.users'].has_group('hr_holidays.group_hr_holidays_user')
show_leaves = self.env.user.has_group('hr_holidays.group_hr_holidays_user')
for employee in self:
if show_leaves or employee.user_id == self.env.user:
employee.show_leaves = True
@ -171,21 +175,19 @@ class HrEmployeeBase(models.AbstractModel):
employee.show_leaves = False
def _search_absent_employee(self, operator, value):
if operator not in ('=', '!=') or not isinstance(value, bool):
raise UserError(_('Operation not supported'))
if operator != 'in':
return NotImplemented
# This search is only used for the 'Absent Today' filter however
# this only returns employees that are absent right now.
today_date = datetime.datetime.utcnow().date()
today_start = fields.Datetime.to_string(today_date)
today_end = fields.Datetime.to_string(today_date + relativedelta(hours=23, minutes=59, seconds=59))
today_start = date.today()
today_end = today_start + timedelta(1)
holidays = self.env['hr.leave'].sudo().search([
('employee_id', '!=', False),
('state', '=', 'validate'),
('date_from', '<=', today_end),
('date_from', '<', today_end),
('date_to', '>=', today_start),
])
operator = ['in', 'not in'][(operator == '=') != value]
return [('id', operator, holidays.mapped('employee_id').ids)]
return [('id', 'in', holidays.employee_id.ids)]
@api.model_create_multi
def create(self, vals_list):
@ -200,10 +202,15 @@ class HrEmployeeBase(models.AbstractModel):
if approver_group and vals.get('leave_manager_id'):
group_updates.append((4, vals['leave_manager_id']))
if group_updates:
approver_group.sudo().write({'users': group_updates})
approver_group.sudo().write({'user_ids': group_updates})
return super().create(vals_list)
def write(self, values):
def write(self, vals):
values = vals
# Prevent the resource calendar of leaves to be updated by a write to
# employee. When this module is enabled the resource calendar of
# leaves are determined by those of the contracts.
self = self.with_context(no_leave_resource_calendar_update=True) # noqa: PLW0642
if 'parent_id' in values:
manager = self.env['hr.employee'].browse(values['parent_id']).user_id
if manager:
@ -218,44 +225,51 @@ class HrEmployeeBase(models.AbstractModel):
old_managers -= leave_manager
approver_group = self.env.ref('hr_holidays.group_hr_holidays_responsible', raise_if_not_found=False)
if approver_group and not leave_manager.has_group('hr_holidays.group_hr_holidays_responsible'):
leave_manager.sudo().write({'groups_id': [(4, approver_group.id)]})
leave_manager.sudo().write({'group_ids': [(4, approver_group.id)]})
res = super(HrEmployeeBase, self).write(values)
res = super().write(values)
# remove users from the Responsible group if they are no longer leave managers
old_managers.sudo()._clean_leave_responsible_users()
# Change the resource calendar of the employee's leaves in the future
# Other modules can disable this behavior by setting the context key
# 'no_leave_resource_calendar_update'
if 'resource_calendar_id' in values and not self.env.context.get('no_leave_resource_calendar_update'):
try:
leaves = self.env['hr.leave'].search([
('employee_id', 'in', self.ids),
('resource_calendar_id', '!=', int(values['resource_calendar_id'])),
('date_from', '>', fields.Datetime.now())])
leaves.write({'resource_calendar_id': values['resource_calendar_id']})
non_hourly_leaves = leaves.filtered(lambda l: not l.request_unit_hours)
non_hourly_leaves.with_context(leave_skip_date_check=True, leave_skip_state_check=True)._compute_date_from_to()
non_hourly_leaves.filtered(lambda l: l.state == 'validate')._validate_leave_request()
except ValidationError:
raise ValidationError(_("Changing this working schedule results in the affected employee(s) not having enough "
"leaves allocated to accomodate for their leaves already taken in the future. Please "
"review this employee's leaves and adjust their allocation accordingly."))
if 'parent_id' in values or 'department_id' in values:
today_date = fields.Datetime.now()
hr_vals = {}
if values.get('parent_id') is not None:
hr_vals['manager_id'] = values['parent_id']
if values.get('department_id') is not None:
hr_vals['department_id'] = values['department_id']
holidays = self.env['hr.leave'].sudo().search(['|', ('state', 'in', ['draft', 'confirm']), ('date_from', '>', today_date), ('employee_id', 'in', self.ids)])
holidays = self.env['hr.leave'].sudo().search([
'|',
('state', '=', 'confirm'),
('date_from', '>', today_date),
('employee_id', 'in', self.ids),
])
holidays.write(hr_vals)
allocations = self.env['hr.leave.allocation'].sudo().search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)])
if values.get('parent_id') is not None:
hr_vals['manager_id'] = values['parent_id']
allocations = self.env['hr.leave.allocation'].sudo().search([
('state', '=', 'confirm'),
('employee_id', 'in', self.ids),
])
allocations.write(hr_vals)
return res
class HrEmployee(models.Model):
_inherit = 'hr.employee'
current_leave_id = fields.Many2one('hr.leave.type', compute='_compute_current_leave', string="Current Time Off Type",
groups="hr.group_hr_user")
def _compute_current_leave(self):
self.current_leave_id = False
holidays = self.env['hr.leave'].sudo().search([
('employee_id', 'in', self.ids),
('date_from', '<=', fields.Datetime.now()),
('date_to', '>=', fields.Datetime.now()),
('state', '=', 'validate'),
])
for holiday in holidays:
employee = self.filtered(lambda e: e.id == holiday.employee_id.id)
employee.current_leave_id = holiday.holiday_status_id.id
def _get_user_m2o_to_empty_on_archived_employees(self):
return super()._get_user_m2o_to_empty_on_archived_employees() + ['leave_manager_id']
@ -271,31 +285,23 @@ class HrEmployee(models.Model):
},
}
def _get_contextual_employee(self):
if self.env.context.get('employee_id'):
return self.browse(self.env.context['employee_id'])
return self.env.user.employee_id
def _is_leave_user(self):
return self == self.env.user.employee_id and self.user_has_groups('hr_holidays.group_hr_holidays_user')
def get_stress_days(self, start_date, end_date):
def get_mandatory_days(self, start_date, end_date):
all_days = {}
self = self or self.env.user.employee_id
stress_days = self._get_stress_days(start_date, end_date)
for stress_day in stress_days:
num_days = (stress_day.end_date - stress_day.start_date).days
mandatory_days = self._get_mandatory_days(start_date, end_date)
for mandatory_day in mandatory_days:
num_days = (mandatory_day.end_date - mandatory_day.start_date).days
for d in range(num_days + 1):
all_days[str(stress_day.start_date + relativedelta(days=d))] = stress_day.color
all_days[str(mandatory_day.start_date + relativedelta(days=d))] = mandatory_day.color
return all_days
@api.model
def get_special_days_data(self, date_start, date_end):
return {
'stressDays': self.get_stress_days_data(date_start, date_end),
'mandatoryDays': self.get_mandatory_days_data(date_start, date_end),
'bankHolidays': self.get_public_holidays_data(date_start, date_end),
}
@ -307,14 +313,30 @@ class HrEmployee(models.Model):
return list(map(lambda bh: {
'id': -bh.id,
'colorIndex': 0,
'end': datetime.datetime.combine(bh.date_to.astimezone(employee_tz), datetime.datetime.max.time()).isoformat(),
'end': datetime.combine(bh.date_to.astimezone(employee_tz), datetime.max.time()).isoformat(),
'endType': "datetime",
'isAllDay': True,
'start': datetime.datetime.combine(bh.date_from.astimezone(employee_tz), datetime.datetime.min.time()).isoformat(),
'start': datetime.combine(bh.date_from.astimezone(employee_tz), datetime.min.time()).isoformat(),
'startType': "datetime",
'title': bh.name,
}, public_holidays))
@api.model
def get_time_off_dashboard_data(self, target_date=None):
dashboard_data = {}
dashboard_data['has_accrual_allocation'] = self.env['hr.leave.type'].has_accrual_allocation()
dashboard_data['allocation_data'] = self.env['hr.leave.type'].get_allocation_data_request(target_date, False)
dashboard_data['allocation_request_amount'] = self.get_allocation_requests_amount()
return dashboard_data
@api.model
def get_allocation_requests_amount(self):
employee = self._get_contextual_employee()
return self.env['hr.leave.allocation'].search_count([
('employee_id', '=', employee.id),
('state', '=', 'confirm'),
])
def _get_public_holidays(self, date_start, date_end):
domain = [
('resource_id', '=', False),
@ -329,37 +351,272 @@ class HrEmployee(models.Model):
return self.env['resource.calendar.leaves'].search(domain)
@api.model
def get_stress_days_data(self, date_start, date_end):
self = self._get_contextual_employee()
stress_days = self._get_stress_days(date_start, date_end).sorted('start_date')
return list(map(lambda sd: {
def get_mandatory_days_data(self, date_start, date_end):
self_with_context = self._get_contextual_employee()
if isinstance(date_start, str):
date_start = datetime.fromisoformat(date_start).replace(tzinfo=None)
elif isinstance(date_start, datetime):
date_start = date_start.replace(tzinfo=None)
if isinstance(date_end, str):
date_end = datetime.fromisoformat(date_end).replace(tzinfo=None)
elif isinstance(date_end, datetime):
date_end = date_end.replace(tzinfo=None)
mandatory_days = self_with_context._get_mandatory_days(date_start, date_end).sorted('start_date')
return [{
'id': -sd.id,
'colorIndex': sd.color,
'end': datetime.datetime.combine(sd.end_date, datetime.datetime.max.time()).isoformat(),
'end': datetime.combine(sd.end_date, datetime.max.time()).isoformat(),
'endType': "datetime",
'isAllDay': True,
'start': datetime.datetime.combine(sd.start_date, datetime.datetime.min.time()).isoformat(),
'start': datetime.combine(sd.start_date, datetime.min.time()).isoformat(),
'startType': "datetime",
'title': sd.name,
}, stress_days))
} for sd in mandatory_days]
def _get_stress_days(self, start_date, end_date):
def _get_mandatory_days(self, start_date, end_date):
domain = [
('start_date', '<=', end_date),
('end_date', '>=', start_date),
('company_id', 'in', self.env.companies.ids),
'|',
('resource_calendar_id', '=', False),
('resource_calendar_id', '=', self.resource_calendar_id.id),
('resource_calendar_id', 'in', self.resource_calendar_id.ids),
]
if self.job_id:
domain += [
('job_ids', 'in', [False] + self.job_id.ids),
]
if self.department_id:
department_ids = self.department_id.ids
domain += [
'|',
('department_ids', '=', False),
('department_ids', 'parent_of', self.department_id.id),
('department_ids', 'parent_of', department_ids),
]
else:
domain += [('department_ids', '=', False)]
return self.env['hr.leave.stress.day'].search(domain)
return self.env['hr.leave.mandatory.day'].search(domain)
@api.model
def _get_contextual_employee(self):
ctx = self.env.context
if self.env.context.get('employee_id') is not None:
return self.browse(ctx.get('employee_id'))
if self.env.context.get('default_employee_id') is not None:
return self.browse(ctx.get('default_employee_id'))
return self.env.user.employee_id
def _get_consumed_leaves(self, leave_types, target_date=False, ignore_future=False):
employees = self or self._get_contextual_employee()
leaves_domain = [
('holiday_status_id', 'in', leave_types.ids),
('employee_id', 'in', employees.ids),
('state', 'in', ['confirm', 'validate1', 'validate']),
]
if self.env.context.get('ignored_leave_ids'):
leaves_domain.append(('id', 'not in', self.env.context.get('ignored_leave_ids')))
if not target_date:
target_date = fields.Date.today()
if ignore_future:
leaves_domain.append(('date_from', '<=', target_date))
leaves = self.env['hr.leave'].search(leaves_domain)
leaves_per_employee_type = defaultdict(lambda: defaultdict(lambda: self.env['hr.leave']))
for leave in leaves:
leaves_per_employee_type[leave.employee_id][leave.holiday_status_id] |= leave
allocations = self.env['hr.leave.allocation'].with_context(active_test=False).search([
('employee_id', 'in', employees.ids),
('holiday_status_id', 'in', leave_types.ids),
('state', '=', 'validate'),
])
allocations_per_employee_type = defaultdict(lambda: defaultdict(lambda: self.env['hr.leave.allocation']))
for allocation in allocations:
allocations_per_employee_type[allocation.employee_id][allocation.holiday_status_id] |= allocation
# _get_consumed_leaves returns a tuple of two dictionnaries.
# 1) The first is a dictionary to map the number of days/hours of leaves taken per allocation
# The structure is the following:
# - KEYS:
# allocation_leaves_consumed
# |--employee_id
# |--holiday_status_id
# |--allocation
# |--virtual_leaves_taken
# |--leaves_taken
# |--virtual_remaining_leaves
# |--remaining_leaves
# |--max_leaves
# |--accrual_bonus
# - VALUES:
# Integer representing the number of (virtual) remaining leaves, (virtual) leaves taken or max leaves
# for each allocation.
# leaves_taken and remaining_leaves only take into account validated leaves, while the "virtual" equivalent are
# also based on leaves in "confirm" or "validate1" state.
# Accrual bonus gives the amount of additional leaves that will have been granted at the given
# target_date in comparison to today.
# The unit is in hour or days depending on the leave type request unit
# 2) The second is a dictionary mapping the remaining days per employee and per leave type that are either
# not taken into account by the allocations, mainly because accruals don't take future leaves into account.
# This is used to warn the user if the leaves they takes bring them above their available limit.
# - KEYS:
# allocation_leaves_consumed
# |--employee_id
# |--holiday_status_id
# |--to_recheck_leaves
# |--excess_days
# |--exceeding_duration
# - VALUES:
# "to_recheck_leaves" stores every leave that is not yet taken into account by the "allocation_leaves_consumed" dictionary.
# "excess_days" represents the excess amount that somehow isn't taken into account by the first dictionary.
# "exceeding_duration" sum up the to_recheck_leaves duration and compares it to the maximum allocated for that time period.
allocations_leaves_consumed = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: 0))))
to_recheck_leaves_per_leave_type = defaultdict(lambda:
defaultdict(lambda: {
'excess_days': defaultdict(lambda: {
'amount': 0,
'is_virtual': True,
}),
'exceeding_duration': 0,
'to_recheck_leaves': self.env['hr.leave']
})
)
for allocation in allocations:
allocation_data = allocations_leaves_consumed[allocation.employee_id][allocation.holiday_status_id][allocation]
future_leaves = 0
if allocation.allocation_type == 'accrual':
future_leaves = allocation._get_future_leaves_on(target_date)
max_leaves = allocation.number_of_hours_display\
if allocation.holiday_status_id.request_unit in ['hour']\
else allocation.number_of_days_display
max_leaves += future_leaves
allocation_data.update({
'max_leaves': max_leaves,
'accrual_bonus': future_leaves,
'virtual_remaining_leaves': max_leaves,
'remaining_leaves': max_leaves,
'leaves_taken': 0,
'virtual_leaves_taken': 0,
})
for employee in employees:
for leave_type in leave_types:
allocations_with_date_to = self.env['hr.leave.allocation']
allocations_without_date_to = self.env['hr.leave.allocation']
for leave_allocation in allocations_per_employee_type[employee][leave_type]:
if leave_allocation.date_to:
allocations_with_date_to |= leave_allocation
else:
allocations_without_date_to |= leave_allocation
sorted_leave_allocations = allocations_with_date_to.sorted(key='date_to') + allocations_without_date_to
if leave_type.request_unit in ['day', 'half_day']:
leave_duration_field = 'number_of_days'
leave_unit = 'days'
else:
leave_duration_field = 'number_of_hours'
leave_unit = 'hours'
leave_type_data = allocations_leaves_consumed[employee][leave_type]
for leave in leaves_per_employee_type[employee][leave_type].sorted('date_from'):
leave_duration = leave[leave_duration_field]
skip_excess = False
if leave.date_from.date() > target_date and sorted_leave_allocations.filtered(lambda a:
a.allocation_type == 'accrual' and
(not a.date_to or a.date_to >= target_date) and
a.date_from <= leave.date_to.date()
):
to_recheck_leaves_per_leave_type[employee][leave_type]['to_recheck_leaves'] |= leave
skip_excess = True
continue
if leave_type.requires_allocation:
for allocation in sorted_leave_allocations:
# We don't want to include future leaves linked to accruals into the total count of available leaves.
# However, we'll need to check if those leaves take more than what will be accrued in total of those days
# to give a warning if the total exceeds what will be accrued.
if allocation.date_from > leave.date_to.date() or (allocation.date_to and allocation.date_to < leave.date_from.date()):
continue
interval_start = max(
leave.date_from,
datetime.combine(allocation.date_from, time.min)
)
interval_end = min(
leave.date_to,
datetime.combine(allocation.date_to, time.max)
if allocation.date_to else leave.date_to
)
duration = leave[leave_duration_field]
if leave.date_from != interval_start or leave.date_to != interval_end:
duration_info = employee._get_calendar_attendances(interval_start.replace(tzinfo=pytz.UTC), interval_end.replace(tzinfo=pytz.UTC))
duration = duration_info['hours' if leave_unit == 'hours' else 'days']
max_allowed_duration = min(
duration,
leave_type_data[allocation]['virtual_remaining_leaves']
)
if not max_allowed_duration:
continue
allocated_time = min(max_allowed_duration, leave_duration)
leave_type_data[allocation]['virtual_leaves_taken'] += allocated_time
leave_type_data[allocation]['virtual_remaining_leaves'] -= allocated_time
if leave.state == 'validate':
leave_type_data[allocation]['leaves_taken'] += allocated_time
leave_type_data[allocation]['remaining_leaves'] -= allocated_time
leave_duration -= allocated_time
if not leave_duration:
break
if round(leave_duration, 2) > 0 and not skip_excess:
to_recheck_leaves_per_leave_type[employee][leave_type]['excess_days'][leave.date_to.date()] = {
'amount': leave_duration,
'is_virtual': leave.state != 'validate',
'leave_id': leave.id,
}
else:
if leave_unit == 'hours':
allocated_time = leave.number_of_hours
else:
allocated_time = leave.number_of_days
leave_type_data[False]['virtual_leaves_taken'] += allocated_time
leave_type_data[False]['virtual_remaining_leaves'] = 0
leave_type_data[False]['remaining_leaves'] = 0
if leave.state == 'validate':
leave_type_data[False]['leaves_taken'] += allocated_time
for employee in to_recheck_leaves_per_leave_type:
for leave_type in to_recheck_leaves_per_leave_type[employee]:
content = to_recheck_leaves_per_leave_type[employee][leave_type]
consumed_content = allocations_leaves_consumed[employee][leave_type]
if content['to_recheck_leaves']:
date_to_simulate = max(content['to_recheck_leaves'].mapped('date_from')).date()
latest_accrual_bonus = 0
date_accrual_bonus = 0
virtual_remaining = 0
additional_leaves_duration = 0
for allocation in consumed_content:
latest_accrual_bonus += allocation and allocation._get_future_leaves_on(date_to_simulate)
date_accrual_bonus += consumed_content[allocation]['accrual_bonus']
virtual_remaining += consumed_content[allocation]['virtual_remaining_leaves']
for leave in content['to_recheck_leaves']:
additional_leaves_duration += leave.number_of_hours if leave_type.request_unit == 'hour' else leave.number_of_days
latest_remaining = virtual_remaining - date_accrual_bonus + latest_accrual_bonus
content['exceeding_duration'] = round(min(0, latest_remaining - additional_leaves_duration), 2)
return (allocations_leaves_consumed, to_recheck_leaves_per_leave_type)
def _get_hours_per_day(self, date_from):
''' Return 24H to handle the case of Fully Flexible (ones without a working calendar)'''
if not self:
return 0
calendars = self._get_calendars(date_from)
return calendars[self.id].hours_per_day if calendars[self.id] else 24
def _get_store_avatar_card_fields(self, target):
return [*super()._get_store_avatar_card_fields(target), "leave_date_to"]

View file

@ -0,0 +1,69 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date, timedelta
from odoo import fields, models
class HrEmployeePublic(models.Model):
_inherit = 'hr.employee.public'
leave_manager_id = fields.Many2one(
'res.users', string='Time Off Approver',
compute='_compute_leave_manager', store=True, readonly=False,
domain="[('share', '=', False), ('company_ids', 'in', company_id)]",
help='Select the user responsible for approving "Time Off" of this employee.\n'
'If empty, the approval is done by an Administrator or Approver (determined in settings/users).')
leave_date_to = fields.Date('To Date', compute='_compute_leave_status')
show_leaves = fields.Boolean('Able to see Remaining Time Off', compute='_compute_show_leaves')
is_absent = fields.Boolean('Absent Today', compute='_compute_leave_status', search='_search_absent_employee')
allocation_display = fields.Char(compute='_compute_allocation_display')
allocation_remaining_display = fields.Char(related='employee_id.allocation_remaining_display')
def _compute_show_leaves(self):
self._compute_from_employee('show_leaves')
def _compute_leave_manager(self):
self._compute_from_employee('leave_manager_id')
def _compute_leave_status(self):
self._compute_from_employee(['leave_date_to', 'is_absent'])
def _search_absent_employee(self, operator, value):
if operator != 'in':
return NotImplemented
# This search is only used for the 'Absent Today' filter however
# this only returns employees that are absent right now.
today_start = date.today()
today_end = today_start + timedelta(1)
holidays = self.env['hr.leave'].sudo().search([
('employee_id', '!=', False),
('state', '=', 'validate'),
('date_from', '<', today_end),
('date_to', '>=', today_start),
])
return [('id', 'in', holidays.employee_id.ids)]
def _compute_allocation_display(self):
self._compute_from_employee('allocation_display')
def action_time_off_dashboard(self):
self.ensure_one()
if self.is_user:
return self.employee_id.action_time_off_dashboard()
def action_open_time_off_calendar(self):
"""Open the time off calendar filtered on this employee."""
self.ensure_one()
action = self.env.ref('hr_holidays.action_my_days_off_dashboard_calendar').sudo().read()[0]
action['domain'] = [('employee_id', '=', self.id)]
ctx = ({
'active_employee_id': self.id,
'search_default_employee_id': [self.id],
'search_default_my_leaves': 0,
'search_default_team': 0,
'search_default_current_year': 1,
'hide_employee_name': 1,
})
action['context'] = ctx
return action

View file

@ -1,28 +1,67 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from calendar import monthrange
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.addons.hr_holidays.models.hr_leave_accrual_plan_level import _get_selection_days
class AccrualPlan(models.Model):
_name = "hr.leave.accrual.plan"
class HrLeaveAccrualPlan(models.Model):
_name = 'hr.leave.accrual.plan'
_description = "Accrual Plan"
active = fields.Boolean(default=True)
name = fields.Char('Name', required=True)
time_off_type_id = fields.Many2one('hr.leave.type', string="Time Off Type",
check_company=True, index='btree_not_null',
help="""Specify if this accrual plan can only be used with this Time Off Type.
Leave empty if this accrual plan can be used with any Time Off Type.""")
employees_count = fields.Integer("Employees", compute='_compute_employee_count')
level_ids = fields.One2many('hr.leave.accrual.level', 'accrual_plan_id', copy=True)
allocation_ids = fields.One2many('hr.leave.allocation', 'accrual_plan_id')
level_ids = fields.One2many('hr.leave.accrual.level', 'accrual_plan_id', copy=True, string="Milestones")
allocation_ids = fields.One2many('hr.leave.allocation', 'accrual_plan_id',
export_string_translation=False)
company_id = fields.Many2one('res.company', string='Company', domain=lambda self: [('id', 'in', self.env.companies.ids)],
compute="_compute_company_id", store="True", readonly=False)
transition_mode = fields.Selection([
('immediately', 'Immediately'),
('end_of_accrual', "After this accrual's period")],
string="Level Transition", default="immediately", required=True,
help="""Immediately: When the date corresponds to the new level, your accrual is automatically computed, granted and you switch to new level
After this accrual's period: When the accrual is complete (a week, a month), and granted, you switch to next level if allocation date corresponds""")
show_transition_mode = fields.Boolean(compute='_compute_show_transition_mode')
export_string_translation=False, default="immediately", required=True)
show_transition_mode = fields.Boolean(compute='_compute_show_transition_mode', export_string_translation=False)
is_based_on_worked_time = fields.Boolean(compute="_compute_is_based_on_worked_time", store=True, readonly=False,
export_string_translation=False,
help="Only excludes requests where the time off type is set as unpaid kind of.")
accrued_gain_time = fields.Selection([
("start", "At the start of the accrual period"),
("end", "At the end of the accrual period")],
export_string_translation=False,
default="end", required=True)
can_be_carryover = fields.Boolean(export_string_translation=False)
carryover_date = fields.Selection([
("year_start", "At the start of the year"),
("allocation", "At the allocation date"),
("other", "Custom date")],
export_string_translation=False,
default="year_start", required=True, string="Carry-Over Time")
carryover_day = fields.Selection(
_get_selection_days, compute='_compute_carryover_day',
export_string_translation=False, store=True, readonly=False, default='1')
carryover_month = fields.Selection([
("1", "January"),
("2", "February"),
("3", "March"),
("4", "April"),
("5", "May"),
("6", "June"),
("7", "July"),
("8", "August"),
("9", "September"),
("10", "October"),
("11", "November"),
("12", "December")
], export_string_translation=False, default=lambda self: str((fields.Date.today()).month))
added_value_type = fields.Selection([('day', 'Days'), ('hour', 'Hours')],
export_string_translation=False, default="day", store=True)
@api.depends('level_ids')
def _compute_show_transition_mode(self):
@ -35,10 +74,10 @@ class AccrualPlan(models.Model):
def _compute_level_count(self):
level_read_group = self.env['hr.leave.accrual.level']._read_group(
[('accrual_plan_id', 'in', self.ids)],
fields=['accrual_plan_id'],
groupby=['accrual_plan_id'],
aggregates=['__count'],
)
mapped_count = {group['accrual_plan_id'][0]: group['accrual_plan_id_count'] for group in level_read_group}
mapped_count = {accrual_plan.id: count for accrual_plan, count in level_read_group}
for plan in self:
plan.level_count = mapped_count.get(plan.id, 0)
@ -46,29 +85,76 @@ class AccrualPlan(models.Model):
def _compute_employee_count(self):
allocations_read_group = self.env['hr.leave.allocation']._read_group(
[('accrual_plan_id', 'in', self.ids)],
['accrual_plan_id', 'employee_count:count_distinct(employee_id)'],
['accrual_plan_id'],
['employee_id:count_distinct'],
)
allocations_dict = {res['accrual_plan_id'][0]: res['employee_count'] for res in allocations_read_group}
allocations_dict = {accrual_plan.id: count for accrual_plan, count in allocations_read_group}
for plan in self:
plan.employees_count = allocations_dict.get(plan.id, 0)
@api.depends('time_off_type_id.company_id')
def _compute_company_id(self):
for accrual_plan in self:
if accrual_plan.time_off_type_id:
accrual_plan.company_id = accrual_plan.time_off_type_id.company_id
else:
accrual_plan.company_id = self.env.company
@api.depends("accrued_gain_time")
def _compute_is_based_on_worked_time(self):
for plan in self:
if plan.accrued_gain_time == "start":
plan.is_based_on_worked_time = False
@api.depends("carryover_month")
def _compute_carryover_day(self):
for plan in self:
# 2020 is a leap year, so monthrange(2020, february) will return [2, 29]
plan.carryover_day = str(min(monthrange(2020, int(plan.carryover_month))[1], int(plan.carryover_day)))
def action_open_accrual_plan_employees(self):
self.ensure_one()
return {
'name': _("Accrual Plan's Employees"),
'type': 'ir.actions.act_window',
'view_mode': 'kanban,tree,form',
'view_mode': 'kanban,list,form',
'res_model': 'hr.employee',
'domain': [('id', 'in', self.allocation_ids.employee_id.ids)],
}
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
default = dict(default or {},
name=_("%s (copy)", self.name))
return super().copy(default=default)
def action_create_accrual_plan_level(self):
return {
'name': self.env._('New Milestone'),
'type': 'ir.actions.act_window',
'res_model': 'hr.leave.accrual.level',
'view_mode': 'form',
'views': [[False, 'form']],
'view_id': self.env.ref('hr_holidays.hr_accrual_level_view_form').id,
'target': 'new',
'context': dict(
self.env.context,
new=True,
default_can_be_carryover=self.can_be_carryover,
default_accrued_gain_time=self.accrued_gain_time,
default_can_modify_value_type=not self.time_off_type_id and not self.level_ids,
default_added_value_type=self.added_value_type,
),
}
def action_open_accrual_plan_level(self, level_id):
return {
'name': self.env._('Milestone Edition'),
'type': 'ir.actions.act_window',
'res_model': 'hr.leave.accrual.level',
'view_mode': 'form',
'views': [[False, 'form']],
'target': 'new',
'res_id': level_id,
}
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", plan.name)) for plan, vals in zip(self, vals_list)]
@api.ondelete(at_uninstall=False)
def _prevent_used_plan_unlink(self):
@ -81,3 +167,10 @@ class AccrualPlan(models.Model):
raise ValidationError(_(
"Some of the accrual plans you're trying to delete are linked to an existing allocation. Delete or cancel them first."
))
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get("name", False):
vals['name'] = self.env._("Unnamed Plan")
return super().create(vals_list)

View file

@ -1,54 +1,49 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
import calendar
from calendar import monthrange
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
from odoo.tools.date_utils import get_timedelta
from odoo.exceptions import ValidationError, UserError
DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
# Used for displaying the days and reversing selection -> integer
DAY_SELECT_VALUES = [str(i) for i in range(1, 29)] + ['last']
DAY_SELECT_SELECTION_NO_LAST = tuple(zip(DAY_SELECT_VALUES, (str(i) for i in range(1, 29))))
def _get_selection_days(self):
return DAY_SELECT_SELECTION_NO_LAST + (("last", _("last day")),)
return [(str(i), str(i)) for i in range(1, 32)]
class AccrualPlanLevel(models.Model):
_name = "hr.leave.accrual.level"
class HrLeaveAccrualLevel(models.Model):
_name = 'hr.leave.accrual.level'
_description = "Accrual Plan Level"
_order = 'sequence asc'
sequence = fields.Integer(
string='sequence', compute='_compute_sequence', store=True,
help='Sequence is generated automatically by start time delta.')
accrual_plan_id = fields.Many2one('hr.leave.accrual.plan', "Accrual Plan", required=True)
start_count = fields.Integer(
"Start after",
help="The accrual starts after a defined period from the allocation start date. This field defines the number of days, months or years after which accrual is used.", default="1")
accrual_plan_id = fields.Many2one('hr.leave.accrual.plan', "Accrual Plan", required=True, index=True, ondelete="cascade", default=lambda self: self.env.context.get("active_id", None))
accrued_gain_time = fields.Selection(related='accrual_plan_id.accrued_gain_time', export_string_translation=False)
start_count = fields.Integer(export_string_translation=False,
help="The accrual starts after a defined period from the allocation start date. This field defines the number of days, months or years after which accrual is used.")
start_type = fields.Selection(
[('day', 'day(s)'),
('month', 'month(s)'),
('year', 'year(s)')],
default='day', string=" ", required=True,
[('day', 'Days'),
('month', 'Months'),
('year', 'Years')],
default='day', required=True, export_string_translation=False,
help="This field defines the unit of time after which the accrual starts.")
is_based_on_worked_time = fields.Boolean("Based on worked time",
help="If checked, the rate will be prorated on time off type where type is set on Working Time in the configuration.")
milestone_date = fields.Selection(
[('creation', 'At allocation creation'),
('after', 'After')],
compute='_compute_milestone_date', inverse='_inverse_milestone_date', readonly=False,
store=True, export_string_translation=False,
default='creation', required=True
)
# Accrue of
added_value = fields.Float(
"Rate", digits=(16, 5), required=True,
help="The number of hours/days that will be incremented in the specified Time Off Type for every period")
added_value_type = fields.Selection(
[('days', 'Days'),
('hours', 'Hours')],
default='days', required=True)
added_value = fields.Float(digits=(16, 5), required=True, default=1, export_string_translation=False)
added_value_type = fields.Selection([
('day', 'Day(s)'),
('hour', 'Hour(s)')
], compute="_compute_added_value_type", inverse="_inverse_added_value_type", precompute=True, store=True, required=True,
readonly=False, export_string_translation=False)
frequency = fields.Selection([
('hourly', 'Hourly'),
('daily', 'Daily'),
('weekly', 'Weekly'),
('bimonthly', 'Twice a month'),
@ -57,85 +52,136 @@ class AccrualPlanLevel(models.Model):
('yearly', 'Yearly'),
], default='daily', required=True, string="Frequency")
week_day = fields.Selection([
('mon', 'Monday'),
('tue', 'Tuesday'),
('wed', 'Wednesday'),
('thu', 'Thursday'),
('fri', 'Friday'),
('sat', 'Saturday'),
('sun', 'Sunday'),
], default='mon', required=True, string="Allocation on")
first_day = fields.Integer(default=1)
first_day_display = fields.Selection(
_get_selection_days, compute='_compute_days_display', inverse='_inverse_first_day_display')
second_day = fields.Integer(default=15)
second_day_display = fields.Selection(
_get_selection_days, compute='_compute_days_display', inverse='_inverse_second_day_display')
first_month_day = fields.Integer(default=1)
first_month_day_display = fields.Selection(
_get_selection_days, compute='_compute_days_display', inverse='_inverse_first_month_day_display')
('0', 'Monday'),
('1', 'Tuesday'),
('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday'),
], default='0', required=True, string="Allocation on")
first_day = fields.Selection(_get_selection_days, default='1', export_string_translation=False)
second_day = fields.Selection(_get_selection_days, default='15', export_string_translation=False)
first_month_day = fields.Selection(
_get_selection_days, compute='_compute_first_month_day', store=True, readonly=False, default='1',
export_string_translation=False)
first_month = fields.Selection([
('jan', 'January'),
('feb', 'February'),
('mar', 'March'),
('apr', 'April'),
('may', 'May'),
('jun', 'June'),
], default="jan")
second_month_day = fields.Integer(default=1)
second_month_day_display = fields.Selection(
_get_selection_days, compute='_compute_days_display', inverse='_inverse_second_month_day_display')
('1', 'January'),
('2', 'February'),
('3', 'March'),
('4', 'April'),
('5', 'May'),
('6', 'June'),
], default="1", export_string_translation=False)
second_month_day = fields.Selection(
_get_selection_days, compute='_compute_second_month_day', store=True, readonly=False, default='1',
export_string_translation=False)
second_month = fields.Selection([
('jul', 'July'),
('aug', 'August'),
('sep', 'September'),
('oct', 'October'),
('nov', 'November'),
('dec', 'December')
], default="jul")
('7', 'July'),
('8', 'August'),
('9', 'September'),
('10', 'October'),
('11', 'November'),
('12', 'December')
], default="7", export_string_translation=False)
yearly_month = fields.Selection([
('jan', 'January'),
('feb', 'February'),
('mar', 'March'),
('apr', 'April'),
('may', 'May'),
('jun', 'June'),
('jul', 'July'),
('aug', 'August'),
('sep', 'September'),
('oct', 'October'),
('nov', 'November'),
('dec', 'December')
], default="jan")
yearly_day = fields.Integer(default=1)
yearly_day_display = fields.Selection(
_get_selection_days, compute='_compute_days_display', inverse='_inverse_yearly_day_display')
('1', 'January'),
('2', 'February'),
('3', 'March'),
('4', 'April'),
('5', 'May'),
('6', 'June'),
('7', 'July'),
('8', 'August'),
('9', 'September'),
('10', 'October'),
('11', 'November'),
('12', 'December')
], default="1", export_string_translation=False)
yearly_day = fields.Selection(
_get_selection_days, compute='_compute_yearly_day', store=True, readonly=False, default='1',
export_string_translation=False)
cap_accrued_time = fields.Boolean(export_string_translation=False,
help="When the field is checked the balance of an allocation using this accrual plan will never exceed the specified amount.")
maximum_leave = fields.Float(
'Limit to', required=False, default=100,
help="Choose a cap for this accrual. 0 means no cap.")
parent_id = fields.Many2one(
'hr.leave.accrual.level', string="Previous Level",
help="If this field is empty, this level is the first one.")
digits=(16, 2), compute="_compute_maximum_leave", default=0, readonly=False, store=True,
help="Choose a cap for this accrual.", export_string_translation=False)
cap_accrued_time_yearly = fields.Boolean(export_string_translation=False,
store=True, readonly=False,
help="When the field is checked the total amount accrued each year will be capped at the specified amount")
maximum_leave_yearly = fields.Float(digits=(16, 2), export_string_translation=False)
can_be_carryover = fields.Boolean(related='accrual_plan_id.can_be_carryover', readonly=True,
export_string_translation=False)
action_with_unused_accruals = fields.Selection(
[('postponed', 'Transferred to the next year'),
('lost', 'Lost')],
string="At the end of the calendar year, unused accruals will be",
default='postponed', required='True')
postpone_max_days = fields.Integer("Maximum amount of accruals to transfer",
help="Set a maximum of days an allocation keeps at the end of the year. 0 for no limit.")
[('lost', 'Lost'),
('all', 'Carried over')],
compute="_compute_action_with_unused_accruals",
store=True,
export_string_translation=False,
default='lost', required=True,
help="When the Carry-Over Time is reached, according to Plan's setting, select what you want "
"to happen with the unused time off: Lost (time will be reset to zero), Carried over (accrued time carried over to "
"the next period.)")
carryover_options = fields.Selection(
[('unlimited', 'Unlimited'),
('limited', 'Up to')],
store=True, readonly=False,
export_string_translation=False,
compute="_compute_carryover_options",
default='unlimited', required=True,
help="You can limit the accrued time carried over for the next period."
)
postpone_max_days = fields.Integer(export_string_translation=False,
help="Set a maximum of accruals an allocation keeps at the end of the year.")
can_modify_value_type = fields.Boolean(compute="_compute_can_modify_value_type", default=False,
export_string_translation=False)
accrual_validity = fields.Boolean(export_string_translation=False, compute="_compute_accrual_validity", store=True, readonly=False)
accrual_validity_count = fields.Integer(
export_string_translation=False,
help="You can define a period of time where the days carried over will be available", default="1")
accrual_validity_type = fields.Selection(
[('day', 'Days'),
('month', 'Months')],
default='day', export_string_translation=False, required=True,
help="This field defines the unit of time after which the accrual ends.")
_sql_constraints = [
('check_dates',
"CHECK( (frequency = 'daily') or"
"(week_day IS NOT NULL AND frequency = 'weekly') or "
"(first_day > 0 AND second_day > first_day AND first_day <= 31 AND second_day <= 31 AND frequency = 'bimonthly') or "
"(first_day > 0 AND first_day <= 31 AND frequency = 'monthly')or "
"(first_month_day > 0 AND first_month_day <= 31 AND second_month_day > 0 AND second_month_day <= 31 AND frequency = 'biyearly') or "
"(yearly_day > 0 AND yearly_day <= 31 AND frequency = 'yearly'))",
"The dates you've set up aren't correct. Please check them."),
('start_count_check', "CHECK( start_count >= 0 )", "You can not start an accrual in the past."),
('added_value_greater_than_zero', 'CHECK(added_value > 0)', 'You must give a rate greater than 0 in accrual plan levels.')
]
_start_count_check = models.Constraint(
"CHECK((start_count > 0 AND milestone_date = 'after') OR (start_count = 0 AND milestone_date = 'creation'))",
'You can not start an accrual in the past.',
)
_added_value_greater_than_zero = models.Constraint(
'CHECK(added_value > 0)',
'You must give a rate greater than 0 in accrual plan levels.',
)
_valid_postpone_max_days_value = models.Constraint(
"CHECK(action_with_unused_accruals <> 'all' OR carryover_options <> 'limited' OR COALESCE(postpone_max_days, 0) > 0)",
'You cannot have a maximum quantity to carryover set to 0.',
)
_valid_accrual_validity_value = models.Constraint(
'CHECK(accrual_validity IS NOT TRUE OR COALESCE(accrual_validity_count, 0) > 0)',
'You cannot have an accrual validity time set to 0.',
)
_valid_yearly_cap_value = models.Constraint(
'CHECK(cap_accrued_time_yearly IS NOT TRUE OR COALESCE(maximum_leave_yearly, 0) > 0)',
'You cannot have a cap on yearly accrued time without setting a maximum amount.',
)
@api.constrains('first_day', 'second_day', 'week_day', 'frequency')
def _check_dates(self):
error_message = ''
for level in self:
if level.frequency == 'weekly' and not level.week_day:
error_message = _("Weekday must be selected to use the frequency weekly")
elif level.frequency == 'bimonthly' and int(level.first_day) >= int(level.second_day):
error_message = _("The first day must be lower than the second day.")
if error_message:
raise ValidationError(error_message)
@api.constrains('cap_accrued_time', 'maximum_leave')
def _check_maximum_leaves(self):
for level in self:
if level.cap_accrued_time and level.maximum_leave <= 0:
raise UserError(self.env._("You cannot have a balance cap on accrued time set to 0."))
@api.depends('start_count', 'start_type')
def _compute_sequence(self):
@ -148,97 +194,124 @@ class AccrualPlanLevel(models.Model):
for level in self:
level.sequence = level.start_count * start_type_multipliers[level.start_type]
@api.depends('first_day', 'second_day', 'first_month_day', 'second_month_day', 'yearly_day')
def _compute_days_display(self):
days_select = _get_selection_days(self)
@api.depends('accrual_plan_id', 'accrual_plan_id.level_ids', 'accrual_plan_id.time_off_type_id')
def _compute_can_modify_value_type(self):
for level in self:
level.first_day_display = days_select[min(level.first_day - 1, 28)][0]
level.second_day_display = days_select[min(level.second_day - 1, 28)][0]
level.first_month_day_display = days_select[min(level.first_month_day - 1, 28)][0]
level.second_month_day_display = days_select[min(level.second_month_day - 1, 28)][0]
level.yearly_day_display = days_select[min(level.yearly_day - 1, 28)][0]
level.can_modify_value_type = not level.accrual_plan_id.time_off_type_id and level.accrual_plan_id.level_ids and level.accrual_plan_id.level_ids[0] == level
def _inverse_first_day_display(self):
def _inverse_added_value_type(self):
for level in self:
if level.first_day_display == 'last':
level.first_day = 31
else:
level.first_day = DAY_SELECT_VALUES.index(level.first_day_display) + 1
if level.accrual_plan_id.level_ids[0] == level:
level.accrual_plan_id.added_value_type = level.added_value_type
def _inverse_second_day_display(self):
@api.depends('accrual_plan_id', 'accrual_plan_id.level_ids', 'accrual_plan_id.added_value_type', 'accrual_plan_id.time_off_type_id')
def _compute_added_value_type(self):
for level in self:
if level.second_day_display == 'last':
level.second_day = 31
else:
level.second_day = DAY_SELECT_VALUES.index(level.second_day_display) + 1
if level.accrual_plan_id.time_off_type_id:
level.added_value_type = "day" if level.accrual_plan_id.time_off_type_id.request_unit in ["day", "half_day"] else "hour"
elif level.accrual_plan_id.level_ids and level.accrual_plan_id.level_ids[0] != level:
level.added_value_type = level.accrual_plan_id.level_ids[0].added_value_type
elif not level.added_value_type:
level.added_value_type = "day" # default value
def _inverse_first_month_day_display(self):
def _set_day(self, day_field, month_field):
for level in self:
if level.first_month_day_display == 'last':
level.first_month_day = 31
else:
level.first_month_day = DAY_SELECT_VALUES.index(level.first_month_day_display) + 1
# 2020 is a leap year, so monthrange(2020, february) will return [2, 29]
level[day_field] = str(min(monthrange(2020, int(level[month_field]))[1], int(level[day_field])))
def _inverse_second_month_day_display(self):
for level in self:
if level.second_month_day_display == 'last':
level.second_month_day = 31
else:
level.second_month_day = DAY_SELECT_VALUES.index(level.second_month_day_display) + 1
@api.depends("first_month")
def _compute_first_month_day(self):
self._set_day("first_month_day", "first_month")
def _inverse_yearly_day_display(self):
@api.depends("second_month")
def _compute_second_month_day(self):
self._set_day("second_month_day", "second_month")
@api.depends("yearly_month")
def _compute_yearly_day(self):
self._set_day("yearly_day", "yearly_month")
@api.depends('cap_accrued_time')
def _compute_maximum_leave(self):
for level in self:
if level.yearly_day_display == 'last':
level.yearly_day = 31
else:
level.yearly_day = DAY_SELECT_VALUES.index(level.yearly_day_display) + 1
if not level.cap_accrued_time:
level.maximum_leave = 0
@api.depends('can_be_carryover')
def _compute_action_with_unused_accruals(self):
for level in self:
if not level.can_be_carryover:
level.action_with_unused_accruals = 'lost'
@api.depends('action_with_unused_accruals')
def _compute_carryover_options(self):
for level in self:
if level.action_with_unused_accruals == 'lost':
level.carryover_options = 'unlimited'
@api.depends('action_with_unused_accruals')
def _compute_accrual_validity(self):
for level in self:
if level.action_with_unused_accruals == 'lost':
level.accrual_validity = False
@api.depends('start_count', 'milestone_date')
def _compute_milestone_date(self):
for level in self:
if level.start_count == 0:
level.milestone_date = 'creation'
def _inverse_milestone_date(self):
for level in self:
if level.milestone_date == 'creation':
level.start_count = 0
def _get_hourly_frequencies(self):
return ['hourly']
def _get_next_date(self, last_call):
"""
Returns the next date with the given last call
"""
self.ensure_one()
if self.frequency == 'daily':
if self.frequency in self._get_hourly_frequencies() + ['daily']:
return last_call + relativedelta(days=1)
elif self.frequency == 'weekly':
daynames = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
weekday = daynames.index(self.week_day)
return last_call + relativedelta(days=1, weekday=weekday)
elif self.frequency == 'bimonthly':
first_date = last_call + relativedelta(day=self.first_day)
second_date = last_call + relativedelta(day=self.second_day)
if self.frequency == 'weekly':
return last_call + relativedelta(days=1, weekday=int(self.week_day))
if self.frequency == 'bimonthly':
first_date = last_call + relativedelta(day=int(self.first_day))
second_date = last_call + relativedelta(day=int(self.second_day))
if last_call < first_date:
return first_date
elif last_call < second_date:
if last_call < second_date:
return second_date
else:
return last_call + relativedelta(months=1, day=self.first_day)
elif self.frequency == 'monthly':
date = last_call + relativedelta(day=self.first_day)
return last_call + relativedelta(day=int(self.first_day), months=1)
if self.frequency == 'monthly':
date = last_call + relativedelta(day=int(self.first_day))
if last_call < date:
return date
else:
return last_call + relativedelta(months=1, day=self.first_day)
elif self.frequency == 'biyearly':
first_month = MONTHS.index(self.first_month) + 1
second_month = MONTHS.index(self.second_month) + 1
first_date = last_call + relativedelta(month=first_month, day=self.first_month_day)
second_date = last_call + relativedelta(month=second_month, day=self.second_month_day)
return last_call + relativedelta(day=int(self.first_day), months=1)
if self.frequency == 'biyearly':
first_date = last_call + relativedelta(month=int(self.first_month), day=int(self.first_month_day))
second_date = last_call + relativedelta(month=int(self.second_month), day=int(self.second_month_day))
if last_call < first_date:
return first_date
elif last_call < second_date:
if last_call < second_date:
return second_date
else:
return last_call + relativedelta(years=1, month=first_month, day=self.first_month_day)
elif self.frequency == 'yearly':
month = MONTHS.index(self.yearly_month) + 1
date = last_call + relativedelta(month=month, day=self.yearly_day)
return last_call + relativedelta(month=int(self.first_month), day=int(self.first_month_day), years=1)
if self.frequency == 'yearly':
date = last_call + relativedelta(month=int(self.yearly_month), day=int(self.yearly_day))
if last_call < date:
return date
else:
return last_call + relativedelta(years=1, month=month, day=self.yearly_day)
else:
return False
return last_call + relativedelta(month=int(self.yearly_month), day=int(self.yearly_day), years=1)
raise ValidationError(_("Your frequency selection is not correct: please choose a frequency between theses options:"
"Hourly, Daily, Weekly, Twice a month, Monthly, Twice a year and Yearly."))
def _get_previous_date(self, last_call):
"""
@ -247,44 +320,52 @@ class AccrualPlanLevel(models.Model):
Contrary to `_get_next_date` this function will return the 01/02 if that date is given
"""
self.ensure_one()
if self.frequency == 'daily':
if self.frequency in self._get_hourly_frequencies() + ['daily']:
return last_call
elif self.frequency == 'weekly':
daynames = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
weekday = daynames.index(self.week_day)
return last_call + relativedelta(days=-6, weekday=weekday)
elif self.frequency == 'bimonthly':
second_date = last_call + relativedelta(day=self.second_day)
first_date = last_call + relativedelta(day=self.first_day)
if self.frequency == 'weekly':
return last_call + relativedelta(days=-6, weekday=int(self.week_day))
if self.frequency == 'bimonthly':
first_date = last_call + relativedelta(day=int(self.first_day))
second_date = last_call + relativedelta(day=int(self.second_day))
if last_call >= second_date:
return second_date
elif last_call >= first_date:
if last_call >= first_date:
return first_date
else:
return last_call + relativedelta(months=-1, day=self.second_day)
elif self.frequency == 'monthly':
date = last_call + relativedelta(day=self.first_day)
return last_call + relativedelta(day=int(self.second_day), months=-1)
if self.frequency == 'monthly':
date = last_call + relativedelta(day=int(self.first_day))
if last_call >= date:
return date
else:
return last_call + relativedelta(months=-1, day=self.first_day)
elif self.frequency == 'biyearly':
first_month = MONTHS.index(self.first_month) + 1
second_month = MONTHS.index(self.second_month) + 1
first_date = last_call + relativedelta(month=first_month, day=self.first_month_day)
second_date = last_call + relativedelta(month=second_month, day=self.second_month_day)
return last_call + relativedelta(day=int(self.first_day), months=-1, days=1)
if self.frequency == 'biyearly':
first_date = last_call + relativedelta(month=int(self.first_month), day=int(self.first_month_day))
second_date = last_call + relativedelta(month=int(self.second_month), day=int(self.second_month_day))
if last_call >= second_date:
return second_date
elif last_call >= first_date:
if last_call >= first_date:
return first_date
else:
return last_call + relativedelta(years=-1, month=second_month, day=self.second_month_day)
elif self.frequency == 'yearly':
month = MONTHS.index(self.yearly_month) + 1
year_date = last_call + relativedelta(month=month, day=self.yearly_day)
return last_call + relativedelta(month=int(self.second_month), day=int(self.second_month_day), years=-1)
if self.frequency == 'yearly':
year_date = last_call + relativedelta(month=int(self.yearly_month), day=int(self.yearly_day))
if last_call >= year_date:
return year_date
else:
return last_call + relativedelta(years=-1, month=month, day=self.yearly_day)
else:
return False
return last_call + relativedelta(month=int(self.yearly_month), day=int(self.yearly_day), years=-1)
raise ValidationError(_("Your frequency selection is not correct: please choose a frequency between theses options:"
"Hourly, Daily, Weekly, Twice a month, Monthly, Twice a year and Yearly."))
def _get_level_transition_date(self, allocation_start):
if self.start_type == 'day':
return allocation_start + relativedelta(days=self.start_count)
if self.start_type == 'month':
return allocation_start + relativedelta(months=self.start_count)
if self.start_type == 'year':
return allocation_start + relativedelta(years=self.start_count)
def action_save_new(self):
return self.accrual_plan_id.action_create_accrual_plan_level()

View file

@ -1,12 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import randint
from odoo import fields, models
from odoo import api, fields, models
class StressDay(models.Model):
_name = 'hr.leave.stress.day'
_description = 'Stress Day'
class HrLeaveMandatoryDay(models.Model):
_name = 'hr.leave.mandatory.day'
_description = 'Mandatory Day'
_order = 'start_date desc, end_date desc'
name = fields.Char(required=True)
@ -18,7 +18,9 @@ class StressDay(models.Model):
'resource.calendar', 'Working Hours',
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
department_ids = fields.Many2many('hr.department', string="Departments")
job_ids = fields.Many2many('hr.job', string="Job Position")
_sql_constraints = [
('date_from_after_day_to', 'CHECK(start_date <= end_date)', 'The start date must be anterior than the end date.')
]
_date_from_after_day_to = models.Constraint(
'CHECK(start_date <= end_date)',
'The start date must be anterior than the end date.',
)

View file

@ -0,0 +1,185 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
from odoo import api, fields, models
from odoo.fields import Domain
from odoo.exceptions import ValidationError
class HrVersion(models.Model):
""" Write and Create:
Special case when setting a contract as running:
If there is already a validated time off over another contract
with a different schedule, split the time off, before the
_check_contracts raises an issue.
If there are existing leaves that are spanned by this new
contract, update their resource calendar to the current one.
"""
# TODO BIOUTIFY ME (the whole file :)
_inherit = 'hr.version'
_description = 'Employee Contract'
@api.constrains('contract_date_start', 'contract_date_end')
def _check_contracts(self):
self._get_leaves()._check_contracts()
@api.model_create_multi
def create(self, vals_list):
all_new_leave_origin = []
all_new_leave_vals = []
leaves_state = {}
created_versions = self.env['hr.version']
for vals in vals_list:
if not 'employee_id' in vals or not 'resource_calendar_id' in vals:
created_versions |= super().create(vals)
continue
leaves = self._get_leaves_from_vals(vals)
is_created = False
for leave in leaves:
leaves_state = self._refuse_leave(leave, leaves_state) if leave.request_date_from < vals['contract_date_start'] else self._set_leave_draft(leave, leaves_state)
if not is_created:
created_versions |= super().create([vals])
is_created = True
overlapping_contracts = self._check_overlapping_contract(leave)
if not overlapping_contracts:
# When the leave is set to draft
leave._compute_date_from_to()
continue
all_new_leave_origin, all_new_leave_vals = self._populate_all_new_leave_vals_from_split_leave(
all_new_leave_origin, all_new_leave_vals, overlapping_contracts, leave, leaves_state)
# TODO FIXME
# to keep creation order, not ideal but ok for now.
if not is_created:
created_versions |= super().create([vals])
try:
if all_new_leave_vals:
self._create_all_new_leave(all_new_leave_origin, all_new_leave_vals)
except ValidationError:
# In case a validation error is thrown due to holiday creation with the new resource calendar (which can
# increase their duration), we catch this error to display a more meaningful error message.
raise ValidationError(
self.env._("Changing the contract on this employee changes their working schedule in a period "
"they already took leaves. Changing this working schedule changes the duration of "
"these leaves in such a way the employee no longer has the required allocation for "
"them. Please review these leaves and/or allocations before changing the contract."))
return created_versions
def write(self, vals):
specific_contracts = self.env['hr.version']
if any(field in vals for field in ['contract_date_start', 'contract_date_end', 'date_version', 'resource_calendar_id']):
all_new_leave_origin = []
all_new_leave_vals = []
leaves_state = {}
try:
for contract in self:
resource_calendar_id = vals.get('resource_calendar_id', contract.resource_calendar_id.id)
extra_domain = [('resource_calendar_id', '!=', resource_calendar_id)] if resource_calendar_id else None
leaves = contract._get_leaves(
extra_domain=extra_domain
)
for leave in leaves:
super(HrVersion, contract).write(vals)
overlapping_contracts = self._check_overlapping_contract(leave)
if not overlapping_contracts:
continue
leaves_state = self._refuse_leave(leave, leaves_state)
specific_contracts += contract
all_new_leave_origin, all_new_leave_vals = self._populate_all_new_leave_vals_from_split_leave(
all_new_leave_origin, all_new_leave_vals, overlapping_contracts, leave, leaves_state)
if all_new_leave_vals:
self._create_all_new_leave(all_new_leave_origin, all_new_leave_vals)
except ValidationError:
# In case a validation error is thrown due to holiday creation with the new resource calendar (which can
# increase their duration), we catch this error to display a more meaningful error message.
raise ValidationError(self.env._("Changing the contract on this employee changes their working schedule in a period "
"they already took leaves. Changing this working schedule changes the duration of "
"these leaves in such a way the employee no longer has the required allocation for "
"them. Please review these leaves and/or allocations before changing the contract."))
return super(HrVersion, self - specific_contracts).write(vals)
def _get_leaves(self, extra_domain=None):
domain = [
('state', '!=', 'refuse'),
('employee_id', 'in', self.mapped('employee_id.id')),
('date_from', '<=', max(end or date.max for end in self.sudo().mapped('contract_date_end'))),
('date_to', '>=', min(self.sudo().mapped('contract_date_start'))),
]
if extra_domain:
domain = Domain.AND([domain, extra_domain])
return self.env['hr.leave'].search(domain)
def _get_leaves_from_vals(self, vals):
domain = [
('state', '!=', 'refuse'),
('employee_id', 'in', vals['employee_id']),
('date_to', '>=', fields.Date.from_string(vals.get('contract_date_start', vals.get('date_version', fields.Date.today())))),
('resource_calendar_id', '!=', vals.get('resource_calendar_id')),
]
if vals.get('contract_date_end'):
domain = Domain.AND([domain, [('date_from', '<=', fields.Date.from_string(vals['contract_date_end']))]])
return self.env['hr.leave'].search(domain)
def _check_overlapping_contract(self, leave):
# Get all overlapping contracts but exclude draft contracts that are not included in this transaction.
overlapping_contracts = leave._get_overlapping_contracts().sorted(
key=lambda c: c.contract_date_start)
if len(overlapping_contracts.resource_calendar_id) <= 1:
if overlapping_contracts:
first_overlapping_contract = next(iter(overlapping_contracts), overlapping_contracts)
if leave.resource_calendar_id != first_overlapping_contract.resource_calendar_id:
leave.resource_calendar_id = first_overlapping_contract.resource_calendar_id
if not leave.request_unit_hours:
leave.with_context(leave_skip_date_check=True, leave_skip_state_check=True)._compute_date_from_to()
if leave.state == 'validate':
leave._validate_leave_request()
return False
return overlapping_contracts
def _refuse_leave(self, leave, leaves_state):
if leave.id not in leaves_state:
leaves_state[leave.id] = leave.state
if leave.state not in ['refuse', 'confirm']:
leave.action_refuse()
return leaves_state
def _set_leave_draft(self, leave, leaves_state):
if leave.id not in leaves_state:
leaves_state[leave.id] = leave.state
if leave.state not in ['refuse', 'confirm']:
leave.action_back_to_approval()
return leaves_state
def _populate_all_new_leave_vals_from_split_leave(self, all_new_leave_origin, all_new_leave_vals, overlapping_contracts, leave, leaves_state):
last_version = overlapping_contracts[-1]
for overlapping_contract in overlapping_contracts:
new_request_date_from = max(leave.request_date_from, overlapping_contract.contract_date_start)
new_request_date_to = min(leave.request_date_to, overlapping_contract.contract_date_end or date.max)
new_leave_vals = leave.copy_data({
'request_date_from': new_request_date_from,
'request_date_to': new_request_date_to,
'state': leaves_state[leave.id] if overlapping_contract.id != last_version.id else 'confirm',
})[0]
new_leave = self.env['hr.leave'].new(new_leave_vals)
new_leave._compute_date_from_to()
new_leave._compute_duration()
# Could happen for part-time contract, that time off is not necessary
# anymore.
if new_leave.date_from < new_leave.date_to:
all_new_leave_origin.append(leave)
all_new_leave_vals.append(new_leave._convert_to_write(new_leave._cache))
return all_new_leave_origin, all_new_leave_vals
def _create_all_new_leave(self, all_new_leave_origin, all_new_leave_vals):
new_leaves = self.env['hr.leave'].with_context(
tracking_disable=True,
mail_activity_automation_skip=True,
leave_fast_create=True,
leave_skip_state_check=True
).create(all_new_leave_vals)
new_leaves.filtered(lambda l: l.state in 'validate')._validate_leave_request()
for index, new_leave in enumerate(new_leaves):
new_leave.message_post_with_source(
'mail.message_origin_link',
render_values={'self': new_leave, 'origin': all_new_leave_origin[index]},
subtype_xmlid='mail.mt_note',
)

View file

@ -0,0 +1,14 @@
from odoo import api, models
class MailActivityType(models.Model):
_inherit = "mail.activity.type"
@api.model
def _get_model_info_by_xmlid(self):
info = super()._get_model_info_by_xmlid()
info['hr_holidays.mail_act_leave_approval'] = {'res_model': 'hr.leave', 'unlink': False}
info['hr_holidays.mail_act_leave_second_approval'] = {'res_model': 'hr.leave', 'unlink': False}
info['hr_holidays.mail_act_leave_allocation_approval'] = {'res_model': 'hr.leave.allocation', 'unlink': False}
info['hr_holidays.mail_act_leave_allocation_second_approval'] = {'res_model': 'hr.leave.allocation', 'unlink': False}
return info

View file

@ -28,7 +28,7 @@ class MailMessageSubtype(models.Model):
department_subtype = self.create({
'name': subtype.name,
'res_model': 'hr.department',
'default': subtype.default or False,
'default': False,
'parent_id': subtype.id,
'relation_field': 'department_id',
})

View file

@ -1,15 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT
from odoo import api, models, fields
from odoo.addons.mail.tools.discuss import Store
class ResPartner(models.Model):
_inherit = 'res.partner'
leave_date_to = fields.Date(compute="_compute_leave_date_to")
def _compute_leave_date_to(self):
for partner in self:
# in the rare case of multi-user partner, return the earliest
# possible return date
dates = partner.user_ids.mapped("leave_date_to")
partner.leave_date_to = min(dates) if dates and all(dates) else False
def _compute_im_status(self):
super(ResPartner, self)._compute_im_status()
super()._compute_im_status()
absent_now = self._get_on_leave_ids()
for partner in self:
if partner.id in absent_now:
@ -17,26 +25,23 @@ class ResPartner(models.Model):
partner.im_status = 'leave_online'
elif partner.im_status == 'away':
partner.im_status = 'leave_away'
else:
elif partner.im_status == 'busy':
partner.im_status = 'leave_busy'
elif partner.im_status == 'offline':
partner.im_status = 'leave_offline'
@api.model
def _get_on_leave_ids(self):
return self.env['res.users']._get_on_leave_ids(partner=True)
def mail_partner_format(self, fields=None):
"""Override to add the current leave status."""
partners_format = super().mail_partner_format(fields=fields)
if not fields:
fields = {'out_of_office_date_end': True}
for partner in self:
if 'out_of_office_date_end' in fields:
# in the rare case of multi-user partner, return the earliest possible return date
dates = partner.mapped('user_ids.leave_date_to')
states = partner.mapped('user_ids.current_leave_state')
date = sorted(dates)[0] if dates and all(dates) else False
state = sorted(states)[0] if states and all(states) else False
partners_format.get(partner).update({
'out_of_office_date_end': date.strftime(DEFAULT_SERVER_DATE_FORMAT) if state == 'validate' and date else False,
})
return partners_format
def _to_store_defaults(self, target):
defaults = super()._to_store_defaults(target)
if target.is_internal(self.env):
# sudo: res.users - to access other company's portal user leave date
defaults.append(
Store.One(
"main_user_id",
[Store.Many("employee_ids", "leave_date_to", sudo=True), "partner_id"],
),
)
return defaults

View file

@ -1,38 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, Command
from odoo import api, fields, models, Command, _
from odoo.tools import format_date
class User(models.Model):
class ResUsers(models.Model):
_inherit = "res.users"
leave_manager_id = fields.Many2one(related='employee_id.leave_manager_id')
show_leaves = fields.Boolean(related='employee_id.show_leaves')
allocation_count = fields.Float(related='employee_id.allocation_count')
leave_date_to = fields.Date(related='employee_id.leave_date_to')
current_leave_state = fields.Selection(related='employee_id.current_leave_state')
is_absent = fields.Boolean(related='employee_id.is_absent')
allocation_remaining_display = fields.Char(related='employee_id.allocation_remaining_display')
allocation_display = fields.Char(related='employee_id.allocation_display')
hr_icon_display = fields.Selection(related='employee_id.hr_icon_display')
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + [
'leave_manager_id',
'show_leaves',
'allocation_count',
'leave_date_to',
'current_leave_state',
'is_absent',
'allocation_remaining_display',
'allocation_display',
'hr_icon_display',
]
def _compute_im_status(self):
super(User, self)._compute_im_status()
super()._compute_im_status()
on_leave_user_ids = self._get_on_leave_ids()
for user in self:
if user.id in on_leave_user_ids:
@ -40,7 +25,9 @@ class User(models.Model):
user.im_status = 'leave_online'
elif user.im_status == 'away':
user.im_status = 'leave_away'
else:
elif user.im_status == 'busy':
user.im_status = 'leave_busy'
elif user.im_status == 'offline':
user.im_status = 'leave_offline'
@api.model
@ -52,9 +39,10 @@ class User(models.Model):
self.env.cr.execute('''SELECT res_users.%s FROM res_users
JOIN hr_leave ON hr_leave.user_id = res_users.id
AND hr_leave.state = 'validate'
AND hr_leave.active = 't'
AND res_users.active = 't'
AND hr_leave.date_from <= %%s AND hr_leave.date_to >= %%s''' % field, (now, now))
AND hr_leave.date_from <= %%s AND hr_leave.date_to >= %%s
RIGHT JOIN hr_leave_type ON hr_leave.holiday_status_id = hr_leave_type.id
AND hr_leave_type.time_type = 'leave';''' % field, (now, now))
return [r[0] for r in self.env.cr.fetchall()]
def _clean_leave_responsible_users(self):
@ -66,14 +54,13 @@ class User(models.Model):
if not any(u.has_group(approver_group) for u in self):
return
res = self.env['hr.employee'].read_group(
res = self.env['hr.employee']._read_group(
[('leave_manager_id', 'in', self.ids)],
['leave_manager_id'],
['leave_manager_id'])
responsibles_to_remove_ids = set(self.ids) - {x['leave_manager_id'][0] for x in res}
responsibles_to_remove_ids = set(self.ids) - {leave_manager.id for [leave_manager] in res}
if responsibles_to_remove_ids:
self.browse(responsibles_to_remove_ids).write({
'groups_id': [Command.unlink(self.env.ref(approver_group).id)],
'group_ids': [Command.unlink(self.env.ref(approver_group).id)],
})
@api.model_create_multi
@ -81,3 +68,12 @@ class User(models.Model):
users = super().create(vals_list)
users.sudo()._clean_leave_responsible_users()
return users
@api.depends('leave_date_to')
@api.depends_context('formatted_display_name')
def _compute_display_name(self):
super()._compute_display_name()
for user in self:
if user.env.context.get("formatted_display_name") and user.leave_date_to:
name = "%s \t ✈ --%s %s--" % (user.display_name or user.name, _("Back on"), format_date(self.env, user.leave_date_to, self.env.user.lang, "medium"))
user.display_name = name.strip()

View file

@ -2,14 +2,19 @@
from odoo import fields, models, api, _
from odoo.exceptions import ValidationError
from odoo.osv import expression
from odoo.fields import Domain
from odoo.tools import babel_locale_parse
from odoo.tools.date_utils import weeknumber
import pytz
from datetime import datetime
from datetime import datetime, time
class CalendarLeaves(models.Model):
class ResourceCalendarLeaves(models.Model):
_inherit = "resource.calendar.leaves"
holiday_id = fields.Many2one("hr.leave", string='Leave Request')
holiday_id = fields.Many2one("hr.leave", string='Time Off Request')
elligible_for_accrual_rate = fields.Boolean(string='Eligible for Accrual Rate', default=False,
help="If checked, this time off type will be taken into account for accruals computation.")
@api.constrains('date_from', 'date_to', 'calendar_id')
def _check_compare_dates(self):
@ -32,14 +37,14 @@ class CalendarLeaves(models.Model):
raise ValidationError(_('Two public holidays cannot overlap each other for the same working hours.'))
def _get_domain(self, time_domain_dict):
domain = []
for date in time_domain_dict:
domain = expression.OR([domain, [
('employee_company_id', '=', date['company_id']),
('date_to', '>', date['date_from']),
('date_from', '<', date['date_to'])]
])
return expression.AND([domain, [('state', '!=', 'refuse'), ('active', '=', True)]])
return Domain.OR(
[
('employee_company_id', '=', date['company_id']),
('date_to', '>', date['date_from']),
('date_from', '<', date['date_to']),
]
for date in time_domain_dict
) & Domain('state', 'not in', ['refuse', 'cancel'])
def _get_time_domain_dict(self):
return [{
@ -59,33 +64,33 @@ class CalendarLeaves(models.Model):
previous_durations = leaves.mapped('number_of_days')
previous_states = leaves.mapped('state')
leaves.sudo().write({
'state': 'draft',
})
self.env.add_to_compute(self.env['hr.leave']._fields['number_of_days'], leaves)
self.env.add_to_compute(self.env['hr.leave']._fields['duration_display'], leaves)
sick_time_status = self.env.ref('hr_holidays.holiday_status_sl')
leaves.sudo().write({
'state': 'confirm',
})
sick_time_status = self.env.ref('hr_holidays.leave_type_sick_time_off', raise_if_not_found=False)
leaves_to_recreate = self.env['hr.leave']
for previous_duration, leave, state in zip(previous_durations, leaves, previous_states):
duration_difference = previous_duration - leave.number_of_days
if duration_difference > 0 and leave['holiday_allocation_id'] and leave.number_of_days == 0.0:
message = False
if duration_difference > 0 and leave.holiday_status_id.requires_allocation:
message = _("Due to a change in global time offs, you have been granted %s day(s) back.", duration_difference)
leave._notify_change(message)
if leave.number_of_days > previous_duration\
and leave.holiday_status_id not in sick_time_status:
new_leaves = leave.split_leave(time_domain_dict)
leaves |= new_leaves
previous_states += [state] * len(new_leaves)
leaves_to_cancel = self.env['hr.leave']
for state, leave in zip(previous_states, leaves):
leave.write({'state': state})
if leave.number_of_days == 0.0:
leaves_to_cancel |= leave
elif leave.state == 'validate':
# recreate the resource leave that were removed by writing state to draft
leave.sudo()._create_resource_leave()
leaves_to_cancel._force_cancel(_("a new public holiday completely overrides this leave."), 'mail.mt_comment')
and (not sick_time_status or leave.holiday_status_id not in sick_time_status):
message = _("Due to a change in global time offs, %s extra day(s) have been taken from your allocation. Please review this leave if you need it to be changed.", -1 * duration_difference)
try:
leave.sudo().write({'state': state}) # sudo in order to skip _check_approval_update
leave._check_validity()
if leave.state == 'validate':
# recreate the resource leave that were removed by writing state to draft
leaves_to_recreate |= leave
except ValidationError:
leave.action_refuse()
message = _("Due to a change in global time offs, this leave no longer has the required amount of available allocation and has been set to refused. Please review this leave.")
if message:
leave._notify_change(message)
leaves_to_recreate.sudo()._create_resource_leave()
def _convert_timezone(self, utc_naive_datetime, tz_from, tz_to):
"""
@ -157,18 +162,65 @@ class CalendarLeaves(models.Model):
return res
@api.depends('calendar_id')
def _compute_company_id(self):
for leave in self:
leave.company_id = leave.holiday_id.employee_id.company_id or leave.calendar_id.company_id or self.env.company
class ResourceCalendar(models.Model):
_inherit = "resource.calendar"
associated_leaves_count = fields.Integer("Leave Count", compute='_compute_associated_leaves_count')
associated_leaves_count = fields.Integer("Time Off Count", compute='_compute_associated_leaves_count')
def _compute_associated_leaves_count(self):
leaves_read_group = self.env['resource.calendar.leaves'].read_group(
[('resource_id', '=', False)],
leaves_read_group = self.env['resource.calendar.leaves']._read_group(
[('resource_id', '=', False), ('calendar_id', 'in', self.ids)],
['calendar_id'],
['calendar_id']
['__count'],
)
result = dict((data['calendar_id'][0] if data['calendar_id'] else 'global', data['calendar_id_count']) for data in leaves_read_group)
result = {calendar.id if calendar else 'global': count for calendar, count in leaves_read_group}
global_leave_count = result.get('global', 0)
for calendar in self:
calendar.associated_leaves_count = result.get(calendar.id, 0) + global_leave_count
class ResourceResource(models.Model):
_inherit = "resource.resource"
leave_date_to = fields.Date(related="user_id.leave_date_to")
def _format_leave(self, leave, resource_hours_per_day, resource_hours_per_week, ranges_to_remove, start_day, end_day, locale):
leave_start = leave[0]
leave_record = leave[2]
holiday_id = leave_record.holiday_id
tz = pytz.timezone(self.tz or self.env.user.tz)
if holiday_id.request_unit_half:
# Half day leaves are limited to half a day within a single day
leave_day = leave_start.date()
half_start_datetime = tz.localize(datetime.combine(leave_day, datetime.min.time() if holiday_id.request_date_from_period == "am" else time(12)))
half_end_datetime = tz.localize(datetime.combine(leave_day, time(12) if holiday_id.request_date_from_period == "am" else datetime.max.time()))
ranges_to_remove.append((half_start_datetime, half_end_datetime, self.env['resource.calendar.attendance']))
if not self._is_fully_flexible():
# only days inside the original period
if leave_day >= start_day and leave_day <= end_day:
resource_hours_per_day[self.id][leave_day] -= holiday_id.number_of_hours
week = weeknumber(babel_locale_parse(locale), leave_day)
resource_hours_per_week[self.id][week] -= holiday_id.number_of_hours
elif holiday_id.request_unit_hours:
# Custom leaves are limited to a specific number of hours within a single day
leave_day = leave_start.date()
range_start_datetime = pytz.utc.localize(leave_record.date_from).replace(tzinfo=None).astimezone(tz)
range_end_datetime = pytz.utc.localize(leave_record.date_to).replace(tzinfo=None).astimezone(tz)
ranges_to_remove.append((range_start_datetime, range_end_datetime, self.env['resource.calendar.attendance']))
if not self._is_fully_flexible():
# only days inside the original period
if leave_day >= start_day and leave_day <= end_day:
resource_hours_per_day[self.id][leave_day] -= holiday_id.number_of_hours
week = weeknumber(babel_locale_parse(locale), leave_day)
resource_hours_per_week[self.id][week] -= holiday_id.number_of_hours
else:
super()._format_leave(leave, resource_hours_per_day, resource_hours_per_week, ranges_to_remove, start_day, end_day, locale)