blackopsrepl commited on
Commit
ede5a9e
·
1 Parent(s): 600ed2b

hotfix: remove timezone normalization and update working hours validation

Browse files

Working hours validation now occurs within each's involved timezone's context

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, normalized to current timezone."""
34
  if hasattr(val, "dt"):
35
  dt = val.dt
36
  if isinstance(dt, datetime):
37
- # If timezone-aware, convert to current timezone, then make naive
38
- if dt.tzinfo is not None:
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 (now normalized to current timezone)
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
- if start_datetime.hour < 9:
 
 
 
 
124
  violations.append(
125
- f"'{summary}' starts at {start_datetime.hour:02d}:{start_datetime.minute:02d} (before 9:00)"
126
  )
127
 
128
  if end_datetime and isinstance(end_datetime, datetime):
129
- if end_datetime.hour > 18 or (
130
- end_datetime.hour == 18 and end_datetime.minute > 0
131
- ):
 
 
132
  violations.append(
133
- f"'{summary}' ends at {end_datetime.hour:02d}:{end_datetime.minute:02d} (after 18:00)"
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 (should be naive local time)
167
- base_date: The base date (slot 0 = base_date at 9:00 AM local time)
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 (naive local time).
191
 
192
  Args:
193
- start_dt: Start datetime (naive local time)
194
- end_dt: End datetime (naive local time)
195
 
196
  Returns:
197
  Duration in 30-minute slots (minimum 1 slot)
198
  """
199
- # Calculate difference in minutes (both should be naive local time)
 
 
 
 
 
 
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