Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 56 additions & 30 deletions ex_app/lib/all_tools/calendar.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,7 +19,10 @@
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


EMAIL_ADDRESS = re.compile(r'[^\s@:;,<>"]+@[^\s@:;,<>"]+')


async def get_tools(nc: AsyncNextcloudApp):
Expand Down Expand Up @@ -186,30 +190,34 @@ 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]):
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')
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"

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
tz = pytz.timezone(get_timezone(timezone or me.get('timezone')))
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
Expand All @@ -232,36 +240,54 @@ 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])
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 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)
@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] = 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)
:return:
: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: "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)
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,
Expand Down
39 changes: 38 additions & 1 deletion ex_app/lib/all_tools/lib/freebusy_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,41 @@ 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)
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.

: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
: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)))
# 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(_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 = _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:
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
105 changes: 105 additions & 0 deletions tests/test_freebusy_timezone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 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")
# 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, now=NOW)
assert start.astimezone(pytz.UTC) == pytz.UTC.localize(datetime(2026, 11, 11, 23, 0)), start
# 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, now=NOW)
assert end.replace(tzinfo=None) == datetime(2026, 10, 29), end
assert end.utcoffset() == timedelta(hours=1), 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():
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)
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, 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)
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}")
Loading