From 0e710917c3109d122e0db34d7bfdce22595145c9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 11 Nov 2026 11:45:36 +0100 Subject: [PATCH 1/3] fix(free_busy_finder): Fix timezone handling Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/calendar.py | 37 ++++++------- ex_app/lib/all_tools/lib/freebusy_finder.py | 25 ++++++++- tests/test_freebusy_timezone.py | 61 +++++++++++++++++++++ 3 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 tests/test_freebusy_timezone.py diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index a33584bb..09f4bfda 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -18,7 +18,7 @@ from ex_app.lib.all_tools.lib.audience import principal_radius from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, Reversibility, destructive, impulse, item_count, read_only, reversibility from ex_app.lib.all_tools.lib.tz import get_timezone, user_timezone -from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour +from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, freebusy_range async def get_tools(nc: AsyncNextcloudApp): @@ -187,29 +187,22 @@ async def schedule_event(calendar_name: str, title: str, description: str, start return True def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: Optional[float], - start_time: Optional[str], end_time: Optional[str]): + start_time: Optional[str], end_time: Optional[str], timezone: Optional[str] = None): me = ncSync.ocs('GET', '/ocs/v2.php/cloud/user') + if not me.get('email'): + raise Exception('Cannot look up free time slots: the current user has no email address set in their Nextcloud profile') attendees = 'ORGANIZER:mailto:' + me['email'] + '\n' attendees += 'ATTENDEE:mailto:' + me['email'] + '\n' for attendee in participants: attendees += f"ATTENDEE:mailto:{attendee}\n" - if start_time is None: - start_time = round_to_nearest_half_hour(datetime.now(timezone.utc)) - else: - start_time = datetime.combine(datetime.strptime(start_time, "%Y-%m-%d").date(), datetime.min.time(), - timezone.utc) - if end_time is None: - end_time = start_time + timedelta(days=7) - else: - end_time = datetime.combine(datetime.strptime(end_time, "%Y-%m-%d").date(), datetime.min.time(), - timezone.utc) - if start_time >= end_time: - end_time = start_time + timedelta(days=7) + # dates are interpreted in the user's timezone (falls back to UTC if the profile has none) + tz = pytz.timezone(get_timezone(timezone or me.get('timezone') or 'UTC')) + start_time, end_time = freebusy_range(start_time, end_time, tz) - dtstart = start_time.strftime("%Y%m%dT%H%M%SZ") - dtend = end_time.strftime("%Y%m%dT%H%M%SZ") + dtstart = start_time.astimezone(pytz.UTC).strftime("%Y%m%dT%H%M%SZ") + dtend = end_time.astimezone(pytz.UTC).strftime("%Y%m%dT%H%M%SZ") freebusyRequest = """ BEGIN:VCALENDAR @@ -246,22 +239,26 @@ def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: vcal = vobject.readOne(vcal_text) for fb in vcal.vfreebusy.contents.get("freebusy", []): busy_times.append(fb.value[0]) + if slot_duration is None: + slot_duration = 1 available_slots = find_available_slots(start_time, end_time, busy_times, timedelta(hours=slot_duration)) - return available_slots + # return the slots in the user's timezone, so they are not presented as UTC times + return [(start.astimezone(tz).isoformat(), end.astimezone(tz).isoformat()) for start, end in available_slots] @tool @impulse(ImpulseRadius.SELF) @read_only - async def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: Optional[str]): + async def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: Optional[str], timezone: Optional[str] = None): """ Finds a free time slot where all participants have time :param participants: The list of participants to find a free slot for (These should be email addresses. If possible use the email addresses from contacts) :param slot_duration: How long the time slot should be in hours, defaults to one hour :param start_time: the start time of the range within which to check for free slots (by default this will be now; use the following format: 2025-01-31) :param end_time: the end time of the range within which to check for free slots (by default this will be 7 days after start_time; use the following format: 2025-01-31) - :return: + :param timezone: Timezone in which start_time and end_time are interpreted and the slots are returned (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. + :return: a list of (start, end) ISO 8601 timestamps in the given timezone """ - available_slots = await asyncio.to_thread(find_free_time_slot_in_calendar_sync, participants, slot_duration, start_time, end_time) + available_slots = await asyncio.to_thread(find_free_time_slot_in_calendar_sync, participants, slot_duration, start_time, end_time, timezone) return available_slots def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str] = None, diff --git a/ex_app/lib/all_tools/lib/freebusy_finder.py b/ex_app/lib/all_tools/lib/freebusy_finder.py index a07b3890..11b60465 100644 --- a/ex_app/lib/all_tools/lib/freebusy_finder.py +++ b/ex_app/lib/all_tools/lib/freebusy_finder.py @@ -71,4 +71,27 @@ def round_to_nearest_half_hour(dt=None): rounded_minutes = 0 dt += timedelta(hours=1) # Round up to next hour if > 45 min - return dt.replace(minute=rounded_minutes, second=0, microsecond=0) \ No newline at end of file + return dt.replace(minute=rounded_minutes, second=0, microsecond=0) + + +def freebusy_range(start_date, end_date, tz): + """ + Resolves the range to search for free slots in. Dates are interpreted as midnight in `tz`. + + :param start_date: Start date as 'YYYY-MM-DD' (default: now, rounded to the nearest half-hour) + :param end_date: End date as 'YYYY-MM-DD' (default: 7 days after the start) + :param tz: A pytz timezone + :return: (start, end) timezone-aware datetimes + """ + if start_date is None: + start_time = tz.normalize(round_to_nearest_half_hour(datetime.now(tz))) + else: + start_time = tz.localize(datetime.strptime(start_date, "%Y-%m-%d")) + # add the 7 days in wall-clock time, so a DST change in between does not shift the end by an hour + default_end = tz.localize(start_time.replace(tzinfo=None) + timedelta(days=7)) + if end_date is None: + return start_time, default_end + end_time = tz.localize(datetime.strptime(end_date, "%Y-%m-%d")) + if start_time >= end_time: + end_time = default_end + return start_time, end_time diff --git a/tests/test_freebusy_timezone.py b/tests/test_freebusy_timezone.py new file mode 100644 index 00000000..0070c1bb --- /dev/null +++ b/tests/test_freebusy_timezone.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Timezone handling of the free/busy range used by find_free_time_slot_in_calendar. + +Run standalone: + + PYTHONPATH=. python tests/test_freebusy_timezone.py + +or via pytest when available. +""" +import os +import sys +from datetime import datetime, timedelta + +import pytz + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, freebusy_range # noqa: E402 + +BERLIN = pytz.timezone("Europe/Berlin") + + +def test_dates_are_midnight_in_user_timezone(): + start, end = freebusy_range("2026-11-12", "2026-11-14", BERLIN) + assert start.astimezone(pytz.UTC) == pytz.UTC.localize(datetime(2026, 11, 11, 23, 0)), start + assert end.astimezone(pytz.UTC) == pytz.UTC.localize(datetime(2026, 11, 13, 23, 0)), end + + +def test_default_end_keeps_wall_clock_across_dst(): + # DST ends in Berlin on 2026-10-25 + start, end = freebusy_range("2026-10-22", None, BERLIN) + assert end.replace(tzinfo=None) == datetime(2026, 10, 29), end + assert end.utcoffset() == timedelta(hours=1), end + + +def test_end_before_start_falls_back_to_seven_days(): + start, end = freebusy_range("2026-11-12", "2026-11-10", BERLIN) + assert end.replace(tzinfo=None) == datetime(2026, 11, 19), end + + +def test_default_start_is_in_user_timezone(): + kathmandu = pytz.timezone("Asia/Kathmandu") # UTC+5:45 + start, _ = freebusy_range(None, None, kathmandu) + assert start.minute in (0, 30), start + assert start.utcoffset() == timedelta(hours=5, minutes=45), start + + +def test_slots_respect_utc_busy_times_in_local_range(): + start, end = freebusy_range("2026-11-12", "2026-11-13", BERLIN) + # busy 00:00-08:00 Berlin, given in UTC as the CalDAV server returns it + busy = [(pytz.UTC.localize(datetime(2026, 11, 11, 23)), pytz.UTC.localize(datetime(2026, 11, 12, 7)))] + (slot_start, _), *_ = find_available_slots(start, end, busy) + assert slot_start.astimezone(BERLIN).isoformat() == "2026-11-12T08:00:00+01:00", slot_start + + +if __name__ == "__main__": + for name, fn in list(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print(f"ok {name}") From 4734db8f10b3a9d50e0be72ecf15697e331bc25c Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 11 Nov 2026 14:43:23 +0100 Subject: [PATCH 2/3] fix(freebusy_finder): Small correctness fixes Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/calendar.py | 8 +++---- ex_app/lib/all_tools/lib/freebusy_finder.py | 20 ++++++++++------- tests/test_freebusy_timezone.py | 25 ++++++++++++++++----- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index 09f4bfda..f80052d2 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -186,8 +186,8 @@ async def schedule_event(calendar_name: str, title: str, description: str, start return True - def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: Optional[float], - start_time: Optional[str], end_time: Optional[str], timezone: Optional[str] = None): + def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: Optional[float] = None, + start_time: Optional[str] = None, end_time: Optional[str] = None, timezone: Optional[str] = None): me = ncSync.ocs('GET', '/ocs/v2.php/cloud/user') if not me.get('email'): raise Exception('Cannot look up free time slots: the current user has no email address set in their Nextcloud profile') @@ -248,13 +248,13 @@ def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: @tool @impulse(ImpulseRadius.SELF) @read_only - async def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: Optional[str], timezone: Optional[str] = None): + async def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, timezone: Optional[str] = None): """ Finds a free time slot where all participants have time :param participants: The list of participants to find a free slot for (These should be email addresses. If possible use the email addresses from contacts) :param slot_duration: How long the time slot should be in hours, defaults to one hour :param start_time: the start time of the range within which to check for free slots (by default this will be now; use the following format: 2025-01-31) - :param end_time: the end time of the range within which to check for free slots (by default this will be 7 days after start_time; use the following format: 2025-01-31) + :param end_time: the end time of the range within which to check for free slots (inclusive; by default this will be 7 days after start_time; use the following format: 2025-01-31) :param timezone: Timezone in which start_time and end_time are interpreted and the slots are returned (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. :return: a list of (start, end) ISO 8601 timestamps in the given timezone """ diff --git a/ex_app/lib/all_tools/lib/freebusy_finder.py b/ex_app/lib/all_tools/lib/freebusy_finder.py index 11b60465..6117e855 100644 --- a/ex_app/lib/all_tools/lib/freebusy_finder.py +++ b/ex_app/lib/all_tools/lib/freebusy_finder.py @@ -74,24 +74,28 @@ def round_to_nearest_half_hour(dt=None): return dt.replace(minute=rounded_minutes, second=0, microsecond=0) -def freebusy_range(start_date, end_date, tz): +def freebusy_range(start_date, end_date, tz, now=None): """ - Resolves the range to search for free slots in. Dates are interpreted as midnight in `tz`. + Resolves the range to search for free slots in. Dates are interpreted in `tz`; the end date is inclusive. - :param start_date: Start date as 'YYYY-MM-DD' (default: now, rounded to the nearest half-hour) - :param end_date: End date as 'YYYY-MM-DD' (default: 7 days after the start) + :param start_date: Start date as 'YYYY-MM-DD' (default: now, rounded to the nearest half-hour). Never earlier than now. + :param end_date: End date as 'YYYY-MM-DD', inclusive (default: 7 days after the start) :param tz: A pytz timezone + :param now: The current time, timezone-aware (default: datetime.now(tz)) :return: (start, end) timezone-aware datetimes """ + earliest = tz.normalize(round_to_nearest_half_hour(now or datetime.now(tz))) if start_date is None: - start_time = tz.normalize(round_to_nearest_half_hour(datetime.now(tz))) + start_time = earliest else: - start_time = tz.localize(datetime.strptime(start_date, "%Y-%m-%d")) - # add the 7 days in wall-clock time, so a DST change in between does not shift the end by an hour + # slots in the past are of no use + start_time = max(tz.localize(datetime.strptime(start_date, "%Y-%m-%d")), earliest) + # add days in wall-clock time, so a DST change in between does not shift the end by an hour default_end = tz.localize(start_time.replace(tzinfo=None) + timedelta(days=7)) if end_date is None: return start_time, default_end - end_time = tz.localize(datetime.strptime(end_date, "%Y-%m-%d")) + # end at midnight after the end date, so the end date itself is included + end_time = tz.localize(datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)) if start_time >= end_time: end_time = default_end return start_time, end_time diff --git a/tests/test_freebusy_timezone.py b/tests/test_freebusy_timezone.py index 0070c1bb..079d55bc 100644 --- a/tests/test_freebusy_timezone.py +++ b/tests/test_freebusy_timezone.py @@ -19,26 +19,41 @@ from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, freebusy_range # noqa: E402 BERLIN = pytz.timezone("Europe/Berlin") +# fixed "now" before all test dates, so the tests do not depend on when they are run +NOW = BERLIN.localize(datetime(2026, 10, 1, 10, 0)) def test_dates_are_midnight_in_user_timezone(): - start, end = freebusy_range("2026-11-12", "2026-11-14", BERLIN) + start, end = freebusy_range("2026-11-12", "2026-11-14", BERLIN, now=NOW) assert start.astimezone(pytz.UTC) == pytz.UTC.localize(datetime(2026, 11, 11, 23, 0)), start - assert end.astimezone(pytz.UTC) == pytz.UTC.localize(datetime(2026, 11, 13, 23, 0)), end + # the end date is inclusive + assert end.astimezone(pytz.UTC) == pytz.UTC.localize(datetime(2026, 11, 14, 23, 0)), end def test_default_end_keeps_wall_clock_across_dst(): # DST ends in Berlin on 2026-10-25 - start, end = freebusy_range("2026-10-22", None, BERLIN) + start, end = freebusy_range("2026-10-22", None, BERLIN, now=NOW) assert end.replace(tzinfo=None) == datetime(2026, 10, 29), end assert end.utcoffset() == timedelta(hours=1), end def test_end_before_start_falls_back_to_seven_days(): - start, end = freebusy_range("2026-11-12", "2026-11-10", BERLIN) + start, end = freebusy_range("2026-11-12", "2026-11-10", BERLIN, now=NOW) assert end.replace(tzinfo=None) == datetime(2026, 11, 19), end +def test_single_day_covers_whole_day(): + start, end = freebusy_range("2026-11-12", "2026-11-12", BERLIN, now=NOW) + assert start.replace(tzinfo=None) == datetime(2026, 11, 12), start + assert end.replace(tzinfo=None) == datetime(2026, 11, 13), end + + +def test_start_in_the_past_is_clamped_to_now(): + start, end = freebusy_range("2026-09-01", "2026-10-01", BERLIN, now=NOW) + assert start == NOW, start + assert end.replace(tzinfo=None) == datetime(2026, 10, 2), end + + def test_default_start_is_in_user_timezone(): kathmandu = pytz.timezone("Asia/Kathmandu") # UTC+5:45 start, _ = freebusy_range(None, None, kathmandu) @@ -47,7 +62,7 @@ def test_default_start_is_in_user_timezone(): def test_slots_respect_utc_busy_times_in_local_range(): - start, end = freebusy_range("2026-11-12", "2026-11-13", BERLIN) + start, end = freebusy_range("2026-11-12", "2026-11-13", BERLIN, now=NOW) # busy 00:00-08:00 Berlin, given in UTC as the CalDAV server returns it busy = [(pytz.UTC.localize(datetime(2026, 11, 11, 23)), pytz.UTC.localize(datetime(2026, 11, 12, 7)))] (slot_start, _), *_ = find_available_slots(start, end, busy) From ebddc046385d6670658dd45fa5dec7edf9598192 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 11 Nov 2026 14:48:55 +0100 Subject: [PATCH 3/3] fix(freebusy_finder): Small correctness fixes Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/calendar.py | 59 +++++++++++++++------ ex_app/lib/all_tools/lib/freebusy_finder.py | 26 ++++++--- tests/test_freebusy_timezone.py | 35 ++++++++++-- 3 files changed, 94 insertions(+), 26 deletions(-) diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index f80052d2..1686097a 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio +import re import time import uuid from datetime import datetime, timezone, timedelta @@ -21,6 +22,9 @@ from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, freebusy_range +EMAIL_ADDRESS = re.compile(r'[^\s@:;,<>"]+@[^\s@:;,<>"]+') + + async def get_tools(nc: AsyncNextcloudApp): ncSync = NextcloudApp() ncSync.set_user(await nc.user) @@ -191,14 +195,25 @@ def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: me = ncSync.ocs('GET', '/ocs/v2.php/cloud/user') if not me.get('email'): raise Exception('Cannot look up free time slots: the current user has no email address set in their Nextcloud profile') + if not EMAIL_ADDRESS.fullmatch(me['email']): + raise Exception('Cannot look up free time slots: the email address in the current user\'s Nextcloud profile is not a plain email address') + + # the addresses go verbatim into the iCalendar body, so anything that could break out of the property is rejected + for attendee in participants: + if not EMAIL_ADDRESS.fullmatch(attendee): + raise ValueError(f"Invalid participant '{attendee}': participants must be plain email addresses") + if slot_duration is None: + slot_duration = 1 + if slot_duration <= 0: + raise ValueError("slot_duration must be a positive number of hours") attendees = 'ORGANIZER:mailto:' + me['email'] + '\n' attendees += 'ATTENDEE:mailto:' + me['email'] + '\n' for attendee in participants: attendees += f"ATTENDEE:mailto:{attendee}\n" - # dates are interpreted in the user's timezone (falls back to UTC if the profile has none) - tz = pytz.timezone(get_timezone(timezone or me.get('timezone') or 'UTC')) + # dates are interpreted in the user's timezone + tz = pytz.timezone(get_timezone(timezone or me.get('timezone'))) start_time, end_time = freebusy_range(start_time, end_time, tz) dtstart = start_time.astimezone(pytz.UTC).strftime("%Y%m%dT%H%M%SZ") @@ -225,25 +240,39 @@ def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: "Content-Type": "text/calendar; charset=utf-8", "Depth": "0", }, data=freebusyRequest) - print(freebusyRequest) - print(response.text) + if response.status_code != 200: + raise Exception(f"Free/busy lookup failed (HTTP {response.status_code})") - # Parse the XML response to extract vCard data - namespace = {"CAL": "urn:ietf:params:xml:ns:caldav"} # Define the namespace + # Parse the XML schedule-response, which has one response per recipient + namespace = {"CAL": "urn:ietf:params:xml:ns:caldav", "D": "DAV:"} root = ET.fromstring(response.text) - vcal_elements = root.findall(".//CAL:calendar-data", namespace) - # Parse vcal strings into dictionaries busy_times = [] - for vcal_element in vcal_elements: + # the server answers 200 even when it cannot resolve a recipient (e.g. someone outside this Nextcloud), + # so a failed recipient must not be mistaken for one without busy times + unknown_availability = [] + for recipient_response in root.findall(".//CAL:response", namespace): + recipient = (recipient_response.findtext("CAL:recipient/D:href", "", namespace) or "").strip() + if recipient.lower().startswith("mailto:"): + recipient = recipient[len("mailto:"):] + status = (recipient_response.findtext("CAL:request-status", "", namespace) or "").strip() + vcal_element = recipient_response.find("CAL:calendar-data", namespace) + if not status.startswith("2.") or vcal_element is None or not (vcal_element.text or "").strip(): + unknown_availability.append(f"{recipient} ({status or 'no free/busy data'})") + continue vcal_text = vcal_element.text.strip() vcal = vobject.readOne(vcal_text) + # one FREEBUSY property can list several comma-separated periods for fb in vcal.vfreebusy.contents.get("freebusy", []): - busy_times.append(fb.value[0]) - if slot_duration is None: - slot_duration = 1 + for busy_start, busy_end in fb.value: + if isinstance(busy_end, timedelta): + busy_end = busy_start + busy_end + busy_times.append((busy_start, busy_end)) available_slots = find_available_slots(start_time, end_time, busy_times, timedelta(hours=slot_duration)) - # return the slots in the user's timezone, so they are not presented as UTC times - return [(start.astimezone(tz).isoformat(), end.astimezone(tz).isoformat()) for start, end in available_slots] + return { + # the slots are in the user's timezone, so they are not presented as UTC times + "slots": [(start.astimezone(tz).isoformat(), end.astimezone(tz).isoformat()) for start, end in available_slots], + "availability_unknown_for": unknown_availability, + } @tool @impulse(ImpulseRadius.SELF) @@ -256,7 +285,7 @@ async def find_free_time_slot_in_calendar(participants: list[str], slot_duration :param start_time: the start time of the range within which to check for free slots (by default this will be now; use the following format: 2025-01-31) :param end_time: the end time of the range within which to check for free slots (inclusive; by default this will be 7 days after start_time; use the following format: 2025-01-31) :param timezone: Timezone in which start_time and end_time are interpreted and the slots are returned (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. - :return: a list of (start, end) ISO 8601 timestamps in the given timezone + :return: "slots": a list of (start, end) ISO 8601 timestamps in the given timezone; "availability_unknown_for": participants whose calendars could not be checked (e.g. because they are not on this Nextcloud), so the slots may not suit them """ available_slots = await asyncio.to_thread(find_free_time_slot_in_calendar_sync, participants, slot_duration, start_time, end_time, timezone) return available_slots diff --git a/ex_app/lib/all_tools/lib/freebusy_finder.py b/ex_app/lib/all_tools/lib/freebusy_finder.py index 6117e855..0e128644 100644 --- a/ex_app/lib/all_tools/lib/freebusy_finder.py +++ b/ex_app/lib/all_tools/lib/freebusy_finder.py @@ -74,6 +74,11 @@ def round_to_nearest_half_hour(dt=None): return dt.replace(minute=rounded_minutes, second=0, microsecond=0) +def _local_midnight(date, tz): + # normalize, so a midnight skipped by a DST change becomes the first wall time that exists that day + return tz.normalize(tz.localize(date)) + + def freebusy_range(start_date, end_date, tz, now=None): """ Resolves the range to search for free slots in. Dates are interpreted in `tz`; the end date is inclusive. @@ -83,19 +88,24 @@ def freebusy_range(start_date, end_date, tz, now=None): :param tz: A pytz timezone :param now: The current time, timezone-aware (default: datetime.now(tz)) :return: (start, end) timezone-aware datetimes + :raises ValueError: if the range ends before it starts or lies entirely in the past """ earliest = tz.normalize(round_to_nearest_half_hour(now or datetime.now(tz))) - if start_date is None: + # compare parsed dates, not strings: strptime also accepts unpadded dates like '2026-11-5' + start_day = datetime.strptime(start_date, "%Y-%m-%d") if start_date is not None else None + end_day = datetime.strptime(end_date, "%Y-%m-%d") if end_date is not None else None + if start_day is None: start_time = earliest else: # slots in the past are of no use - start_time = max(tz.localize(datetime.strptime(start_date, "%Y-%m-%d")), earliest) - # add days in wall-clock time, so a DST change in between does not shift the end by an hour - default_end = tz.localize(start_time.replace(tzinfo=None) + timedelta(days=7)) - if end_date is None: - return start_time, default_end + start_time = max(_local_midnight(start_day, tz), earliest) + if end_day is None: + # add days in wall-clock time, so a DST change in between does not shift the end by an hour + return start_time, tz.normalize(tz.localize(start_time.replace(tzinfo=None) + timedelta(days=7))) # end at midnight after the end date, so the end date itself is included - end_time = tz.localize(datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)) + end_time = _local_midnight(end_day + timedelta(days=1), tz) + if start_day is not None and end_day < start_day: + raise ValueError(f"The end date {end_date} is before the start date {start_date}") if start_time >= end_time: - end_time = default_end + raise ValueError(f"The range ending on {end_date} lies entirely in the past; free slots can only be found from now on") return start_time, end_time diff --git a/tests/test_freebusy_timezone.py b/tests/test_freebusy_timezone.py index 079d55bc..33c925d9 100644 --- a/tests/test_freebusy_timezone.py +++ b/tests/test_freebusy_timezone.py @@ -37,9 +37,38 @@ def test_default_end_keeps_wall_clock_across_dst(): assert end.utcoffset() == timedelta(hours=1), end -def test_end_before_start_falls_back_to_seven_days(): - start, end = freebusy_range("2026-11-12", "2026-11-10", BERLIN, now=NOW) - assert end.replace(tzinfo=None) == datetime(2026, 11, 19), end +def _raises_value_error(fn): + try: + fn() + except ValueError: + return True + return False + + +def test_end_before_start_is_rejected(): + assert _raises_value_error(lambda: freebusy_range("2026-11-12", "2026-11-10", BERLIN, now=NOW)) + + +def test_unpadded_dates_are_compared_as_dates(): + start, end = freebusy_range("2026-11-5", "2026-11-10", BERLIN, now=NOW) + assert start.replace(tzinfo=None) == datetime(2026, 11, 5), start + assert end.replace(tzinfo=None) == datetime(2026, 11, 11), end + + +def test_range_in_the_past_is_rejected(): + assert _raises_value_error(lambda: freebusy_range("2026-09-01", "2026-09-05", BERLIN, now=NOW)) + assert _raises_value_error(lambda: freebusy_range(None, "2026-09-05", BERLIN, now=NOW)) + + +def test_midnight_skipped_by_dst_is_normalized(): + # DST starts in Santiago at midnight on 2026-09-06, so 00:00 does not exist that day + santiago = pytz.timezone("America/Santiago") + now = santiago.localize(datetime(2026, 8, 1, 10, 0)) + start, _ = freebusy_range("2026-09-06", "2026-09-06", santiago, now=now) + assert start.replace(tzinfo=None) == datetime(2026, 9, 6, 1, 0), start + assert start.utcoffset() == timedelta(hours=-3), start + _, end = freebusy_range("2026-09-01", "2026-09-05", santiago, now=now) + assert end.replace(tzinfo=None) == datetime(2026, 9, 6, 1, 0), end def test_single_day_covers_whole_day():