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

@ -4,3 +4,5 @@
from . import hr_holidays_cancel_leave
from . import hr_holidays_summary_employees
from . import hr_departure_wizard
from . import hr_leave_generate_multi_wizard
from . import hr_leave_allocation_generate_multi_wizard

View file

@ -1,27 +1,68 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from datetime import timedelta
from odoo import api, fields, models
from odoo import _, models
class HrDepartureWizard(models.TransientModel):
_inherit = 'hr.departure.wizard'
cancel_leaves = fields.Boolean("Cancel Future Leaves", default=True,
help="Cancel all time off after this date.")
archive_allocation = fields.Boolean("Archive Employee Allocations", default=True,
help="Remove employee from existing accrual plans.")
def action_register_departure(self):
super(HrDepartureWizard, self).action_register_departure()
if self.cancel_leaves:
future_leaves = self.env['hr.leave'].search([('employee_id', '=', self.employee_id.id),
('date_to', '>', self.departure_date),
('state', '!=', 'refuse')])
future_leaves.action_refuse()
action = super().action_register_departure()
employee_leaves = self.env['hr.leave'].search([
('employee_id', 'in', self.employee_ids.ids),
('date_to', '>', self.departure_date),
])
if self.archive_allocation:
employee_allocations = self.env['hr.leave.allocation'].search([('employee_id', '=', self.employee_id.id)])
employee_allocations.action_archive()
if employee_leaves:
leaves_with_departure = employee_leaves.filtered(
lambda leave: leave.date_from.date() <= self.departure_date)
leaves_after_departure = employee_leaves - leaves_with_departure
new_leaves = leaves_with_departure._split_leaves(
split_date_from=(self.departure_date + timedelta(days=1)))
# Post message for changes leaves
changes_leaves = leaves_with_departure.filtered(lambda leave: leave.date_to.date() <= self.departure_date)
changes_msg = _('End date has been updated because '
'the employee will leave the company on %(departure_date)s.',
departure_date=self.departure_date
)
for leave in changes_leaves:
leave.message_post(body=changes_msg, message_type="comment", subtype_xmlid="mail.mt_comment")
# Cancel approved leaves
leaves_after_departure |= leaves_with_departure - changes_leaves
leaves_after_departure |= new_leaves
leaves_to_cancel = leaves_after_departure.filtered(lambda leave: leave.state in ['validate', 'validate1'])
cancel_msg = _('The employee will leave the company on %(departure_date)s.',
departure_date=self.departure_date)
leaves_to_cancel._force_cancel(cancel_msg, notify_responsibles=False)
# Delete others leaves
leaves_to_delete = leaves_after_departure - leaves_to_cancel
leaves_to_delete.with_context(leave_skip_state_check=True).unlink()
employee_allocations = self.env['hr.leave.allocation'].search([
('employee_id', 'in', self.employee_ids.ids),
'|',
('date_to', '=', False),
('date_to', '>', self.departure_date),
])
if not employee_allocations:
return action
to_delete = self.env['hr.leave.allocation']
to_modify = self.env['hr.leave.allocation']
allocation_msg = _('Validity End date has been updated because '
'the employee will leave the company on %(departure_date)s.',
departure_date=self.departure_date
)
for allocation in employee_allocations:
if allocation.date_from > self.departure_date:
to_delete |= allocation
else:
to_modify |= allocation
allocation.message_post(body=allocation_msg, subtype_xmlid='mail.mt_comment')
to_delete.with_context(allocation_skip_state_check=True).unlink()
to_modify.date_to = self.departure_date
return action

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_departure_wizard_view_form" model="ir.ui.view">
<field name="name">hr.departure.wizard.view.form.extend3</field>
<field name="model">hr.departure.wizard</field>
<field name="inherit_id" ref="hr.hr_departure_wizard_view_form" />
<field name="arch" type="xml">
<xpath expr="//div[@id='activities_label']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//div[@id='activities']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//div[@id='activities']" position="inside">
<div><field name="cancel_leaves"/><label for="cancel_leaves" string="Time Off"/></div>
<div><field name="archive_allocation"/><label for="archive_allocation" string="Allocations"/></div>
</xpath>
</field>
</record>
</odoo>

View file

@ -2,15 +2,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import ValidationError
class HrHolidaysCancelLeave(models.TransientModel):
_name = 'hr.holidays.cancel.leave'
_description = 'Cancel Leave Wizard'
_description = 'Cancel Time Off Wizard'
leave_id = fields.Many2one('hr.leave', required=True)
reason = fields.Text(required=True)
leave_id = fields.Many2one('hr.leave', string="Time Off Request", required=True)
reason = fields.Text()
def action_cancel_leave(self):
self.ensure_one()
@ -22,7 +21,7 @@ class HrHolidaysCancelLeave(models.TransientModel):
'tag': 'display_notification',
'params': {
'type': 'success',
'message': _("Your time off has been canceled."),
'message': _("Your time off has been cancelled."),
'next': {'type': 'ir.actions.act_window_close'},
}
}

View file

@ -6,11 +6,15 @@
<form string="Cancel Time Off">
<group>
<field name="leave_id" invisible="1" />
<field name="reason" placeholder="Provide a reason for cancellation of an approved time off" />
<field name="reason" placeholder="Why do you want to cancel this approved time off ?" />
</group>
<footer>
<button name="action_cancel_leave" type="object" class="btn-primary" string="Delete Time Off" />
<button special="cancel" string="Discard" close="1" />
<button name="action_cancel_leave"
type="object"
class="btn-primary"
string="Cancel Time Off"
accesskey="c" />
<button special="cancel" string="Discard" close="1" accesskey="j" />
</footer>
</form>
</field>

View file

@ -5,9 +5,9 @@ import time
from odoo import api, fields, models
class HolidaysSummaryEmployee(models.TransientModel):
class HrHolidaysSummaryEmployee(models.TransientModel):
_name = 'hr.holidays.summary.employee'
_description = 'HR Time Off Summary Report By Employee'
date_from = fields.Date(string='From', required=True, default=lambda *a: time.strftime('%Y-%m-01'))

View file

@ -15,7 +15,7 @@
</group>
<footer>
<button name="print_report" string="Print" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x" />
</footer>
</form>
</field>
@ -23,7 +23,6 @@
<record id="action_hr_holidays_summary_employee" model="ir.actions.act_window">
<field name="name">Time Off Summary</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">hr.holidays.summary.employee</field>
<field name="view_mode">form</field>
<field name="target">new</field>

View file

@ -0,0 +1,138 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.exceptions import AccessError
from odoo.fields import Domain
from odoo.addons.resource.models.utils import HOURS_PER_DAY
class HrLeaveAllocationGenerateMultiWizard(models.TransientModel):
_name = 'hr.leave.allocation.generate.multi.wizard'
_inherit = ['hr.mixin']
_description = 'Generate time off allocations for multiple employees'
def _get_employee_domain(self):
domain = Domain([('company_id', 'in', self.env.companies.ids)])
if not self.env.user.has_group('hr_holidays.group_hr_holidays_user'):
domain &= Domain(['|', ('leave_manager_id', '=', self.env.user.id), ('user_id', '=', self.env.user.id)])
return domain
def _domain_holiday_status_id(self):
domain = [
('company_id', 'in', self.env.companies.ids + [False]),
('requires_allocation', '=', True),
]
if self.env.user.has_group('hr_holidays.group_hr_holidays_user'):
return domain
return Domain.AND([domain, [('employee_requests', '=', True)]])
name = fields.Char("Description", compute="_compute_name", store=True, readonly=False)
duration = fields.Float(string="Allocation")
holiday_status_id = fields.Many2one(
"hr.leave.type", string="Time Off Type", required=True,
domain=_domain_holiday_status_id)
request_unit = fields.Selection(related="holiday_status_id.request_unit")
allocation_mode = fields.Selection([
('employee', 'By Employee'),
('company', 'By Company'),
('department', 'By Department'),
('category', 'By Employee Tag')],
string='Allocation Mode', readonly=False, required=True, default='employee',
help="Allow to create requests in batchs:\n- By Employee: for a specific employee"
"\n- By Company: all employees of the specified company"
"\n- By Department: all employees of the specified department"
"\n- By Employee Tag: all employees of the specific employee group category")
employee_ids = fields.Many2many('hr.employee', string='Employees', domain=lambda self: self._get_employee_domain())
company_id = fields.Many2one('res.company', default=lambda self: self.env.company, required=True)
department_id = fields.Many2one('hr.department')
category_id = fields.Many2one('hr.employee.category', string='Employee Tag')
allocation_type = fields.Selection([
('regular', 'Regular Allocation'),
('accrual', 'Based on Accrual Plan')
], string="Allocation Type", default="regular", required=True)
accrual_plan_id = fields.Many2one('hr.leave.accrual.plan',
domain="['|', ('time_off_type_id', '=', False), ('time_off_type_id', '=', holiday_status_id)]")
date_from = fields.Date('Start Date', default=fields.Date.context_today, required=True)
date_to = fields.Date('End Date')
notes = fields.Text('Reasons')
@api.depends('holiday_status_id', 'duration')
def _compute_name(self):
for allocation_multi in self:
allocation_multi.name = allocation_multi._get_title()
def _get_title(self):
self.ensure_one()
if not self.holiday_status_id:
return self.env._("Allocation Request")
return self.env._(
'%(name)s (%(duration)s %(request_unit)s(s))',
name=self.holiday_status_id.name,
duration=self.duration,
request_unit=self.request_unit
)
def _get_employees_from_allocation_mode(self):
self.ensure_one()
if self.allocation_mode == 'employee':
employees = self.employee_ids or self.env['hr.employee'].search(self._get_employee_domain())
elif self.allocation_mode == 'category':
employees = self.category_id.employee_ids.filtered(lambda e: e.company_id in self.env.companies)
elif self.allocation_mode == 'company':
employees = self.env['hr.employee'].search([('company_id', '=', self.company_id.id)])
else:
employees = self.department_id.member_ids
return employees
def _prepare_allocation_values(self, employees):
self.ensure_one()
hours_per_day = {
e.id: e.resource_calendar_id.hours_per_day or self.company_id.resource_calendar_id.hours_per_day or HOURS_PER_DAY
for e in employees.sudo()
}
return [{
'name': self.name,
'holiday_status_id': self.holiday_status_id.id,
'number_of_days': self.duration if self.request_unit != "hour" else self.duration / hours_per_day[employee.id],
'employee_id': employee.id,
'state': 'confirm',
'allocation_type': self.allocation_type,
'date_from': self.date_from,
'date_to': self.date_to,
'accrual_plan_id': self.accrual_plan_id.id,
'notes': self.notes
} for employee in employees]
def action_generate_allocations(self):
self.ensure_one()
employees = self._get_employees_from_allocation_mode()
vals_list = self._prepare_allocation_values(employees)
if vals_list:
allocations = self.env['hr.leave.allocation'].with_context(
mail_notify_force_send=False,
mail_activity_automation_skip=True,
).create(vals_list)
allocations.filtered(lambda c: c.validation_type not in ('no_validation', 'hr')).action_approve()
if self.env.user.has_group('hr_holidays.group_hr_holidays_user'):
allocations.filtered(lambda c: c.validation_type == 'hr').action_approve()
return {
'type': 'ir.actions.act_window',
'name': self.env._('Generated Allocations'),
"views": [[self.env.ref('hr_holidays.hr_leave_allocation_view_tree').id, "list"], [self.env.ref('hr_holidays.hr_leave_allocation_view_form_manager').id, "form"]],
'view_mode': 'list',
'res_model': 'hr.leave.allocation',
'domain': [('id', 'in', allocations.ids)],
'context': {
'active_id': False,
},
}
return None
@api.constrains('allocation_mode')
def _check_allocation_mode(self):
is_manager = self.env.user.has_group('hr_holidays.group_hr_holidays_user')
for record in self:
if record.allocation_mode != 'employee' and not is_manager:
raise AccessError(self.env._("As Time Off Responsible, you can only use the allocation mode 'By Employee'."))

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_leave_allocation_generate_multi_wizard_view_form" model="ir.ui.view">
<field name="model">hr.leave.allocation.generate.multi.wizard</field>
<field name="arch" type="xml">
<form string="Generate time off allocations for multiple employees">
<group>
<field name="allocation_mode" string="Grant" groups="hr_holidays.group_hr_holidays_user"/>
<field name="employee_ids" invisible="allocation_mode != 'employee'" widget="many2many_avatar_employee" placeholder="All Employees"/>
<field name="company_id" invisible="allocation_mode != 'company'"/>
<field name="department_id" invisible="allocation_mode != 'department'" required="allocation_mode == 'department'"/>
<field name="category_id" invisible="allocation_mode != 'category'" required="allocation_mode == 'category'"/>
<field name="holiday_status_id"/>
<field name="allocation_type" widget="radio"/>
<field name="request_unit" invisible="1"/>
<field name="accrual_plan_id"
invisible="allocation_type == 'regular'"
required="allocation_type == 'accrual'"/>
<div class="o_td_label" name="validity_label">
<label for="date_from" string="Validity Period"
invisible="allocation_type == 'accrual'"/>
<label for="date_from" string="Start Date" invisible="allocation_type == 'regular'"/>
</div>
<div class="o_row" name="validity">
<field name="date_from" nolabel="1"/>
<i class="fa fa-long-arrow-right mx-2" aria-label="Arrow icon" title="Arrow" invisible="allocation_type == 'accrual'"/>
<label class="mx-2" for="date_to" string="Run until" invisible="allocation_type == 'regular'"/>
<field name="date_to" nolabel="1" placeholder="No Limit"/>
<div id="no_limit_label" class="oe_read_only" invisible="date_to">No limit</div>
</div>
<div class="o_td_label">
<label for="duration"/>
</div>
<div name="duration_display">
<field name="duration" nolabel="1" style="width: 5rem;"/>
<span class="ml8" invisible="request_unit == 'hour'">Days</span>
<span class="ml8" invisible="request_unit != 'hour'">Hours</span>
</div>
</group>
<field name="notes" placeholder="Add a reason..." nolabel="1"/>
<footer>
<button name="action_generate_allocations" type="object" class="btn-primary" string="Allocate Time Off" accesskey="c"/>
<button special="cancel" string="Discard" close="1" accesskey="j" />
</footer>
</form>
</field>
</record>
<record id="action_hr_leave_allocation_generate_multi_wizard" model="ir.actions.act_window">
<field name="name">New Group Allocation</field>
<field name="res_model">hr.leave.allocation.generate.multi.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View file

@ -0,0 +1,130 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from pytz import UTC, timezone
from odoo import api, fields, models
from odoo.exceptions import AccessError, UserError
from odoo.fields import Domain
class HrLeaveGenerateMultiWizard(models.TransientModel):
_name = 'hr.leave.generate.multi.wizard'
_inherit = ['hr.mixin']
_description = 'Generate time off for multiple employees'
def _get_employee_domain(self):
domain = Domain([('company_id', 'in', self.env.companies.ids)])
if not self.env.user.has_group('hr_holidays.group_hr_holidays_user'):
domain &= Domain(['|', ('leave_manager_id', '=', self.env.user.id), ('user_id', '=', self.env.user.id)])
return domain
name = fields.Char("Description")
holiday_status_id = fields.Many2one(
"hr.leave.type", string="Time Off Type", required=True,
domain="[('company_id', 'in', [company_id, False])]")
allocation_mode = fields.Selection([
('employee', 'By Employee'),
('company', 'By Company'),
('department', 'By Department'),
('category', 'By Employee Tag')],
string='Allocation Mode', readonly=False, required=True, default='employee',
help="Allow to create requests in batchs:\n- By Employee: for a specific employee"
"\n- By Company: all employees of the specified company"
"\n- By Department: all employees of the specified department"
"\n- By Employee Tag: all employees of the specific employee group category")
employee_ids = fields.Many2many('hr.employee', string='Employees', domain=lambda self: self._get_employee_domain())
company_id = fields.Many2one('res.company', default=lambda self: self.env.company, required=True)
department_id = fields.Many2one('hr.department')
category_id = fields.Many2one('hr.employee.category', string='Employee Tag')
date_from = fields.Date('Start Date', required=True)
date_to = fields.Date('End Date', required=True)
def _get_employees_from_allocation_mode(self):
self.ensure_one()
if self.allocation_mode == 'employee':
employees = self.employee_ids or self.env['hr.employee'].search(self._get_employee_domain())
elif self.allocation_mode == 'category':
employees = self.category_id.employee_ids.filtered(lambda e: e.company_id in self.env.companies)
elif self.allocation_mode == 'company':
employees = self.env['hr.employee'].search([('company_id', '=', self.company_id.id)])
else:
employees = self.department_id.member_ids
return employees
def _prepare_employees_holiday_values(self, employees, date_from_tz, date_to_tz):
self.ensure_one()
work_days_data = employees.sudo()._get_work_days_data_batch(date_from_tz, date_to_tz)
validated = self.env.user.has_group('hr_holidays.group_hr_holidays_user') or self.holiday_status_id.leave_validation_type == 'no_validation'
return [{
'name': self.name,
'holiday_status_id': self.holiday_status_id.id,
'date_from': date_from_tz,
'date_to': date_to_tz,
'request_date_from': self.date_from,
'request_date_to': self.date_to,
'number_of_days': work_days_data[employee.id]['days'],
'employee_id': employee.id,
'state': 'validate' if validated else 'confirm',
} for employee in employees if work_days_data[employee.id]['days']]
def action_generate_time_off(self):
self.ensure_one()
employees = self._get_employees_from_allocation_mode()
tz = timezone(self.company_id.resource_calendar_id.tz or self.env.user.tz or 'UTC')
date_from_tz = tz.localize(datetime.combine(self.date_from, datetime.min.time())).astimezone(UTC).replace(tzinfo=None)
date_to_tz = tz.localize(datetime.combine(self.date_to, datetime.max.time())).astimezone(UTC).replace(tzinfo=None)
conflicting_leaves = self.env['hr.leave'].with_context(
tracking_disable=True,
mail_activity_automation_skip=True,
leave_fast_create=True,
).search([
('date_from', '<=', date_to_tz),
('date_to', '>', date_from_tz),
('state', 'not in', ['cancel', 'refuse']),
('employee_id', 'in', employees.ids)])
if conflicting_leaves:
# YTI: More complex use cases could be managed later
invalid_time_off = conflicting_leaves.filtered(lambda leave: leave.leave_type_request_unit == 'hour')
if invalid_time_off:
raise UserError(self.env._('Some employees already have time off requests in hours that overlap with the selected period, Odoo cannot automatically adjust or split hourly leaves during batch generation. Conflicting time off:\n%s', '\n'.join(f"- {l.display_name}" for l in invalid_time_off)))
one_day_leaves = conflicting_leaves.filtered(lambda leave: leave.request_date_from == leave.request_date_to)
one_day_leaves.action_refuse()
(conflicting_leaves - one_day_leaves)._split_leaves(self.date_from, self.date_to + timedelta(days=1))
vals_list = self._prepare_employees_holiday_values(employees, date_from_tz, date_to_tz)
leaves = self.env['hr.leave'].with_context(
tracking_disable=True,
mail_activity_automation_skip=True,
leave_fast_create=True,
no_calendar_sync=True,
leave_skip_state_check=True,
# date_from and date_to are computed based on the employee tz
# If _compute_date_from_to is used instead, it will trigger _compute_number_of_days
# and create a conflict on the number of days calculation between the different leaves
leave_compute_date_from_to=True,
).create(vals_list)
leaves._validate_leave_request()
return {
'type': 'ir.actions.act_window',
'name': self.env._('Generated Time Off'),
"views": [[self.env.ref('hr_holidays.hr_leave_view_tree').id, "list"], [self.env.ref('hr_holidays.hr_leave_view_form_manager').id, "form"]],
'view_mode': 'list',
'res_model': 'hr.leave',
'domain': [('id', 'in', leaves.ids)],
'context': {
'active_id': False,
},
}
@api.constrains('allocation_mode')
def _check_allocation_mode(self):
is_manager = self.env.user.has_group('hr_holidays.group_hr_holidays_user')
for record in self:
if record.allocation_mode != 'employee' and not is_manager:
raise AccessError(self.env._("As Time Off Responsible, you can only use the allocation mode 'By Employee'."))

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_leave_generate_multi_wizard_view_form" model="ir.ui.view">
<field name="model">hr.leave.generate.multi.wizard</field>
<field name="arch" type="xml">
<form string="Generate time off for multiple employees">
<group>
<field name="holiday_status_id" domain="[('requires_allocation', '=', False)]" class="w-100"/>
<field name="allocation_mode" string="Mode" groups="hr_holidays.group_hr_holidays_user"/>
<field name="employee_ids" invisible="allocation_mode != 'employee'" required="allocation_mode == 'employee'"
widget="many2many_avatar_employee" placeholder="Everyone"/>
<field name="company_id" invisible="allocation_mode != 'company'"/>
<field name="department_id" invisible="allocation_mode != 'department'" required="allocation_mode == 'department'"/>
<field name="category_id" invisible="allocation_mode != 'category'" required="allocation_mode == 'category'"/>
<label for="date_from" string="Dates"/>
<field
name="date_from"
widget="daterange"
options="{'end_date_field': 'date_to'}"
class="w-50" nolabel="1"/>
<field name="date_to" invisible="1" />
</group>
<field name="name" widget="text" placeholder="e.g. Extra recuperation, Company unavailability, ..."/>
<footer>
<button name="action_generate_time_off" type="object" class="btn-primary" string="Generate Time Off" accesskey="c"/>
<button special="cancel" string="Discard" close="1" accesskey="j" />
</footer>
</form>
</field>
</record>
<record id="action_hr_leave_generate_multi_wizard" model="ir.actions.act_window">
<field name="name">Multiple Requests</field>
<field name="res_model">hr.leave.generate.multi.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>