Spaces:
Paused
Paused
Commit
·
ede5a9e
1
Parent(s):
600ed2b
hotfix: remove timezone normalization and update working hours validation
Browse filesWorking hours validation now occurs within each's involved timezone's context
- src/utils/extract_calendar.py +36 -23
- tests/data/calendar.ics +58 -58
src/utils/extract_calendar.py
CHANGED
@@ -30,18 +30,12 @@ def extract_ical_entries(file_bytes):
|
|
30 |
return str(val)
|
31 |
|
32 |
def to_datetime(val):
|
33 |
-
"""Convert icalendar datetime to Python datetime object,
|
34 |
if hasattr(val, "dt"):
|
35 |
dt = val.dt
|
36 |
if isinstance(dt, datetime):
|
37 |
-
#
|
38 |
-
|
39 |
-
# Convert to local timezone then strip timezone info
|
40 |
-
local_dt = dt.astimezone()
|
41 |
-
return local_dt.replace(tzinfo=None)
|
42 |
-
else:
|
43 |
-
# Already naive, return as-is
|
44 |
-
return dt
|
45 |
elif isinstance(dt, date):
|
46 |
# Convert date to datetime at 9 AM (naive)
|
47 |
return datetime.combine(
|
@@ -49,7 +43,7 @@ def extract_ical_entries(file_bytes):
|
|
49 |
)
|
50 |
return None
|
51 |
|
52 |
-
# Parse datetime objects for slot calculation (
|
53 |
start_datetime = to_datetime(dtstart)
|
54 |
end_datetime = to_datetime(dtend)
|
55 |
|
@@ -103,6 +97,8 @@ def validate_calendar_working_hours(
|
|
103 |
"""
|
104 |
Validate that all calendar entries fall within standard working hours (9:00-18:00) and don't span lunch break (13:00-14:00).
|
105 |
|
|
|
|
|
106 |
Args:
|
107 |
calendar_entries: List of calendar entry dictionaries
|
108 |
|
@@ -120,20 +116,26 @@ def validate_calendar_working_hours(
|
|
120 |
end_datetime = entry.get("end_datetime")
|
121 |
|
122 |
if start_datetime and isinstance(start_datetime, datetime):
|
123 |
-
|
|
|
|
|
|
|
|
|
124 |
violations.append(
|
125 |
-
f"'{summary}' starts at {
|
126 |
)
|
127 |
|
128 |
if end_datetime and isinstance(end_datetime, datetime):
|
129 |
-
|
130 |
-
|
131 |
-
|
|
|
|
|
132 |
violations.append(
|
133 |
-
f"'{summary}' ends at {
|
134 |
)
|
135 |
|
136 |
-
# Check for lunch break spanning (13:00-14:00)
|
137 |
if (
|
138 |
start_datetime
|
139 |
and end_datetime
|
@@ -163,12 +165,17 @@ def datetime_to_slot(dt: datetime, base_date: date) -> int:
|
|
163 |
Convert a datetime to a 30-minute slot index within working days.
|
164 |
|
165 |
Args:
|
166 |
-
dt: The datetime to convert (
|
167 |
-
base_date: The base date (slot 0 = base_date at 9:00 AM
|
168 |
|
169 |
Returns:
|
170 |
The slot index (each slot = 30 minutes within working hours)
|
171 |
"""
|
|
|
|
|
|
|
|
|
|
|
172 |
# Calculate which working day this datetime falls on
|
173 |
days_from_base = (dt.date() - base_date).days
|
174 |
|
@@ -187,16 +194,22 @@ def datetime_to_slot(dt: datetime, base_date: date) -> int:
|
|
187 |
|
188 |
def calculate_duration_slots(start_dt: datetime, end_dt: datetime) -> int:
|
189 |
"""
|
190 |
-
Calculate duration in 30-minute slots between two datetimes
|
191 |
|
192 |
Args:
|
193 |
-
start_dt: Start datetime (
|
194 |
-
end_dt: End datetime (
|
195 |
|
196 |
Returns:
|
197 |
Duration in 30-minute slots (minimum 1 slot)
|
198 |
"""
|
199 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
time_diff = end_dt - start_dt
|
201 |
total_minutes = time_diff.total_seconds() / 60
|
202 |
|
|
|
30 |
return str(val)
|
31 |
|
32 |
def to_datetime(val):
|
33 |
+
"""Convert icalendar datetime to Python datetime object, keeping original timezone."""
|
34 |
if hasattr(val, "dt"):
|
35 |
dt = val.dt
|
36 |
if isinstance(dt, datetime):
|
37 |
+
# Keep timezone-aware datetimes as-is, or return naive ones unchanged
|
38 |
+
return dt
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
elif isinstance(dt, date):
|
40 |
# Convert date to datetime at 9 AM (naive)
|
41 |
return datetime.combine(
|
|
|
43 |
)
|
44 |
return None
|
45 |
|
46 |
+
# Parse datetime objects for slot calculation (keeping original timezone)
|
47 |
start_datetime = to_datetime(dtstart)
|
48 |
end_datetime = to_datetime(dtend)
|
49 |
|
|
|
97 |
"""
|
98 |
Validate that all calendar entries fall within standard working hours (9:00-18:00) and don't span lunch break (13:00-14:00).
|
99 |
|
100 |
+
Each event is validated in its original timezone context.
|
101 |
+
|
102 |
Args:
|
103 |
calendar_entries: List of calendar entry dictionaries
|
104 |
|
|
|
116 |
end_datetime = entry.get("end_datetime")
|
117 |
|
118 |
if start_datetime and isinstance(start_datetime, datetime):
|
119 |
+
# Use the hour in the event's original timezone
|
120 |
+
event_start_hour = start_datetime.hour
|
121 |
+
event_start_minute = start_datetime.minute
|
122 |
+
|
123 |
+
if event_start_hour < 9:
|
124 |
violations.append(
|
125 |
+
f"'{summary}' starts at {event_start_hour:02d}:{event_start_minute:02d} (before 9:00)"
|
126 |
)
|
127 |
|
128 |
if end_datetime and isinstance(end_datetime, datetime):
|
129 |
+
# Use the hour in the event's original timezone
|
130 |
+
event_end_hour = end_datetime.hour
|
131 |
+
event_end_minute = end_datetime.minute
|
132 |
+
|
133 |
+
if event_end_hour > 18 or (event_end_hour == 18 and event_end_minute > 0):
|
134 |
violations.append(
|
135 |
+
f"'{summary}' ends at {event_end_hour:02d}:{event_end_minute:02d} (after 18:00)"
|
136 |
)
|
137 |
|
138 |
+
# Check for lunch break spanning (13:00-14:00) in original timezone
|
139 |
if (
|
140 |
start_datetime
|
141 |
and end_datetime
|
|
|
165 |
Convert a datetime to a 30-minute slot index within working days.
|
166 |
|
167 |
Args:
|
168 |
+
dt: The datetime to convert (timezone-aware or naive)
|
169 |
+
base_date: The base date (slot 0 = base_date at 9:00 AM)
|
170 |
|
171 |
Returns:
|
172 |
The slot index (each slot = 30 minutes within working hours)
|
173 |
"""
|
174 |
+
# Convert timezone-aware datetime to naive for slot calculation
|
175 |
+
if dt.tzinfo is not None:
|
176 |
+
# Convert to local system timezone, then make naive
|
177 |
+
dt = dt.astimezone().replace(tzinfo=None)
|
178 |
+
|
179 |
# Calculate which working day this datetime falls on
|
180 |
days_from_base = (dt.date() - base_date).days
|
181 |
|
|
|
194 |
|
195 |
def calculate_duration_slots(start_dt: datetime, end_dt: datetime) -> int:
|
196 |
"""
|
197 |
+
Calculate duration in 30-minute slots between two datetimes.
|
198 |
|
199 |
Args:
|
200 |
+
start_dt: Start datetime (timezone-aware or naive)
|
201 |
+
end_dt: End datetime (timezone-aware or naive)
|
202 |
|
203 |
Returns:
|
204 |
Duration in 30-minute slots (minimum 1 slot)
|
205 |
"""
|
206 |
+
# Convert timezone-aware datetimes to naive for calculation
|
207 |
+
if start_dt.tzinfo is not None:
|
208 |
+
start_dt = start_dt.astimezone().replace(tzinfo=None)
|
209 |
+
if end_dt.tzinfo is not None:
|
210 |
+
end_dt = end_dt.astimezone().replace(tzinfo=None)
|
211 |
+
|
212 |
+
# Calculate difference in minutes
|
213 |
time_diff = end_dt - start_dt
|
214 |
total_minutes = time_diff.total_seconds() / 60
|
215 |
|
tests/data/calendar.ics
CHANGED
@@ -1,59 +1,59 @@
|
|
1 |
-
BEGIN:VCALENDAR
|
2 |
-
VERSION:2.0
|
3 |
-
PRODID:-//ical.marudot.com//iCal Event Maker
|
4 |
-
CALSCALE:GREGORIAN
|
5 |
-
BEGIN:VTIMEZONE
|
6 |
-
TZID:Africa/Lagos
|
7 |
-
LAST-MODIFIED:20240422T053450Z
|
8 |
-
TZURL:https://www.tzurl.org/zoneinfo-outlook/Africa/Lagos
|
9 |
-
X-LIC-LOCATION:Africa/Lagos
|
10 |
-
BEGIN:STANDARD
|
11 |
-
TZNAME:WAT
|
12 |
-
TZOFFSETFROM:+0100
|
13 |
-
TZOFFSETTO:+0100
|
14 |
-
DTSTART:19700101T000000
|
15 |
-
END:STANDARD
|
16 |
-
END:VTIMEZONE
|
17 |
-
BEGIN:VEVENT
|
18 |
-
DTSTAMP:20250620T134120Z
|
19 |
-
UID:recur-meeting-2@mock
|
20 |
-
DTSTART;TZID=Africa/Lagos:20250602T150000
|
21 |
-
DTEND;TZID=Africa/Lagos:20250602T160000
|
22 |
-
SUMMARY:Project Review
|
23 |
-
END:VEVENT
|
24 |
-
BEGIN:VEVENT
|
25 |
-
DTSTAMP:20250620T134120Z
|
26 |
-
UID:recur-meeting-1@mock
|
27 |
-
DTSTART;TZID=Africa/Lagos:20250603T100000
|
28 |
-
DTEND;TZID=Africa/Lagos:20250603T110000
|
29 |
-
SUMMARY:Team Sync
|
30 |
-
END:VEVENT
|
31 |
-
BEGIN:VEVENT
|
32 |
-
DTSTAMP:20250620T134120Z
|
33 |
-
UID:single-event-1@mock
|
34 |
-
DTSTART;TZID=Africa/Lagos:20250605T140000
|
35 |
-
DTEND;TZID=Africa/Lagos:20250605T150000
|
36 |
-
SUMMARY:Client Call
|
37 |
-
END:VEVENT
|
38 |
-
BEGIN:VEVENT
|
39 |
-
DTSTAMP:20250620T134120Z
|
40 |
-
UID:single-event-2@mock
|
41 |
-
DTSTART;TZID=Africa/Lagos:20250616T160000
|
42 |
-
DTEND;TZID=Africa/Lagos:20250616T170000
|
43 |
-
SUMMARY:Workshop
|
44 |
-
END:VEVENT
|
45 |
-
BEGIN:VEVENT
|
46 |
-
DTSTAMP:20250620T134120Z
|
47 |
-
UID:single-event-3@mock
|
48 |
-
DTSTART;TZID=Africa/Lagos:20250707T110000
|
49 |
-
DTEND;TZID=Africa/Lagos:20250707T120000
|
50 |
-
SUMMARY:Planning Session
|
51 |
-
END:VEVENT
|
52 |
-
BEGIN:VEVENT
|
53 |
-
DTSTAMP:20250620T134120Z
|
54 |
-
UID:single-event-4@mock
|
55 |
-
DTSTART;TZID=Africa/Lagos:20250722T090000
|
56 |
-
DTEND;TZID=Africa/Lagos:20250722T100000
|
57 |
-
SUMMARY:Demo
|
58 |
-
END:VEVENT
|
59 |
END:VCALENDAR
|
|
|
1 |
+
BEGIN:VCALENDAR
|
2 |
+
VERSION:2.0
|
3 |
+
PRODID:-//ical.marudot.com//iCal Event Maker
|
4 |
+
CALSCALE:GREGORIAN
|
5 |
+
BEGIN:VTIMEZONE
|
6 |
+
TZID:Africa/Lagos
|
7 |
+
LAST-MODIFIED:20240422T053450Z
|
8 |
+
TZURL:https://www.tzurl.org/zoneinfo-outlook/Africa/Lagos
|
9 |
+
X-LIC-LOCATION:Africa/Lagos
|
10 |
+
BEGIN:STANDARD
|
11 |
+
TZNAME:WAT
|
12 |
+
TZOFFSETFROM:+0100
|
13 |
+
TZOFFSETTO:+0100
|
14 |
+
DTSTART:19700101T000000
|
15 |
+
END:STANDARD
|
16 |
+
END:VTIMEZONE
|
17 |
+
BEGIN:VEVENT
|
18 |
+
DTSTAMP:20250620T134120Z
|
19 |
+
UID:recur-meeting-2@mock
|
20 |
+
DTSTART;TZID=Africa/Lagos:20250602T150000
|
21 |
+
DTEND;TZID=Africa/Lagos:20250602T160000
|
22 |
+
SUMMARY:Project Review
|
23 |
+
END:VEVENT
|
24 |
+
BEGIN:VEVENT
|
25 |
+
DTSTAMP:20250620T134120Z
|
26 |
+
UID:recur-meeting-1@mock
|
27 |
+
DTSTART;TZID=Africa/Lagos:20250603T100000
|
28 |
+
DTEND;TZID=Africa/Lagos:20250603T110000
|
29 |
+
SUMMARY:Team Sync
|
30 |
+
END:VEVENT
|
31 |
+
BEGIN:VEVENT
|
32 |
+
DTSTAMP:20250620T134120Z
|
33 |
+
UID:single-event-1@mock
|
34 |
+
DTSTART;TZID=Africa/Lagos:20250605T140000
|
35 |
+
DTEND;TZID=Africa/Lagos:20250605T150000
|
36 |
+
SUMMARY:Client Call
|
37 |
+
END:VEVENT
|
38 |
+
BEGIN:VEVENT
|
39 |
+
DTSTAMP:20250620T134120Z
|
40 |
+
UID:single-event-2@mock
|
41 |
+
DTSTART;TZID=Africa/Lagos:20250616T160000
|
42 |
+
DTEND;TZID=Africa/Lagos:20250616T170000
|
43 |
+
SUMMARY:Workshop
|
44 |
+
END:VEVENT
|
45 |
+
BEGIN:VEVENT
|
46 |
+
DTSTAMP:20250620T134120Z
|
47 |
+
UID:single-event-3@mock
|
48 |
+
DTSTART;TZID=Africa/Lagos:20250707T110000
|
49 |
+
DTEND;TZID=Africa/Lagos:20250707T120000
|
50 |
+
SUMMARY:Planning Session
|
51 |
+
END:VEVENT
|
52 |
+
BEGIN:VEVENT
|
53 |
+
DTSTAMP:20250620T134120Z
|
54 |
+
UID:single-event-4@mock
|
55 |
+
DTSTART;TZID=Africa/Lagos:20250722T090000
|
56 |
+
DTEND;TZID=Africa/Lagos:20250722T100000
|
57 |
+
SUMMARY:Demo
|
58 |
+
END:VEVENT
|
59 |
END:VCALENDAR
|