Skip to content

Commit 8e58755

Browse files
migration-bot-adhocced-adhoc
authored andcommitted
[MIG] hr_holidays_ux: Migration to 19.0
1 parent 97a5f1a commit 8e58755

6 files changed

Lines changed: 212 additions & 47 deletions

File tree

hr_holidays_ux/README.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ Holidays UX
1515
============
1616

1717
This module:
18-
# Splits time off records by month
19-
# Improves visibility of massive leave allocations feature for hr managers adding a button on the new allocation form
18+
19+
#. Splits time off records by month
20+
#. Adds a pre-approved stage for time off requests that require attachments. This allows managers to fully approve the time off once the employee uploads the required document
21+
2022

2123

2224
Installation

hr_holidays_ux/__manifest__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
##############################################################################
2020
{
2121
"name": "Holidays UX",
22-
"version": "18.0.1.1.0",
22+
"version": "19.0.1.0.0",
2323
"category": "Human Resources",
2424
"sequence": 14,
2525
"summary": "Split time off records by month",
@@ -31,12 +31,11 @@
3131
"hr_holidays",
3232
],
3333
"data": [
34-
"views/hr_leave_allocation_views.xml",
3534
"views/hr_leave_type_views.xml",
3635
"views/hr_leave_views.xml",
3736
],
3837
"demo": [],
39-
"installable": False,
38+
"installable": True,
4039
"auto_install": False,
4140
"application": False,
4241
}

hr_holidays_ux/models/hr_leave.py

Lines changed: 200 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import calendar
2-
from datetime import timedelta
2+
from datetime import datetime, time, timedelta
33

44
from odoo import api, fields, models
55

@@ -20,9 +20,18 @@ def split_days(self, rec):
2020
date_from = rec["request_date_from"]
2121
original_date_to = rec["request_date_to"]
2222

23+
# ensure working with datetime objects
24+
date_from = self._convert_date_to_datetime(date_from)
25+
original_date_to = self._convert_date_to_datetime(original_date_to)
26+
2327
while date_from <= original_date_to:
24-
# get last day of the month
25-
end_of_month = self.get_last_day_of_month(date_from)
28+
# convert to date for get_last_day_of_month, then back to datetime
29+
date_from_date = self._convert_to_date(date_from)
30+
end_of_month_date = self.get_last_day_of_month(date_from_date)
31+
32+
# convert back to datetime
33+
end_of_month = self._convert_date_to_datetime(end_of_month_date)
34+
2635
if original_date_to <= end_of_month:
2736
# save the dates of the single record to be created
2837
new_records.append(
@@ -40,33 +49,32 @@ def split_days(self, rec):
4049
"request_date_to": end_of_month,
4150
}
4251
)
43-
date_from = end_of_month + timedelta(days=1)
52+
# Move to the first day of next month
53+
next_month_date = end_of_month_date + timedelta(days=1)
54+
date_from = self._convert_date_to_datetime(next_month_date)
4455

4556
return new_records
4657

4758
@api.model_create_multi
4859
def create(self, vals_list):
60+
"""Override create to automatically split leave records across months."""
4961
new_vals_list = []
5062
for vals in vals_list:
5163
date_from = vals.get("request_date_from")
5264
date_to = vals.get("request_date_to")
5365

54-
if date_from and date_to:
55-
# convert to datetime objects if they are strings
56-
if isinstance(date_from, str):
57-
date_from = fields.Datetime.from_string(date_from)
58-
if isinstance(date_to, str):
59-
date_to = fields.Datetime.from_string(date_to)
60-
61-
# if the leave ends in a different month, call the split_days() method to get the dates for splitting the leaves
62-
if date_to > self.get_last_day_of_month(date_from):
66+
# if the leave spans multiple months, split it into separate records
67+
if date_from and date_to and self._should_split_leave(date_from, date_to):
68+
# convert to datetime objects for split_days method
69+
date_from = self._convert_date_to_datetime(date_from)
70+
date_to = self._convert_date_to_datetime(date_to)
6371
split_records = self.split_days(
6472
{
6573
"request_date_from": date_from,
6674
"request_date_to": date_to,
6775
}
6876
)
69-
# for each record generated in split_days, copy the original vals, and update with the dates of the record rec, adding them to a new list
77+
# create a separate record for each split period
7078
for rec in split_records:
7179
new_vals = vals.copy()
7280
new_vals.update(rec)
@@ -76,6 +84,143 @@ def create(self, vals_list):
7684

7785
return super().create(new_vals_list)
7886

87+
def _convert_date_to_datetime(self, date):
88+
"""Convert string or date object to datetime object if needed."""
89+
if isinstance(date, str):
90+
return fields.Datetime.from_string(date)
91+
elif hasattr(date, "year") and not hasattr(date, "hour"):
92+
# date object: convert to datetime at start of day
93+
return datetime.combine(date, time.min)
94+
return date
95+
96+
def _convert_to_date(self, date):
97+
"""Convert datetime or string to date object for comparison."""
98+
if isinstance(date, str):
99+
return fields.Date.from_string(date)
100+
elif hasattr(date, "date"):
101+
# datetime object
102+
return date.date()
103+
return date
104+
105+
def _should_split_leave(self, date_from, date_to):
106+
"""Check if leave period needs to be split across months."""
107+
# convert to date objects for month comparison
108+
date_from = self._convert_to_date(date_from)
109+
date_to = self._convert_to_date(date_to)
110+
last_day_of_month = self.get_last_day_of_month(date_from)
111+
return date_to > last_day_of_month
112+
113+
def _check_overlapping_leaves(self, record, date_from, date_to):
114+
"""Check if there are existing leave records that overlap with the given dates."""
115+
domain = [
116+
("employee_id", "=", record.employee_id.id),
117+
("id", "!=", record.id), # Exclude current record being edited
118+
("state", "not in", ["cancel", "refuse"]), # Only active leaves
119+
# Check for date overlap: (start1 <= end2) and (end1 >= start2)
120+
("request_date_from", "<=", date_to),
121+
("request_date_to", ">=", date_from),
122+
]
123+
return self.search(domain)
124+
125+
def write(self, vals):
126+
"""Override write to handle automatic splitting when dates are modified."""
127+
date_from = vals.get("request_date_from")
128+
date_to = vals.get("request_date_to")
129+
130+
# if no date fields are being modified, proceed with normal write
131+
if not (date_from or date_to):
132+
return super().write(vals)
133+
134+
# for each record, check if it needs splitting after the write
135+
for record in self:
136+
# get the final dates. New values override existing ones
137+
final_date_from = date_from or record.request_date_from
138+
final_date_to = date_to or record.request_date_to
139+
140+
# check if this record needs to be split
141+
if self._should_split_leave(final_date_from, final_date_to):
142+
# convert dates for split_days method (needs datetime objects)
143+
final_date_from = self._convert_date_to_datetime(final_date_from)
144+
final_date_to = self._convert_date_to_datetime(final_date_to)
145+
146+
# get split records
147+
split_records = self.split_days(
148+
{
149+
"request_date_from": final_date_from,
150+
"request_date_to": final_date_to,
151+
}
152+
)
153+
154+
# only split if we actually get multiple records
155+
if len(split_records) > 1:
156+
# prepare values for the first record (update current record)
157+
first_split = split_records[0]
158+
first_vals = vals.copy()
159+
first_vals.update(first_split)
160+
161+
# update current record with first split period
162+
super(HrLeave, record).write(first_vals)
163+
164+
# check for overlapping records before creating new ones
165+
records_to_create = []
166+
for split_rec in split_records[1:]:
167+
split_date_from = self._convert_to_date(split_rec["request_date_from"])
168+
split_date_to = self._convert_to_date(split_rec["request_date_to"])
169+
170+
# check if there are overlapping records for this period
171+
overlapping = self._check_overlapping_leaves(record, split_date_from, split_date_to)
172+
173+
if not overlapping:
174+
# no overlapping records, safe to create
175+
base_vals = {
176+
"employee_id": record.employee_id.id,
177+
"holiday_status_id": record.holiday_status_id.id,
178+
}
179+
180+
# add fields that exist in vals or record, safely
181+
safe_fields = [
182+
"number_of_days",
183+
"state",
184+
"notes",
185+
"date_from",
186+
"date_to",
187+
"name",
188+
"description",
189+
"request_unit_half",
190+
"request_unit_hours",
191+
]
192+
193+
for field in safe_fields:
194+
if field in vals:
195+
base_vals[field] = vals[field]
196+
elif hasattr(record, field):
197+
base_vals[field] = getattr(record, field, False)
198+
199+
# add any other vals being updated (except date fields that will be overridden)
200+
for key, value in vals.items():
201+
if key not in ["request_date_from", "request_date_to"]:
202+
base_vals[key] = value
203+
204+
new_vals = base_vals.copy()
205+
new_vals.update(split_rec)
206+
records_to_create.append(new_vals)
207+
# if overlapping records exist, skip creating this split
208+
# this allows editing the current record but prevents duplicates
209+
210+
# create only the non-overlapping records
211+
if records_to_create:
212+
self.create(records_to_create)
213+
else:
214+
super(HrLeave, record).write(vals)
215+
else:
216+
super(HrLeave, record).write(vals)
217+
218+
return True
219+
220+
def copy_data(self, default=None):
221+
"""Override copy_data to handle leave splitting properly."""
222+
return super().copy_data(default=default)
223+
79224
def action_approve(self, check_state=True):
80225
res = super().action_approve()
81226
if (
@@ -93,4 +238,44 @@ def action_validate(self, check_state=True):
93238
return res
94239

95240
def action_post_approve(self):
96-
self.state = "validate"
241+
"""Approve a pre-validated leave once documents are uploaded."""
242+
self.write({"state": "validate"})
243+
self._validate_leave_request()
244+
if not self.env.context.get("leave_fast_create"):
245+
self.activity_update()
246+
return True
247+
248+
def _get_next_states_by_state(self):
249+
"""Override to add pre-validate state transitions."""
250+
state_result = super()._get_next_states_by_state()
251+
252+
# Add pre-validate state to the state_result dictionary
253+
state_result["pre-validate"] = set()
254+
255+
is_officer = self.env.user.has_group("hr_holidays.group_hr_holidays_user")
256+
is_time_off_manager = self.employee_id.leave_manager_id == self.env.user
257+
258+
if is_officer or is_time_off_manager:
259+
# From pre-validate, you can go to validate, refuse, or cancel
260+
state_result["pre-validate"].update({"validate", "refuse", "cancel"})
261+
262+
return state_result
263+
264+
def _check_approval_update(self, state, raise_if_not_possible=True):
265+
"""Override to handle pre-validate state transitions."""
266+
# For transitions from pre-validate state
267+
if self.state == "pre-validate" and state == "validate":
268+
# Allow transition from pre-validate to validate
269+
is_officer = self.env.user.has_group("hr_holidays.group_hr_holidays_user")
270+
is_time_off_manager = self.employee_id.leave_manager_id == self.env.user
271+
272+
if is_officer or is_time_off_manager:
273+
return True
274+
275+
if raise_if_not_possible:
276+
from odoo.exceptions import UserError
277+
278+
raise UserError(self.env._("Only a Time Off Officer/Manager can approve a pre-validated leave."))
279+
return False
280+
281+
return super()._check_approval_update(state, raise_if_not_possible)

hr_holidays_ux/views/hr_leave_allocation_views.xml

Lines changed: 0 additions & 24 deletions
This file was deleted.

hr_holidays_ux/views/hr_leave_type_views.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
<field name="model">hr.leave.type</field>
66
<field name="inherit_id" ref="hr_holidays.edit_holiday_status_form"/>
77
<field name="arch" type="xml">
8+
<xpath expr="//label[@for='support_document']" position="after">
9+
<label for="pre_approved_instance" class="me-4" string="Pre Approved Instance" invisible="not support_document"/>
10+
</xpath>
811
<xpath expr="//field[@name='support_document']" position="after">
9-
<field name="pre_approved_instance" invisible="not support_document"/>
12+
<field name="pre_approved_instance" nolabel="1" invisible="not support_document"/>
1013
</xpath>
1114
</field>
1215
</record>

hr_holidays_ux/views/hr_leave_views.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
<field name="arch" type="xml">
88
<xpath expr="//field[@name='supported_attachment_ids']" position="attributes">
99
<attribute name="invisible">
10-
not leave_type_support_document or state not in ('confirm', 'validate1','pre-validate')
10+
not leave_type_support_document or state not in ('confirm', 'validate1', 'pre-validate')
1111
</attribute>
1212
</xpath>
1313
<xpath expr="//button[@name='action_approve']" position="after">
14-
<button string="Approve" name="action_post_approve" type="object" class="oe_highlight" invisible="state != 'pre-validate' or not supported_attachment_ids "/>
14+
<button string="Approve" name="action_post_approve" type="object" class="oe_highlight" invisible="state != 'pre-validate' or not supported_attachment_ids"/>
1515
</xpath>
1616
</field>
1717
</record>

0 commit comments

Comments
 (0)