Skip to content
Open
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
31 changes: 26 additions & 5 deletions ex_app/lib/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import random
import string
from collections.abc import Awaitable, Callable
from datetime import date
from datetime import date, datetime
from time import monotonic
from typing import Any, cast

Expand All @@ -14,6 +14,8 @@
from nc_py_api import AsyncNextcloudApp
from nc_py_api.ex_app import persistent_storage

import pytz

from ex_app.lib.all_tools.nextcloud_links import get_absolute_base_url
from ex_app.lib.all_tools.skills import list_skills_metadata
from ex_app.lib.graph import AgentState, get_graph
Expand Down Expand Up @@ -112,6 +114,15 @@ async def react(
model.bind_nextcloud(nc)
model.multimodal = multimodal

tz_name = user_tz = None
try:
user = (await nc.ocs("GET", "/ocs/v2.php/cloud/user")) or {}
tz_name = user.get("timezone") or None
if tz_name:
user_tz = pytz.timezone(tz_name)
except Exception:
tz_name = user_tz = None
Comment on lines +117 to +124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for pointing out that this code is re-run every time
maybe we can save some network calls here by caching this value per user?
and since it can change, with a timed cache perhaps:

def timed_memoize(timeout):


safe_tools, dangerous_tools = await get_tools(nc)

tools = dangerous_tools + safe_tools
Expand All @@ -130,12 +141,18 @@ async def call_model(
state: AgentState,
config: RunnableConfig,
):
current_date = date.today().strftime("%Y-%m-%d")
if user_tz is not None:
now = datetime.now(user_tz)
current_date = now.strftime("%Y-%m-%d")
current_time = f" The current time is {now.strftime('%H:%M')} in the user's timezone {tz_name}."
else:
current_date = date.today().strftime("%Y-%m-%d")
current_time = ""

system_prompt_text = """
You are a helpful AI assistant with access to tools, please respond to the user's query to the best of your ability, using the provided tools if necessary. If no tool is needed to provide a correct answer, do not use one. If you used a tool, you still need to convey its output to the user.
Use the same language for your answers as the user used in their message.
Today is {CURRENT_DATE}.
Today is {CURRENT_DATE}.{CURRENT_TIME}
Comment on lines -138 to +155

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt of passing just the date and the timezone since after some time in the chat session or picking it up again, the time would be off, so would the date but that wouldn't affect that many sessions, only the ones that span across days.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a concern, substituted inside call_model on every LLM round (and every turn of a resumed session), recomputes the current date/time anew

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah you're right, damn, but that's all the more reason to not include the time in the system prompt, it would bust the cache every second.
what do you think about adding a new tool for "date + time + user's timezone" instead?

(for later reference: https://github.com/nextcloud/context_agent/blob/ee5df89e3dcb71f05d2665647adf27d023f7fa96/ex_app/lib/agent.py #L212-L218)

@WSHAPER WSHAPER Oct 8, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did some digging: prefix caching needs byte identical prefixes, and dynamic timestamps in system prompts are a known cache killer in production, e.g. openclaw hit exactly this and paid 10x (openclaw#19534), the date itself is byte stable within a day though, so only the current time breaks the cache on every call.

Claude.ai itself uses "current date in the system prompt, injected once per conversation" (Claude system prompt docs, Claude 4 prompt analysis) plus an official time MCP reference server for precise time (modelcontextprotocol/servers/src/time), and the general rule is stable content before variable content with cache points after the stable part (AWS Bedrock prompt caching).

what do you think about adding a new tool for "date + time + user's timezone" instead?

So i'd follow that pattern: keep the date in the prompt, drop the time, add a small get_current_datetime tool. "tomorrow at noon" needs the date and the tz, not the minute.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry I should've added more context, yeah the same cache.

yep, that should do the trick.

Intuit the language the user is using (there is no tool for this, you will need to guess). Reply in the language intuited. Do not output the language you intuited.
Only use tools if you cannot answer the user without them.
If you get a link as a tool output, always add the link to your response.
Expand All @@ -152,7 +169,11 @@ async def call_model(
system_prompt_text += "Use the find_person_in_contacts tool to find a person's email address and location.\n"
if tool_enabled("find_person_in_users"):
system_prompt_text += "Use the find_person_in_users tool to find a person's userId and user details.\n"
if tool_enabled("find_details_of_current_user"):
if tz_name is not None:
system_prompt_text += (
f"The user's timezone is {tz_name}. Use it for the timezone parameter of calendar/task tools.\n"
)
Comment on lines +172 to +175

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be good to also move this to the date replacement part so date and timezone appear at the same place.

elif tool_enabled("find_details_of_current_user"):
system_prompt_text += "Use the find_details_of_current_user tool to find the current user's location and timezone.\n"
if tool_enabled("list_mails"):
system_prompt_text += "Always check for the mail account id before requesting a folder list.\n"
Expand Down Expand Up @@ -189,7 +210,7 @@ async def call_model(

# this is similar to customizing the create_react_agent with state_modifier, but is a lot more flexible
system_prompt = SystemMessage(
system_prompt_text.replace("{CURRENT_DATE}", current_date)
system_prompt_text.replace("{CURRENT_DATE}", current_date).replace("{CURRENT_TIME}", current_time)
)

response = await bound_model.ainvoke([system_prompt] + state["messages"], config)
Expand Down
17 changes: 10 additions & 7 deletions ex_app/lib/all_tools/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from nc_py_api import AsyncNextcloudApp

from ex_app.lib.all_tools.lib.decorator import dangerous_tool, safe_tool
from ex_app.lib.all_tools.lib.tz import get_timezone, starts_at_timestamp, user_timezone_async


async def get_tools(nc: AsyncNextcloudApp):
Expand All @@ -23,17 +24,18 @@ async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, t
:param title: A title for the Scheduled Task, e.g. "Transcribe audio files" -- This is only for the user's reference and has no effect on the execution of the Scheduled Task.
:param prompt: The instructions for the agent carrying out the Scheduled Task (Do not tell it to create a scheduled task here, as this is the instruction that runs as the scheduled task already. So instead of "Create a scheduled task to do X", just pass "Do X" here)
:param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day, an empty string as the recurrence_rule means the task does not repeat.
:param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Make sure to use the user's timezone for this, obtainable with find_details_of_current_user
:param timezone: Timezone (e.g., 'America/New_York') defaults to the user's current time zone
:param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately.
:param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone; used to interpret starts_at. Must be a valid IANA timezone name when supplied.
:return:
"""

tz = get_timezone(timezone or await user_timezone_async(nc))
await nc.ocs('POST', f'/ocs/v2.php/apps/assistant/assignments', json={
'title': title,
'prompt': prompt,
'recurrence': recurrence_rule,
'startsAt': int(datetime.datetime.fromisoformat(starts_at.replace('Z', '+00:00')).timestamp()) if starts_at is not None else datetime.datetime.now(datetime.UTC).timestamp(),
'timezone': pytz.timezone(timezone).zone if timezone is not None else None
'startsAt': starts_at_timestamp(starts_at, tz) if starts_at is not None else datetime.datetime.now(datetime.UTC).timestamp(),
'timezone': tz
})

return True
Expand All @@ -56,16 +58,17 @@ async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rul
:param id: The ID of the Scheduled Task to update, you can obtain this from the list_scheduled_tasks tool
:param prompt: The instructions for the AI carrying out the Scheduled Task. Pass `None` to leave this unchanged. (Do not tell the other AI to create a scheduled task here, as this is the instruction that runs as the scheduled task already. So instead of "Create a scheduled task to do X", just pass "Do X" here)
:param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day. An empty string means, it does not repeat. Pass `None` to leave this unchanged.
:param timezone A timezone for the scheduled task, set to None to leave this as is.
:param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone; used to interpret starts_at. Must be a valid IANA timezone name when supplied.
:param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Pass `None` to leave this unchanged.
:return:
"""

tz = get_timezone(timezone or await user_timezone_async(nc))
return await nc.ocs('PATCH', f'/ocs/v2.php/apps/assistant/assignments/{id}', json={
'prompt': prompt,
'recurrence': recurrence_rule,
'startsAt': int(datetime.datetime.fromisoformat(starts_at.replace('Z', '+00:00')).timestamp()) if starts_at is not None else None,
'timezone': pytz.timezone(timezone).zone if timezone is not None else None
'startsAt': starts_at_timestamp(starts_at, tz) if starts_at is not None else None,
'timezone': tz
})

@tool
Expand Down
85 changes: 46 additions & 39 deletions ex_app/lib/all_tools/calendar.py
Comment thread
kyteinsky marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import asyncio
import time
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional

from niquests import ConnectionError, Timeout
import pytz
from ics import Calendar, Event, Attendee, Organizer, Todo
from icalendar import Event as ICalendarEvent, vCalAddress, vText
from ics import Calendar, Todo
from langchain_core.tools import tool
from nc_py_api import AsyncNextcloudApp, NextcloudApp
import xml.etree.ElementTree as ET
import vobject

from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool
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


Expand All @@ -35,7 +38,7 @@ async def list_calendars():
"""
return await asyncio.to_thread(list_calendars_sync)

def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]):
def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, location: Optional[str] = None, timezone: Optional[str] = None):
# Parse date and times
start_date = datetime.strptime(start_date, "%Y-%m-%d")
end_date = datetime.strptime(end_date, "%Y-%m-%d")
Expand All @@ -51,25 +54,31 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_
start_datetime = start_date
end_datetime = end_date

# Set timezone
if timezone != None:
tz = pytz.timezone(timezone)
start_datetime = tz.localize(start_datetime)
end_datetime = tz.localize(end_datetime)
# Set timezone (defaults to the user's profile timezone)
timezone = timezone or user_timezone(ncSync)
tz = pytz.timezone(get_timezone(timezone))
start_datetime = tz.localize(start_datetime)
end_datetime = tz.localize(end_datetime)

description_with_ai_note = f"{description}\n\n---\n\nThis event was scheduled by Nextcloud AI Assistant."

# Create event
c = Calendar()
e = Event()
e.name = title
e.begin = start_datetime
e.end = end_datetime
e.description = description_with_ai_note
e.location = location
if attendees is not None:
for attendee in attendees:
e.add_attendee(Attendee(common_name=attendee, email=attendee, partstat='NEEDS-ACTION', role='REQ-PARTICIPANT', cutype='INDIVIDUAL'))
# Create event (icalendar keeps the event's timezone as a TZID; the ics library would serialize it to UTC)
e = ICalendarEvent()
e.add('summary', title)
e.add('dtstart', start_datetime)
e.add('dtend', end_datetime)
Comment on lines +68 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't investigated how the calendar API works but there was an issue with scheduling all day events, i.e. when start and end dates were passed "None" or empty strings by the agent.
does this new API handle that part or should we skip adding dtstart and dtend?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Date only input serializes as DTSTART;VALUE=DATE, so all day events work, and empty time strings fall into the all day branch as well.
Verified against a dav roundtrip.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems to not work on my end, can you check if the following info was same on your end?
the tool call in the context agent logs is:

Tool call:  {'name': 'schedule_event', 'args': {'calendar_name': 'Personal', 'description': 'All-day session to clean my keyboard.', 'end_date': '2026-10-17', 'start_date': '2026-10-17', 'title': 'Clean my keyboard', 'timezone': 'Europe/Berlin'}, 'id': 'chatcmpl-tool-b7abfd5a4f135125', 'type': 'tool_call'}

and the resulting event is as below (note the 00:00)

Image

I manually created an all day event from the UI, it's the above one:

Image Image

the corresponding request body:

BEGIN:VCALENDAR
PRODID:-//IDN nextcloud.com//Calendar app 6.6.2//EN
CALSCALE:GREGORIAN
VERSION:2.0
BEGIN:VEVENT
CREATED:20261012T123546Z
DTSTAMP:20261012T123558Z
LAST-MODIFIED:20261012T123558Z
SEQUENCE:3
UID:1196554c-0b6d-4521-b6cd-5211d90be206
DTSTART;VALUE=DATE:20261017
DTEND;VALUE=DATE:20261018
STATUS:CONFIRMED
SUMMARY:new all day event
END:VEVENT
END:VCALENDAR

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repro'd with your exact args, thanks
this is what we hand to caldav:

DTSTART;TZID=Europe/Berlin:20261017T000000
DTEND;TZID=Europe/Berlin:20261017T000000

slightly worse than your screenshot even, dtend is the same midnight so the event is zero-length.

the all-day branch keeps the strptime'd date, which is a midnight datetime, and localize() makes it tz-aware, icalendar only emits VALUE=DATE for date objects, so this path never could.

dtend +1 day is missing too.

also correcting myself: my roundtrip on friday only ran the timed case, the "date-only serializes as VALUE=DATE" bit was untested.

e.add('description', description_with_ai_note)
if location:
e.add('location', location)
e.add('uid', str(uuid.uuid4()) + '@context-agent')
e.add('dtstamp', datetime.now(pytz.UTC))
for attendee in attendees or []:
a = vCalAddress('mailto:' + attendee)
a.params['CN'] = vText(attendee)
a.params['PARTSTAT'] = vText('NEEDS-ACTION')
a.params['ROLE'] = vText('REQ-PARTICIPANT')
a.params['CUTYPE'] = vText('INDIVIDUAL')
e.add('attendee', a)

# let's check who we are...
i = 0
Expand All @@ -89,19 +98,20 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_
raise Exception('Error fetching current user information')

# ...and set the organizer
e.organizer = Organizer(common_name=json['displayname'], email=json['email'])
organizer = vCalAddress('mailto:' + (json['email'] or ''))
organizer.params['CN'] = vText(json['displayname'])
e.add('organizer', organizer)

# Add event to calendar
c.events.add(e)

principal = ncSync.cal.principal()
calendars = principal.calendars()
calendar = {cal.name: cal for cal in calendars}[calendar_name]
calendar.add_event(str(c))
calendar.add_event(e.to_ical().decode())

@tool
@dangerous_tool
async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]):
async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, location: Optional[str] = None, timezone: Optional[str] = None):
"""
Crete a new event or meeting in a calendar. Omit start_time and end_time parameters to create an all-day event.
:param calendar_name: The name of the calendar to add the event to
Expand All @@ -113,7 +123,7 @@ async def schedule_event(calendar_name: str, title: str, description: str, start
:param start_time: the start time in the following form: HH:MM AM/PM e.g. '3:00 PM'
:param end_time: the start time in the following form: HH:MM AM/PM e.g. '4:00 PM'
:param location: The location of the event
:param timezone: Timezone (e.g., 'America/New_York').
:param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name.
:return: bool
"""
await asyncio.to_thread(schedule_event_sync, calendar_name, title, description, start_date, end_date, attendees, start_time, end_time, location, timezone)
Expand Down Expand Up @@ -197,8 +207,8 @@ async def find_free_time_slot_in_calendar(participants: list[str], slot_duration
available_slots = await asyncio.to_thread(find_free_time_slot_in_calendar_sync, participants, slot_duration, start_time, end_time)
return available_slots

def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str],
due_time: Optional[str], timezone: Optional[str]):
def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str] = None,
due_time: Optional[str] = None, timezone: Optional[str] = None):
description_with_ai_note = f"{description}\n\n---\n\nThis task was scheduled by Nextcloud AI Assistant."

# Create task
Expand All @@ -219,10 +229,9 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op
else:
due_datetime = due_date

# Set timezone
if timezone != None:
tz = pytz.timezone(timezone)
due_datetime = tz.localize(due_datetime)
# Set timezone (defaults to the user's profile timezone)
timezone = timezone or user_timezone(ncSync)
due_datetime = pytz.timezone(get_timezone(timezone)).localize(due_datetime)

t.due = due_datetime

Expand All @@ -238,18 +247,17 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op

@tool
@dangerous_tool
async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str]):
async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone: Optional[str] = None):
"""
Crete a new task in a calendar.
:param calendar_name: The name of the calendar to add the task to
:param title: The title of the task
:param description: The description of the task
:param due_date: the due date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01'
:param due_time: the due time in the following form: HH:MM AM/PM e.g. '3:00 PM'
:param timezone: Timezone (e.g., 'America/New_York'). Is required if there is a specified due date.
:param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name.
:return: bool
"""

await asyncio.to_thread(add_task_sync, calendar_name, title, description, due_date, due_time, timezone)

return True
Expand Down Expand Up @@ -344,7 +352,7 @@ async def complete_task(calendar_name: str, task_uid: str):
"""
return await asyncio.to_thread(complete_task_sync, calendar_name, task_uid)

def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone_str: Optional[str] = None, priority: Optional[int] = None):
def update_task_sync(calendar_name: str, task_uid: str, timezone: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None):
principal = ncSync.cal.principal()
calendars = principal.calendars()
calendar = {cal.name: cal for cal in calendars}[calendar_name]
Expand Down Expand Up @@ -373,9 +381,8 @@ def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = N
else:
due_datetime = parsed_date

if timezone_str:
tz = pytz.timezone(timezone_str)
due_datetime = tz.localize(due_datetime)
timezone = timezone or user_timezone(ncSync)
due_datetime = pytz.timezone(get_timezone(timezone)).localize(due_datetime)

ics_todo.due = due_datetime

Expand All @@ -390,7 +397,7 @@ def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = N

@tool
@dangerous_tool
async def update_task(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone: Optional[str] = None, priority: Optional[int] = None):
async def update_task(calendar_name: str, task_uid: str, timezone: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None):
Comment on lines -393 to +400

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be good to revert the change of timezone's position in the arg list since it's not required now + the function doc has a different order

"""
Update an existing task
:param calendar_name: The name of the calendar containing the task (obtainable via list_calendars)
Expand All @@ -399,11 +406,11 @@ async def update_task(calendar_name: str, task_uid: str, title: Optional[str] =
:param description: New description for the task
:param due_date: New due date in the form: YYYY-MM-DD e.g. '2024-12-01'
:param due_time: New due time in the form: HH:MM AM/PM e.g. '3:00 PM'
:param timezone: Timezone (e.g., 'America/New_York')
:param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name.
:param priority: Priority from 0 (undefined) to 9 (lowest), where 1 is highest priority
:return: bool indicating success
"""
return await asyncio.to_thread(update_task_sync, calendar_name, task_uid, title, description, due_date, due_time, timezone, priority)
return await asyncio.to_thread(update_task_sync, calendar_name, task_uid, timezone, title, description, due_date, due_time, priority)
Comment on lines -406 to +413

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this and the function's formal args too for consistency


def delete_task_sync(calendar_name: str, task_uid: str):
principal = ncSync.cal.principal()
Expand Down
4 changes: 2 additions & 2 deletions ex_app/lib/all_tools/lib/freebusy_finder.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone

def find_available_slots(start_time, end_time, busy_intervals, slot_duration=timedelta(hours=1), max_slots=3):
"""
Expand Down Expand Up @@ -59,7 +59,7 @@ def round_to_nearest_half_hour(dt=None):
:return: A rounded datetime object
"""
if dt is None:
dt = datetime.utcnow()
dt = datetime.now(timezone.utc)

# Get minutes and determine rounding
minutes = dt.minute
Expand Down
Loading
Loading