mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-25 06:54:49 -06:00
refac
This commit is contained in:
@@ -2810,3 +2810,423 @@ async def delete_automation(
|
||||
except Exception as e:
|
||||
log.exception(f'delete_automation error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CALENDAR TOOLS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_user_tz(user_dict: dict):
|
||||
"""Get the user's timezone as a ZoneInfo, falling back to UTC."""
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
tz_name = None
|
||||
if user_dict:
|
||||
tz_name = user_dict.get('timezone')
|
||||
if tz_name:
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
pass
|
||||
return ZoneInfo('UTC')
|
||||
|
||||
|
||||
def _dt_to_ns(dt_str: str, tz) -> int:
|
||||
"""Convert a datetime string to nanoseconds since epoch, interpreting in the given timezone."""
|
||||
from datetime import datetime
|
||||
|
||||
dt = datetime.fromisoformat(dt_str)
|
||||
# If naive (no timezone info), localize to user's timezone
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=tz)
|
||||
return int(dt.timestamp() * 1_000) * 1_000_000
|
||||
|
||||
|
||||
def _ns_to_dt(ns: int, tz) -> str:
|
||||
"""Convert nanoseconds since epoch to a datetime string in the given timezone."""
|
||||
from datetime import datetime
|
||||
|
||||
seconds = ns / 1_000_000_000
|
||||
dt = datetime.fromtimestamp(seconds, tz=tz)
|
||||
return dt.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
|
||||
def _event_to_dict(event, tz) -> dict:
|
||||
"""Convert a calendar event model to a human-friendly dict with local timestamps."""
|
||||
return {
|
||||
'id': event.id,
|
||||
'calendar_id': event.calendar_id,
|
||||
'title': event.title,
|
||||
'description': event.description or '',
|
||||
'start': _ns_to_dt(event.start_at, tz),
|
||||
'end': _ns_to_dt(event.end_at, tz) if event.end_at else None,
|
||||
'all_day': event.all_day,
|
||||
'location': event.location or '',
|
||||
'color': event.color,
|
||||
'is_cancelled': event.is_cancelled,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def search_calendar_events(
|
||||
query: Optional[str] = None,
|
||||
start: Optional[str] = None,
|
||||
end: Optional[str] = None,
|
||||
count: int = 10,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Search calendar events by text and/or date range.
|
||||
Returns matching events across all accessible calendars.
|
||||
|
||||
:param query: Search text to match against event title, description, or location (optional)
|
||||
:param start: Only return events starting at or after this datetime, e.g. "2026-04-20 00:00" (optional)
|
||||
:param end: Only return events starting before this datetime, e.g. "2026-04-27 00:00" (optional)
|
||||
:param count: Maximum number of events to return (default: 10)
|
||||
:return: JSON list of matching events with id, title, description, start, end, calendar_id, location
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
try:
|
||||
from open_webui.models.calendar import CalendarEvents
|
||||
|
||||
user_id = __user__.get('id')
|
||||
tz = _get_user_tz(__user__)
|
||||
|
||||
if isinstance(count, str):
|
||||
try:
|
||||
count = int(count)
|
||||
except ValueError:
|
||||
count = 10
|
||||
|
||||
if start or end:
|
||||
# Date range query — use get_events_by_range
|
||||
try:
|
||||
start_ns = _dt_to_ns(start, tz) if start else 0
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({'error': f'Invalid start datetime: {e}'})
|
||||
|
||||
try:
|
||||
end_ns = _dt_to_ns(end, tz) if end else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({'error': f'Invalid end datetime: {e}'})
|
||||
|
||||
items = await CalendarEvents.get_events_by_range(
|
||||
user_id=user_id,
|
||||
start=start_ns,
|
||||
end=end_ns,
|
||||
)
|
||||
|
||||
# Apply text filter if query is also provided
|
||||
if query:
|
||||
q = query.lower()
|
||||
items = [
|
||||
e for e in items
|
||||
if q in (e.title or '').lower()
|
||||
or q in (e.description or '').lower()
|
||||
or q in (e.location or '').lower()
|
||||
]
|
||||
|
||||
events = [_event_to_dict(item, tz) for item in items[:count]]
|
||||
return json.dumps(
|
||||
{'events': events, 'total': len(items)},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
else:
|
||||
# Text-only search
|
||||
result = await CalendarEvents.search_events(
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
skip=0,
|
||||
limit=count,
|
||||
)
|
||||
|
||||
events = [_event_to_dict(item, tz) for item in result.items]
|
||||
return json.dumps(
|
||||
{'events': events, 'total': result.total},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f'search_calendar_events error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
async def create_calendar_event(
|
||||
title: str,
|
||||
start: str,
|
||||
end: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
calendar_id: Optional[str] = None,
|
||||
all_day: bool = False,
|
||||
location: Optional[str] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a new calendar event. If no calendar_id is provided, the event is
|
||||
added to the user's default calendar.
|
||||
|
||||
:param title: Event title
|
||||
:param start: Start datetime string in your local time (e.g. "2026-04-20 09:00" or "2026-04-20T09:00:00")
|
||||
:param end: End datetime string in your local time (optional, omit for point-in-time events)
|
||||
:param description: Event description (optional)
|
||||
:param calendar_id: Target calendar ID (optional, uses default calendar if omitted)
|
||||
:param all_day: Whether this is an all-day event (default: false)
|
||||
:param location: Event location (optional)
|
||||
:return: JSON with the created event details including id
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
try:
|
||||
from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventForm
|
||||
|
||||
user_id = __user__.get('id')
|
||||
|
||||
# Resolve calendar_id: use provided, or fall back to default
|
||||
if not calendar_id:
|
||||
calendars = await Calendars.get_calendars_by_user(user_id)
|
||||
default_cal = next((c for c in calendars if c.is_default), None)
|
||||
if not default_cal and calendars:
|
||||
default_cal = calendars[0]
|
||||
if not default_cal:
|
||||
return json.dumps({'error': 'No calendars found. Cannot create event.'})
|
||||
calendar_id = default_cal.id
|
||||
|
||||
# Verify access
|
||||
cal = await Calendars.get_calendar_by_id(calendar_id)
|
||||
if not cal:
|
||||
return json.dumps({'error': 'Calendar not found'})
|
||||
if cal.user_id != user_id and __user__.get('role') != 'admin':
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='calendar',
|
||||
resource_id=cal.id,
|
||||
permission='write',
|
||||
user_group_ids=set(user_group_ids),
|
||||
):
|
||||
return json.dumps({'error': 'Access denied to this calendar'})
|
||||
|
||||
# Coerce boolean from LLM
|
||||
if isinstance(all_day, str):
|
||||
all_day = all_day.lower() in ('true', '1', 'yes')
|
||||
|
||||
# Convert datetime strings to nanoseconds using user's timezone
|
||||
tz = _get_user_tz(__user__)
|
||||
try:
|
||||
start_ns = _dt_to_ns(start, tz)
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({'error': f'Invalid start datetime: {e}. Use format like "2026-04-20 09:00"'})
|
||||
|
||||
end_ns = None
|
||||
if end:
|
||||
try:
|
||||
end_ns = _dt_to_ns(end, tz)
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({'error': f'Invalid end datetime: {e}. Use format like "2026-04-20 10:00"'})
|
||||
elif not all_day:
|
||||
# Default to 1 hour duration
|
||||
end_ns = start_ns + 3_600_000_000_000
|
||||
|
||||
form = CalendarEventForm(
|
||||
calendar_id=calendar_id,
|
||||
title=title,
|
||||
description=description,
|
||||
start_at=start_ns,
|
||||
end_at=end_ns,
|
||||
all_day=all_day,
|
||||
location=location,
|
||||
)
|
||||
|
||||
event = await CalendarEvents.insert_new_event(user_id, form)
|
||||
if not event:
|
||||
return json.dumps({'error': 'Failed to create event'})
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'status': 'success',
|
||||
**_event_to_dict(event, tz),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f'create_calendar_event error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
async def update_calendar_event(
|
||||
event_id: str,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
start: Optional[str] = None,
|
||||
end: Optional[str] = None,
|
||||
all_day: Optional[bool] = None,
|
||||
location: Optional[str] = None,
|
||||
is_cancelled: Optional[bool] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Update an existing calendar event. Only provided fields are changed;
|
||||
omitted fields stay the same.
|
||||
|
||||
:param event_id: The ID of the event to update
|
||||
:param title: New event title (optional)
|
||||
:param description: New event description (optional)
|
||||
:param start: New start datetime string in your local time, e.g. "2026-04-20 09:00" (optional)
|
||||
:param end: New end datetime string in your local time (optional)
|
||||
:param all_day: Whether this is an all-day event (optional)
|
||||
:param location: New event location (optional)
|
||||
:param is_cancelled: Set to true to cancel the event (optional)
|
||||
:return: JSON with the updated event details
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
try:
|
||||
from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventUpdateForm
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_id = __user__.get('id')
|
||||
|
||||
event = await CalendarEvents.get_event_by_id(event_id)
|
||||
if not event:
|
||||
return json.dumps({'error': 'Event not found'})
|
||||
|
||||
# Check write access to the event's calendar
|
||||
cal = await Calendars.get_calendar_by_id(event.calendar_id)
|
||||
if cal and cal.user_id != user_id and __user__.get('role') != 'admin':
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='calendar',
|
||||
resource_id=cal.id,
|
||||
permission='write',
|
||||
user_group_ids=set(user_group_ids),
|
||||
):
|
||||
return json.dumps({'error': 'Access denied'})
|
||||
|
||||
# Coerce boolean strings from LLM
|
||||
if isinstance(all_day, str):
|
||||
all_day = all_day.lower() in ('true', '1', 'yes')
|
||||
if isinstance(is_cancelled, str):
|
||||
is_cancelled = is_cancelled.lower() in ('true', '1', 'yes')
|
||||
|
||||
# Convert datetime strings to nanoseconds using user's timezone
|
||||
tz = _get_user_tz(__user__)
|
||||
start_ns = None
|
||||
if start is not None:
|
||||
try:
|
||||
start_ns = _dt_to_ns(start, tz)
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({'error': f'Invalid start datetime: {e}'})
|
||||
|
||||
end_ns = None
|
||||
if end is not None:
|
||||
try:
|
||||
end_ns = _dt_to_ns(end, tz)
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({'error': f'Invalid end datetime: {e}'})
|
||||
|
||||
form = CalendarEventUpdateForm(
|
||||
title=title,
|
||||
description=description,
|
||||
start_at=start_ns,
|
||||
end_at=end_ns,
|
||||
all_day=all_day,
|
||||
location=location,
|
||||
is_cancelled=is_cancelled,
|
||||
)
|
||||
|
||||
updated = await CalendarEvents.update_event_by_id(event_id, form)
|
||||
if not updated:
|
||||
return json.dumps({'error': 'Failed to update event'})
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'status': 'success',
|
||||
**_event_to_dict(updated, tz),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f'update_calendar_event error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
async def delete_calendar_event(
|
||||
event_id: str,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Delete a calendar event permanently.
|
||||
|
||||
:param event_id: The ID of the event to delete
|
||||
:return: JSON confirming the event was deleted
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
try:
|
||||
from open_webui.models.calendar import Calendars, CalendarEvents
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_id = __user__.get('id')
|
||||
|
||||
event = await CalendarEvents.get_event_by_id(event_id)
|
||||
if not event:
|
||||
return json.dumps({'error': 'Event not found'})
|
||||
|
||||
# Check write access
|
||||
cal = await Calendars.get_calendar_by_id(event.calendar_id)
|
||||
if cal and cal.user_id != user_id and __user__.get('role') != 'admin':
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='calendar',
|
||||
resource_id=cal.id,
|
||||
permission='write',
|
||||
user_group_ids=set(user_group_ids),
|
||||
):
|
||||
return json.dumps({'error': 'Access denied'})
|
||||
|
||||
title = event.title
|
||||
result = await CalendarEvents.delete_event_by_id(event_id)
|
||||
if not result:
|
||||
return json.dumps({'error': 'Failed to delete event'})
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'status': 'success',
|
||||
'message': f'Event "{title}" deleted',
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f'delete_calendar_event error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ from open_webui.tools.builtin import (
|
||||
list_automations,
|
||||
toggle_automation,
|
||||
delete_automation,
|
||||
search_calendar_events,
|
||||
create_calendar_event,
|
||||
update_calendar_event,
|
||||
delete_calendar_event,
|
||||
)
|
||||
|
||||
import copy
|
||||
@@ -552,6 +556,12 @@ async def get_builtin_tools(
|
||||
[create_automation, update_automation, list_automations, toggle_automation, delete_automation]
|
||||
)
|
||||
|
||||
# Calendar tools - search/create/update/delete events
|
||||
if is_builtin_tool_enabled('calendar'):
|
||||
builtin_functions.extend(
|
||||
[search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event]
|
||||
)
|
||||
|
||||
for func in builtin_functions:
|
||||
callable = await get_async_tool_function_and_apply_extra_params(
|
||||
func,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
||||
@@ -28,6 +29,7 @@
|
||||
let allDay = false;
|
||||
let location = '';
|
||||
let loading = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
|
||||
const NS = 1_000_000;
|
||||
|
||||
@@ -223,9 +225,9 @@
|
||||
<div class="flex items-center gap-0.5 flex-1 min-w-0">
|
||||
{#if event && !event.meta?.automation_id}
|
||||
<button
|
||||
class="px-3 py-1 text-xs text-gray-400 hover:text-red-500 transition"
|
||||
class="px-3 py-1 text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
|
||||
type="button"
|
||||
on:click={deleteHandler}
|
||||
on:click={() => (showDeleteConfirmDialog = true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
@@ -258,3 +260,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
title={$i18n.t('Delete Event')}
|
||||
message={$i18n.t('This action cannot be undone. Do you wish to continue?')}
|
||||
on:confirm={deleteHandler}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
automations: {
|
||||
label: $i18n.t('Automations'),
|
||||
description: $i18n.t('Create and manage scheduled automations')
|
||||
},
|
||||
calendar: {
|
||||
label: $i18n.t('Calendar'),
|
||||
description: $i18n.t('List calendars, search, create, update, and delete calendar events')
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user