diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 8d95f5b27..c13250c58 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -105,6 +105,7 @@ from open_webui.routers import ( scim, terminals, automations, + calendar, ) from open_webui.routers.retrieval import ( @@ -1433,6 +1434,7 @@ if ENABLE_ADMIN_ANALYTICS: app.include_router(utils.router, prefix='/api/v1/utils', tags=['utils']) app.include_router(terminals.router, prefix='/api/v1/terminals', tags=['terminals']) app.include_router(automations.router, prefix='/api/v1/automations', tags=['automations']) +app.include_router(calendar.router, prefix='/api/v1/calendars', tags=['calendars']) # SCIM 2.0 API for identity management if ENABLE_SCIM: diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 9ee6c2dce..3840cb4a1 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -3,6 +3,7 @@ from logging.config import fileConfig from alembic import context from open_webui.models.auths import Auth +from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401 from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT from sqlalchemy import engine_from_config, pool, create_engine diff --git a/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py new file mode 100644 index 000000000..8277daa73 --- /dev/null +++ b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py @@ -0,0 +1,79 @@ +"""add calendar tables + +Revision ID: 56359461a091 +Revises: c1d2e3f4a5b6 +Create Date: 2026-04-19 16:20:58.162045 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '56359461a091' +down_revision: Union[str, None] = 'c1d2e3f4a5b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('calendar', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('color', sa.Text(), nullable=True), + sa.Column('is_system', sa.Boolean(), nullable=False), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False) + + op.create_table('calendar_event', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('calendar_id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('title', sa.Text(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('start_at', sa.BigInteger(), nullable=False), + sa.Column('end_at', sa.BigInteger(), nullable=True), + sa.Column('all_day', sa.Boolean(), nullable=False), + sa.Column('rrule', sa.Text(), nullable=True), + sa.Column('color', sa.Text(), nullable=True), + sa.Column('location', sa.Text(), nullable=True), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('is_cancelled', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False) + op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False) + + op.create_table('calendar_event_attendee', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('event_id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee') + ) + op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False) + + +def downgrade() -> None: + op.drop_index('ix_calendar_event_attendee_user', table_name='calendar_event_attendee') + op.drop_table('calendar_event_attendee') + op.drop_index('ix_calendar_event_user_date', table_name='calendar_event') + op.drop_index('ix_calendar_event_calendar', table_name='calendar_event') + op.drop_table('calendar_event') + op.drop_index('ix_calendar_user', table_name='calendar') + op.drop_table('calendar') diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 02a6f231d..c7c78a7c8 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -153,6 +153,18 @@ class AutomationTable: row = await db.get(Automation, id) return AutomationModel.model_validate(row) if row else None + async def get_active_by_user( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[AutomationModel]: + """Get active automations for a user (for calendar RRULE expansion).""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Automation) + .filter_by(user_id=user_id, is_active=True) + .order_by(Automation.created_at.desc()) + ) + return [AutomationModel.model_validate(r) for r in result.scalars().all()] + async def search_automations( self, user_id: str, @@ -383,6 +395,32 @@ class AutomationRunTable: await db.commit() return result.rowcount + async def get_runs_by_user_range( + self, + user_id: str, + start_ns: int, + end_ns: int, + limit: int = 500, + db: Optional[AsyncSession] = None, + ) -> list[tuple['AutomationRunModel', 'AutomationModel']]: + """Get runs within a date range for a user, joined with parent automation.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(AutomationRun, Automation) + .join(Automation, Automation.id == AutomationRun.automation_id) + .filter( + Automation.user_id == user_id, + AutomationRun.created_at >= start_ns, + AutomationRun.created_at < end_ns, + ) + .order_by(AutomationRun.created_at.desc()) + .limit(limit) + ) + return [ + (AutomationRunModel.model_validate(run), AutomationModel.model_validate(auto)) + for run, auto in result.all() + ] + Automations = AutomationTable() AutomationRuns = AutomationRunTable() diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py new file mode 100644 index 000000000..e055c87f9 --- /dev/null +++ b/backend/open_webui/models/calendar.py @@ -0,0 +1,820 @@ +import time +import logging +from typing import Optional +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import ( + Column, + Text, + JSON, + Boolean, + BigInteger, + Index, + UniqueConstraint, + select, + or_, + exists, + func, + delete, +) +from sqlalchemy.ext.asyncio import AsyncSession + +from open_webui.internal.db import Base, get_async_db_context +from open_webui.models.access_grants import AccessGrantModel, AccessGrants +from open_webui.models.groups import Groups +from open_webui.models.users import User, UserModel, UserResponse + +log = logging.getLogger(__name__) + + +#################### +# Calendar DB Schema +#################### + + +class Calendar(Base): + __tablename__ = 'calendar' + + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + name = Column(Text, nullable=False) + color = Column(Text, nullable=True) + is_system = Column(Boolean, nullable=False, default=False) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = (Index('ix_calendar_user', 'user_id'),) + + +class CalendarEvent(Base): + __tablename__ = 'calendar_event' + + id = Column(Text, primary_key=True) + calendar_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + title = Column(Text, nullable=False) + description = Column(Text, nullable=True) + start_at = Column(BigInteger, nullable=False) + end_at = Column(BigInteger, nullable=True) + all_day = Column(Boolean, nullable=False, default=False) + rrule = Column(Text, nullable=True) + color = Column(Text, nullable=True) + location = Column(Text, nullable=True) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + is_cancelled = Column(Boolean, nullable=False, default=False) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + Index('ix_calendar_event_calendar', 'calendar_id', 'start_at'), + Index('ix_calendar_event_user_date', 'user_id', 'start_at'), + ) + + +class CalendarEventAttendee(Base): + __tablename__ = 'calendar_event_attendee' + + id = Column(Text, primary_key=True) + event_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + status = Column(Text, nullable=False, default='pending') + meta = Column(JSON, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'), + Index('ix_calendar_event_attendee_user', 'user_id', 'status'), + ) + + +#################### +# Pydantic Models +#################### + + +class CalendarModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + name: str + color: Optional[str] = None + is_system: bool = False + data: Optional[dict] = None + meta: Optional[dict] = None + + access_grants: list[AccessGrantModel] = Field(default_factory=list) + + created_at: int + updated_at: int + + +class CalendarEventModel(BaseModel): + model_config = ConfigDict(from_attributes=True, extra='allow') + + id: str + calendar_id: str + user_id: str + title: str + description: Optional[str] = None + start_at: int + end_at: Optional[int] = None + all_day: bool = False + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + is_cancelled: bool = False + + attendees: list['CalendarEventAttendeeModel'] = Field(default_factory=list) + + created_at: int + updated_at: int + + +class CalendarEventAttendeeModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + event_id: str + user_id: str + status: str = 'pending' + meta: Optional[dict] = None + + created_at: int + updated_at: int + + +#################### +# Forms +#################### + + +class CalendarForm(BaseModel): + name: str + color: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + access_grants: Optional[list[dict]] = None + + +class CalendarUpdateForm(BaseModel): + name: Optional[str] = None + color: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + access_grants: Optional[list[dict]] = None + + +class CalendarEventForm(BaseModel): + calendar_id: str + title: str + description: Optional[str] = None + start_at: int + end_at: Optional[int] = None + all_day: bool = False + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + attendees: Optional[list[dict]] = None + + +class CalendarEventUpdateForm(BaseModel): + calendar_id: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + start_at: Optional[int] = None + end_at: Optional[int] = None + all_day: Optional[bool] = None + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + is_cancelled: Optional[bool] = None + attendees: Optional[list[dict]] = None + + +class RSVPForm(BaseModel): + status: str # 'accepted' | 'declined' | 'tentative' | 'pending' + + +#################### +# Response Models +#################### + + +class CalendarEventUserResponse(CalendarEventModel): + user: Optional[UserResponse] = None + + +class CalendarEventListResponse(BaseModel): + items: list[CalendarEventUserResponse] + total: int + + +#################### +# Table Operations +#################### + + +class CalendarTable: + async def _get_access_grants( + self, calendar_id: str, db: Optional[AsyncSession] = None + ) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('calendar', calendar_id, db=db) + + async def _to_calendar_model( + self, + cal: Calendar, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[AsyncSession] = None, + ) -> CalendarModel: + cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'}) + cal_data['access_grants'] = ( + access_grants + if access_grants is not None + else await self._get_access_grants(cal_data['id'], db=db) + ) + return CalendarModel.model_validate(cal_data) + + async def get_or_create_defaults( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarModel]: + """Return user's calendars, creating 'Personal' and 'Scheduled Tasks' if none exist.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Calendar).filter(Calendar.user_id == user_id).order_by(Calendar.created_at.asc()) + ) + calendars = result.scalars().all() + + if calendars: + return [CalendarModel.model_validate(c) for c in calendars] + + now = int(time.time_ns()) + defaults = [ + Calendar( + id=str(uuid4()), + user_id=user_id, + name='Personal', + color='#3b82f6', + is_system=True, + created_at=now, + updated_at=now, + ), + Calendar( + id=str(uuid4()), + user_id=user_id, + name='Scheduled Tasks', + color='#8b5cf6', + is_system=True, + created_at=now + 1, + updated_at=now + 1, + ), + ] + for cal in defaults: + db.add(cal) + await db.commit() + return [CalendarModel.model_validate(c) for c in defaults] + + async def get_calendars_by_user( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarModel]: + """Owned + shared calendars.""" + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + stmt = select(Calendar) + stmt = AccessGrants.has_permission_filter( + db=db, + query=stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + stmt = stmt.order_by(Calendar.created_at.asc()) + + result = await db.execute(stmt) + calendars = result.scalars().all() + + if not calendars: + return await self.get_or_create_defaults(user_id, db=db) + + cal_ids = [c.id for c in calendars] + grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db) + + return [ + await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) + for c in calendars + ] + + async def get_calendar_by_id( + self, id: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + return await self._to_calendar_model(cal, db=db) if cal else None + + async def get_scheduled_tasks_calendar( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + """Get the user's Scheduled Tasks calendar (for automation integration).""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Calendar).filter( + Calendar.user_id == user_id, + Calendar.name == 'Scheduled Tasks', + Calendar.is_system == True, + ) + ) + cal = result.scalars().first() + if not cal: + # Ensure defaults exist then retry + await self.get_or_create_defaults(user_id, db=db) + result = await db.execute( + select(Calendar).filter( + Calendar.user_id == user_id, + Calendar.name == 'Scheduled Tasks', + Calendar.is_system == True, + ) + ) + cal = result.scalars().first() + # Lightweight return — skip access_grants loading since we only need id/color + return CalendarModel.model_validate(cal) if cal else None + + async def insert_new_calendar( + self, user_id: str, form_data: CalendarForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + now = int(time.time_ns()) + cal = Calendar( + id=str(uuid4()), + user_id=user_id, + name=form_data.name, + color=form_data.color, + is_system=False, + data=form_data.data, + meta=form_data.meta, + created_at=now, + updated_at=now, + ) + db.add(cal) + await db.commit() + if form_data.access_grants is not None: + await AccessGrants.set_access_grants('calendar', cal.id, form_data.access_grants, db=db) + return await self._to_calendar_model(cal, db=db) + + async def update_calendar_by_id( + self, id: str, form_data: CalendarUpdateForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + if not cal: + return None + + update_data = form_data.model_dump(exclude_unset=True) + if 'name' in update_data: + cal.name = update_data['name'] + if 'color' in update_data: + cal.color = update_data['color'] + if 'data' in update_data: + cal.data = {**(cal.data or {}), **update_data['data']} + if 'meta' in update_data: + cal.meta = {**(cal.meta or {}), **update_data['meta']} + if 'access_grants' in update_data: + await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db) + + cal.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_calendar_model(cal, db=db) + + async def delete_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete a non-system calendar. Cascades to events, attendees, and grants.""" + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + if not cal or cal.is_system: + return False + + # Delete attendees for all events in this calendar + event_ids_result = await db.execute( + select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id) + ) + event_ids = [r[0] for r in event_ids_result.all()] + if event_ids: + await db.execute( + delete(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id.in_(event_ids) + ) + ) + + # Delete events + await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id)) + + # Delete access grants + await AccessGrants.revoke_all_access('calendar', id, db=db) + + # Delete calendar + await db.execute(delete(Calendar).filter(Calendar.id == id)) + await db.commit() + return True + except Exception: + return False + + +class CalendarEventTable: + async def _get_attendees( + self, event_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) + ) + rows = result.scalars().all() + return [CalendarEventAttendeeModel.model_validate(r) for r in rows] + + async def _to_event_model( + self, + event: CalendarEvent, + attendees: Optional[list[CalendarEventAttendeeModel]] = None, + db: Optional[AsyncSession] = None, + ) -> CalendarEventModel: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = ( + attendees if attendees is not None else await self._get_attendees(event_data['id'], db=db) + ) + return CalendarEventModel.model_validate(event_data) + + async def insert_new_event( + self, user_id: str, form_data: CalendarEventForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + now = int(time.time_ns()) + event = CalendarEvent( + id=str(uuid4()), + calendar_id=form_data.calendar_id, + user_id=user_id, + title=form_data.title, + description=form_data.description, + start_at=form_data.start_at, + end_at=form_data.end_at, + all_day=form_data.all_day, + rrule=form_data.rrule, + color=form_data.color, + location=form_data.location, + data=form_data.data, + meta=form_data.meta, + is_cancelled=False, + created_at=now, + updated_at=now, + ) + db.add(event) + await db.commit() + + # Add attendees + if form_data.attendees: + await CalendarEventAttendees.set_attendees(event.id, form_data.attendees, db=db) + + return await self._to_event_model(event, db=db) + + async def get_event_by_id( + self, id: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id)) + event = result.scalars().first() + return await self._to_event_model(event, db=db) if event else None + + async def get_events_by_range( + self, + user_id: str, + start: int, + end: int, + calendar_ids: Optional[list[str]] = None, + db: Optional[AsyncSession] = None, + ) -> list[CalendarEventUserResponse]: + """Fetch events visible to user within a date range. + + Visible events = events in owned/shared calendars + events user attends. + Recurring events are fetched if they have any rrule (expansion in Python). + """ + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + # Get calendar IDs accessible to user + cal_stmt = select(Calendar.id) + cal_stmt = AccessGrants.has_permission_filter( + db=db, + query=cal_stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + cal_result = await db.execute(cal_stmt) + accessible_cal_ids = [r[0] for r in cal_result.all()] + + if calendar_ids: + # Filter to requested calendars only + accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids] + + # Also get event IDs where user is an attendee + attendee_event_ids_result = await db.execute( + select(CalendarEventAttendee.event_id).filter( + CalendarEventAttendee.user_id == user_id + ) + ) + attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()] + + # Build conditions for accessible events + conditions = [] + if accessible_cal_ids: + conditions.append(CalendarEvent.calendar_id.in_(accessible_cal_ids)) + if attendee_event_ids: + conditions.append(CalendarEvent.id.in_(attendee_event_ids)) + + if not conditions: + return [] + + # Build event query + stmt = ( + select(CalendarEvent, User) + .outerjoin(User, User.id == CalendarEvent.user_id) + .filter( + CalendarEvent.is_cancelled == False, + or_(*conditions), + or_( + # Non-recurring: overlaps the range + ( + CalendarEvent.rrule.is_(None) + & (CalendarEvent.start_at < end) + & or_( + CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start), + CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start), + ) + ), + # Recurring: fetch all (expansion in Python) + CalendarEvent.rrule.isnot(None), + ), + ) + .order_by(CalendarEvent.start_at.asc()) + ) + + result = await db.execute(stmt) + items = result.all() + + if not items: + return [] + + # Batch-load attendees for all events in one query (avoid N+1) + event_ids = [event.id for event, _user in items] + att_result = await db.execute( + select(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id.in_(event_ids) + ) + ) + att_rows = att_result.scalars().all() + att_map: dict[str, list[CalendarEventAttendeeModel]] = {} + for a in att_rows: + att_map.setdefault(a.event_id, []).append( + CalendarEventAttendeeModel.model_validate(a) + ) + + events = [] + for event, user in items: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = att_map.get(event.id, []) + events.append( + CalendarEventUserResponse( + **event_data, + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), + ) + ) + + return events + + async def search_events( + self, + user_id: str, + query: Optional[str] = None, + skip: int = 0, + limit: int = 30, + db: Optional[AsyncSession] = None, + ) -> CalendarEventListResponse: + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + # Get accessible calendar IDs + cal_stmt = select(Calendar.id) + cal_stmt = AccessGrants.has_permission_filter( + db=db, + query=cal_stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + cal_result = await db.execute(cal_stmt) + accessible_cal_ids = [r[0] for r in cal_result.all()] + if not accessible_cal_ids: + return CalendarEventListResponse(items=[], total=0) + + stmt = ( + select(CalendarEvent, User) + .outerjoin(User, User.id == CalendarEvent.user_id) + .filter( + CalendarEvent.is_cancelled == False, + CalendarEvent.calendar_id.in_(accessible_cal_ids), + ) + ) + + if query: + search = f'%{query}%' + stmt = stmt.filter( + or_( + CalendarEvent.title.ilike(search), + CalendarEvent.description.ilike(search), + CalendarEvent.location.ilike(search), + ) + ) + + stmt = stmt.order_by(CalendarEvent.start_at.desc()) + + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await db.execute(stmt) + items = result.all() + + if not items: + return CalendarEventListResponse(items=[], total=total) + + # Batch-load attendees + event_ids = [event.id for event, _user in items] + att_result = await db.execute( + select(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id.in_(event_ids) + ) + ) + att_rows = att_result.scalars().all() + att_map: dict[str, list[CalendarEventAttendeeModel]] = {} + for a in att_rows: + att_map.setdefault(a.event_id, []).append( + CalendarEventAttendeeModel.model_validate(a) + ) + + events = [] + for event, user in items: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = att_map.get(event.id, []) + events.append( + CalendarEventUserResponse( + **event_data, + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), + ) + ) + + return CalendarEventListResponse(items=events, total=total) + + async def update_event_by_id( + self, id: str, form_data: CalendarEventUpdateForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id)) + event = result.scalars().first() + if not event: + return None + + update_data = form_data.model_dump(exclude_unset=True) + for field in [ + 'calendar_id', 'title', 'description', 'start_at', 'end_at', + 'all_day', 'rrule', 'color', 'location', 'is_cancelled', + ]: + if field in update_data: + setattr(event, field, update_data[field]) + + if 'data' in update_data and update_data['data'] is not None: + event.data = {**(event.data or {}), **update_data['data']} + if 'meta' in update_data and update_data['meta'] is not None: + event.meta = {**(event.meta or {}), **update_data['meta']} + + if 'attendees' in update_data and update_data['attendees'] is not None: + await CalendarEventAttendees.set_attendees(id, update_data['attendees'], db=db) + + event.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_event_model(event, db=db) + + + async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + try: + async with get_async_db_context(db) as db: + await db.execute( + delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id) + ) + await db.execute(delete(CalendarEvent).filter(CalendarEvent.id == id)) + await db.commit() + return True + except Exception: + return False + + +class CalendarEventAttendeeTable: + async def set_attendees( + self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + """Replace all attendees for an event. + + Each dict in attendees: {user_id: str, status?: str, meta?: dict} + """ + async with get_async_db_context(db) as db: + # Remove existing + await db.execute( + delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) + ) + + now = int(time.time_ns()) + models = [] + for att in attendees: + row = CalendarEventAttendee( + id=str(uuid4()), + event_id=event_id, + user_id=att['user_id'], + status=att.get('status', 'pending'), + meta=att.get('meta'), + created_at=now, + updated_at=now, + ) + db.add(row) + models.append(CalendarEventAttendeeModel.model_validate(row)) + + await db.commit() + return models + + async def update_rsvp( + self, event_id: str, user_id: str, status: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id == event_id, + CalendarEventAttendee.user_id == user_id, + ) + ) + att = result.scalars().first() + if not att: + return None + + att.status = status + att.updated_at = int(time.time_ns()) + await db.commit() + return CalendarEventAttendeeModel.model_validate(att) + + async def get_attendees_by_event( + self, event_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) + ) + return [CalendarEventAttendeeModel.model_validate(r) for r in result.scalars().all()] + + async def get_events_by_attendee( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[str]: + """Return event IDs where user is an attendee.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee.event_id).filter( + CalendarEventAttendee.user_id == user_id + ) + ) + return [r[0] for r in result.all()] + + +Calendars = CalendarTable() +CalendarEvents = CalendarEventTable() +CalendarEventAttendees = CalendarEventAttendeeTable() diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py new file mode 100644 index 000000000..4b052bd75 --- /dev/null +++ b/backend/open_webui/routers/calendar.py @@ -0,0 +1,319 @@ +import logging +import time +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from open_webui.models.calendar import ( + Calendars, + CalendarEvents, + CalendarEventAttendees, + CalendarForm, + CalendarUpdateForm, + CalendarEventForm, + CalendarEventUpdateForm, + CalendarModel, + CalendarEventModel, + CalendarEventUserResponse, + CalendarEventListResponse, + RSVPForm, +) +from open_webui.models.access_grants import AccessGrants +from open_webui.models.groups import Groups +from open_webui.models.users import UserModel +from open_webui.utils.auth import get_verified_user +from open_webui.utils.calendar import expand_recurring_event + +log = logging.getLogger(__name__) + +router = APIRouter() + + +async def _check_calendar_access( + calendar_id: str, user: UserModel, permission: str = 'write' +) -> CalendarModel: + """Verify user has access to a calendar. Returns the calendar or raises 403/404.""" + cal = await Calendars.get_calendar_by_id(calendar_id) + if not cal: + raise HTTPException(status_code=404, detail='Calendar not found') + if cal.user_id == user.id or user.role == 'admin': + return cal + user_groups = await Groups.get_groups_by_member_id(user.id) + user_group_ids = [g.id for g in user_groups] + if await AccessGrants.has_access( + user_id=user.id, + resource_type='calendar', + resource_id=cal.id, + permission=permission, + user_group_ids=user_group_ids, + ): + return cal + raise HTTPException(status_code=403, detail='Access denied') + + +#################### +# Calendar CRUD (static paths first) +#################### + + +@router.get('/', response_model=list[CalendarModel]) +async def get_calendars(user: UserModel = Depends(get_verified_user)): + """List user's calendars (owned + shared). Auto-creates defaults on first call.""" + return await Calendars.get_calendars_by_user(user.id) + + +@router.post('/create', response_model=CalendarModel) +async def create_calendar(form_data: CalendarForm, user: UserModel = Depends(get_verified_user)): + """Create a new user calendar.""" + return await Calendars.insert_new_calendar(user.id, form_data) + + +#################### +# Event CRUD (before /{calendar_id} to avoid route conflicts) +#################### + + +@router.get('/events') +async def get_events( + start: str, + end: str, + calendar_ids: Optional[str] = None, + user: UserModel = Depends(get_verified_user), +): + """Get events in date range. + + Args: + start: ISO 8601 datetime string (e.g. 2026-04-01T00:00:00) + end: ISO 8601 datetime string (e.g. 2026-05-01T00:00:00) + calendar_ids: optional comma-separated list to filter + + Includes: + - Stored events from the database + - Virtual events computed from active automation RRULEs (Scheduled Tasks calendar) + """ + from datetime import datetime + + try: + start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail='Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00)') + + NS = 1_000_000 + start_ns = int(start_dt.timestamp() * 1000) * NS + end_ns = int(end_dt.timestamp() * 1000) * NS + cal_id_list = calendar_ids.split(',') if calendar_ids else None + + # 1. Stored events + events = await CalendarEvents.get_events_by_range( + user_id=user.id, + start=start_ns, + end=end_ns, + calendar_ids=cal_id_list, + ) + + # Expand recurring stored events + expanded = [] + for event in events: + event_dict = event.model_dump() + if event_dict.get('rrule'): + instances = expand_recurring_event( + event_dict, start_ns, end_ns, tz=user.timezone + ) + for inst in instances: + expanded.append(CalendarEventUserResponse(**{**inst, 'user': event.user})) + else: + expanded.append(event) + + # 2. Virtual automation events (Scheduled Tasks calendar) + try: + from open_webui.models.automations import Automations, AutomationRuns + + scheduled_cal = await Calendars.get_scheduled_tasks_calendar(user.id) + if scheduled_cal and (cal_id_list is None or scheduled_cal.id in cal_id_list): + # Future runs: expand RRULEs for active automations only + active_automations = await Automations.get_active_by_user(user.id) + for auto in active_automations: + rrule_str = auto.data.get('rrule', '') if auto.data else '' + if not rrule_str: + continue + + virtual = { + 'id': f'auto_{auto.id}', + 'calendar_id': scheduled_cal.id, + 'user_id': user.id, + 'title': auto.name, + 'description': auto.data.get('prompt', '') if auto.data else '', + 'start_at': auto.next_run_at or 0, + 'end_at': None, + 'all_day': False, + 'rrule': rrule_str, + 'color': None, + 'location': None, + 'data': None, + 'meta': {'automation_id': auto.id}, + 'is_cancelled': False, + 'attendees': [], + 'created_at': auto.created_at, + 'updated_at': auto.updated_at, + 'user': None, + } + + # Only expand into the future — past runs are handled below + now_ns = int(time.time_ns()) + rrule_start = max(start_ns, now_ns) + instances = expand_recurring_event(virtual, rrule_start, end_ns, tz=user.timezone) + for inst in instances: + expanded.append(CalendarEventUserResponse(**inst)) + + # Past runs: single range query joined with automation + runs_with_auto = await AutomationRuns.get_runs_by_user_range( + user.id, start_ns, end_ns + ) + for run, auto in runs_with_auto: + expanded.append(CalendarEventUserResponse( + id=f'run_{run.id}', + calendar_id=scheduled_cal.id, + user_id=user.id, + title=auto.name, + description=run.error if run.status == 'error' else '', + start_at=run.created_at, + end_at=None, + all_day=False, + color=None, + location=None, + data=None, + meta={ + 'automation_id': auto.id, + 'run_id': run.id, + 'chat_id': run.chat_id, + 'status': run.status, + }, + is_cancelled=False, + attendees=[], + created_at=run.created_at, + updated_at=run.created_at, + user=None, + )) + except Exception as e: + log.warning(f'Failed to compute automation events: {e}', exc_info=True) + + return [e.model_dump() if hasattr(e, 'model_dump') else e for e in expanded] + + +@router.post('/events/create', response_model=CalendarEventModel) +async def create_event(form_data: CalendarEventForm, user: UserModel = Depends(get_verified_user)): + await _check_calendar_access(form_data.calendar_id, user, 'write') + return await CalendarEvents.insert_new_event(user.id, form_data) + + +@router.get('/events/search', response_model=CalendarEventListResponse) +async def search_events( + query: Optional[str] = None, + skip: int = 0, + limit: int = 30, + user: UserModel = Depends(get_verified_user), +): + return await CalendarEvents.search_events( + user_id=user.id, query=query, skip=skip, limit=limit + ) + + +@router.get('/events/{event_id}', response_model=CalendarEventModel) +async def get_event(event_id: str, user: UserModel = Depends(get_verified_user)): + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'read') + + return event + + +@router.post('/events/{event_id}/update', response_model=CalendarEventModel) +async def update_event( + event_id: str, form_data: CalendarEventUpdateForm, user: UserModel = Depends(get_verified_user) +): + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'write') + + updated = await CalendarEvents.update_event_by_id(event_id, form_data) + if not updated: + raise HTTPException(status_code=500, detail='Failed to update') + return updated + + +@router.delete('/events/{event_id}/delete') +async def delete_event(event_id: str, user: UserModel = Depends(get_verified_user)): + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'write') + + result = await CalendarEvents.delete_event_by_id(event_id) + if not result: + raise HTTPException(status_code=500, detail='Failed to delete') + return {'status': True} + + +@router.post('/events/{event_id}/rsvp', response_model=dict) +async def rsvp_event( + event_id: str, form_data: RSVPForm, user: UserModel = Depends(get_verified_user) +): + """Update own RSVP status for an event.""" + if form_data.status not in ('accepted', 'declined', 'tentative', 'pending'): + raise HTTPException(status_code=400, detail='Invalid status') + + result = await CalendarEventAttendees.update_rsvp(event_id, user.id, form_data.status) + if not result: + raise HTTPException(status_code=404, detail='Not an attendee of this event') + return {'status': True, 'rsvp': result.status} + + +#################### +# Calendar by ID (dynamic path — MUST come after /events* routes) +#################### + + +@router.get('/{calendar_id}', response_model=CalendarModel) +async def get_calendar_by_id(calendar_id: str, user: UserModel = Depends(get_verified_user)): + cal = await _check_calendar_access(calendar_id, user, 'read') + return cal + + +@router.post('/{calendar_id}/update', response_model=CalendarModel) +async def update_calendar( + calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user) +): + cal = await _check_calendar_access(calendar_id, user, 'write') + + # Only owner/admin can change access grants + if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=403, detail='Only owner can manage sharing') + + updated = await Calendars.update_calendar_by_id(calendar_id, form_data) + if not updated: + raise HTTPException(status_code=500, detail='Failed to update') + return updated + + +@router.delete('/{calendar_id}/delete') +async def delete_calendar(calendar_id: str, user: UserModel = Depends(get_verified_user)): + cal = await _check_calendar_access(calendar_id, user, 'write') + + # Only owner/admin can delete + if cal.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=403, detail='Only owner can delete calendar') + + if cal.is_system: + raise HTTPException(status_code=400, detail='Cannot delete system calendar') + + result = await Calendars.delete_calendar_by_id(calendar_id) + if not result: + raise HTTPException(status_code=500, detail='Failed to delete') + return {'status': True} diff --git a/backend/open_webui/utils/calendar.py b/backend/open_webui/utils/calendar.py new file mode 100644 index 000000000..9484c58dc --- /dev/null +++ b/backend/open_webui/utils/calendar.py @@ -0,0 +1,83 @@ +""" +Calendar utilities. + +RRULE expansion reusing the automation infra. +""" + +import logging +from datetime import datetime, timedelta +from typing import Optional +from zoneinfo import ZoneInfo + +from open_webui.utils.automations import _parse_rule + +log = logging.getLogger(__name__) + + +def expand_recurring_event( + event_dict: dict, + range_start_ns: int, + range_end_ns: int, + tz: Optional[str] = None, + max_instances: int = 5000, +) -> list[dict]: + """Expand a recurring event into individual instances within a date range. + + Takes an event dict (from CalendarEventModel.model_dump()) and produces + one dict per occurrence, with adjusted start_at / end_at. + """ + from dateutil.rrule import rrulestr + + rrule_str = event_dict.get('rrule') + if not rrule_str: + return [event_dict] + + range_start_dt = datetime.fromtimestamp(range_start_ns / 1_000_000_000) + range_end_dt = datetime.fromtimestamp(range_end_ns / 1_000_000_000) + scan_start = range_start_dt - timedelta(days=1) + + try: + # Parse with dtstart near the range so we never iterate from epoch + rule = rrulestr(rrule_str, dtstart=scan_start, ignoretz=True) + except Exception: + log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}') + return [event_dict] + + original_start_ns = event_dict['start_at'] + original_end_ns = event_dict.get('end_at') + duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None + + instances = [] + dt = rule.after(scan_start, inc=True) + + while dt and dt < range_end_dt and len(instances) < max_instances: + if tz: + try: + dt_tz = dt.replace(tzinfo=ZoneInfo(tz)) + instance_start_ns = int(dt_tz.timestamp() * 1_000_000_000) + except Exception: + instance_start_ns = int(dt.timestamp() * 1_000_000_000) + else: + instance_start_ns = int(dt.timestamp() * 1_000_000_000) + + if instance_start_ns >= range_start_ns: + instance = { + **event_dict, + 'start_at': instance_start_ns, + 'end_at': (instance_start_ns + duration_ns) if duration_ns else None, + 'instance_id': f'{event_dict["id"]}_{instance_start_ns}', + } + instances.append(instance) + + dt = rule.after(dt) + + return instances + + +def ns_from_date(year: int, month: int, day: int, tz: Optional[str] = None) -> int: + """Create epoch nanoseconds from a date.""" + if tz: + dt = datetime(year, month, day, tzinfo=ZoneInfo(tz)) + else: + dt = datetime(year, month, day) + return int(dt.timestamp() * 1_000_000_000) diff --git a/src/lib/apis/calendar/index.ts b/src/lib/apis/calendar/index.ts new file mode 100644 index 000000000..39540bb89 --- /dev/null +++ b/src/lib/apis/calendar/index.ts @@ -0,0 +1,427 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type CalendarModel = { + id: string; + user_id: string; + name: string; + color: string | null; + is_system: boolean; + data: Record | null; + meta: Record | null; + access_grants: any[]; + created_at: number; + updated_at: number; +}; + +export type CalendarEventAttendeeModel = { + id: string; + event_id: string; + user_id: string; + status: string; + meta: Record | null; + created_at: number; + updated_at: number; +}; + +export type CalendarEventModel = { + id: string; + calendar_id: string; + user_id: string; + title: string; + description: string | null; + start_at: number; + end_at: number | null; + all_day: boolean; + rrule: string | null; + color: string | null; + location: string | null; + data: Record | null; + meta: Record | null; + is_cancelled: boolean; + attendees: CalendarEventAttendeeModel[]; + created_at: number; + updated_at: number; + // Set by expand_recurring_event for recurring instances + instance_id?: string; +}; + +export type CalendarEventForm = { + calendar_id: string; + title: string; + description?: string; + start_at: number; + end_at?: number; + all_day?: boolean; + rrule?: string; + color?: string; + location?: string; + data?: Record; + meta?: Record; + attendees?: { user_id: string; status?: string }[]; +}; + +export type CalendarForm = { + name: string; + color?: string; + data?: Record; + meta?: Record; + access_grants?: { target_type: string; target_id: string; permission: string }[]; +}; + +// ── Calendars ───────────────────────────────── + +export const getCalendars = async (token: string): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createCalendar = async (token: string, form: CalendarForm): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateCalendar = async ( + token: string, + calendarId: string, + form: Partial +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteCalendar = async (token: string, calendarId: string): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/delete`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res?.status ?? false; +}; + +// ── Events ───────────────────────────────── + +export const getCalendarEvents = async ( + token: string, + start: string, + end: string, + calendarIds?: string[] +): Promise => { + let error = null; + + const params = new URLSearchParams(); + params.append('start', start); + params.append('end', end); + if (calendarIds && calendarIds.length > 0) { + params.append('calendar_ids', calendarIds.join(',')); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events?${params.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createCalendarEvent = async ( + token: string, + form: CalendarEventForm +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getCalendarEventById = async ( + token: string, + eventId: string +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateCalendarEvent = async ( + token: string, + eventId: string, + form: Partial +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteCalendarEvent = async (token: string, eventId: string): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/delete`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res?.status ?? false; +}; + +export const rsvpCalendarEvent = async ( + token: string, + eventId: string, + status: string +): Promise<{ status: boolean; rsvp: string }> => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/rsvp`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ status }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + + +export const searchCalendarEvents = async ( + token: string, + query: string | null, + skip: number = 0, + limit: number = 30 +): Promise<{ items: CalendarEventModel[]; total: number }> => { + let error = null; + + const params = new URLSearchParams(); + if (query) params.append('query', query); + params.append('skip', skip.toString()); + params.append('limit', limit.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/search?${params.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/calendar/CalendarEventChip.svelte b/src/lib/components/calendar/CalendarEventChip.svelte new file mode 100644 index 000000000..c63eac106 --- /dev/null +++ b/src/lib/components/calendar/CalendarEventChip.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte new file mode 100644 index 000000000..6e334e9cc --- /dev/null +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -0,0 +1,260 @@ + + + +
+ +
+ + +
+ + +
+ +
+
{$i18n.t('Calendar')}
+ +
+ + +
+
{$i18n.t('When')}
+
+ + {#if !allDay} + + – + + {/if} + +
+
+ + +
+
{$i18n.t('Location')}
+ +
+ + +
+
{$i18n.t('Description')}
+ +
+
+ + +
+
+ {#if event && !event.meta?.automation_id} + + {/if} +
+ +
+ + +
+
+
+
diff --git a/src/lib/components/calendar/CalendarSidebar.svelte b/src/lib/components/calendar/CalendarSidebar.svelte new file mode 100644 index 000000000..09e604a28 --- /dev/null +++ b/src/lib/components/calendar/CalendarSidebar.svelte @@ -0,0 +1,174 @@ + + +
+ +
+
+
{miniMonthNames[miniMonth]} {miniYear}
+
+ + +
+
+ +
+ {#each ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as d} +
{d}
+ {/each} +
+ +
+ {#each miniDays as day} + + {/each} +
+
+ + +
+
+
+ {$i18n.t('Calendars')} +
+
+ + {#each calendars as cal (cal.id)} + + {/each} +
+
diff --git a/src/lib/components/calendar/CalendarView.svelte b/src/lib/components/calendar/CalendarView.svelte new file mode 100644 index 000000000..0b67d7de9 --- /dev/null +++ b/src/lib/components/calendar/CalendarView.svelte @@ -0,0 +1,380 @@ + + +
+ + + + + {#if view === 'month'} +
+
+ {#each DAY_NAMES as day} +
{$i18n.t(day)}
+ {/each} +
+ +
+ {#each monthDays as day, i} + {@const dayKey = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime().toString()} + {@const dayEvents = eventsByDay[dayKey] || []} + {@const col = i % 7} + {@const row = Math.floor(i / 7)} + + {/each} +
+
+ + + {:else if view === 'week'} +
+
+
+
+
+
+ {#each weekDays as day} +
+
{DAY_NAMES[day.getDay()]}
+
+ {day.getDate()} +
+
+ {/each} +
+ +
+ {#each hours as hour} +
+
{hour > 0 ? formatHour(hour) : ''}
+ {#each weekDays as day} + {@const hourEvents = getEventsForHour(day, hour, filteredEvents)} + + {/each} +
+ {/each} +
+
+
+
+
+ + + {:else} +
+
+ {#each hours as hour} + {@const hourEvents = getEventsForHour(currentDate, hour, filteredEvents)} +
+
{hour > 0 ? formatHour(hour) : ''}
+ +
+ {/each} +
+
+ {/if} +
diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index 1a1681a84..30c29962b 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -250,6 +250,38 @@ {/if} + {#if $user?.role === 'admin' || $user?.permissions?.features?.calendar} + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; + e.preventDefault(); + show = false; + goto('/calendar'); + }} + > +
+ + + +
+
{$i18n.t('Calendar')}
+
+ {/if} + {#if role === 'admin'} + import { onMount, getContext, tick } from 'svelte'; + import { toast } from 'svelte-sonner'; + import { goto } from '$app/navigation'; + import { WEBUI_NAME, mobile, showSidebar, user } from '$lib/stores'; + import { + getCalendars, + getCalendarEvents, + type CalendarModel, + type CalendarEventModel + } from '$lib/apis/calendar'; + import CalendarView from '$lib/components/calendar/CalendarView.svelte'; + import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte'; + import CalendarEventModal from '$lib/components/calendar/CalendarEventModal.svelte'; + import Spinner from '$lib/components/common/Spinner.svelte'; + import Plus from '$lib/components/icons/Plus.svelte'; + + const i18n = getContext('i18n'); + + let loaded = false; + let calendars: CalendarModel[] = []; + let events: CalendarEventModel[] = []; + let visibleCalendarIds: Set = new Set(); + + let view: 'month' | 'week' | 'day' = 'month'; + let currentDate = new Date(); + + let showEventModal = false; + let editEvent: CalendarEventModel | null = null; + let defaultStartAt: number | null = null; + + function getVisibleRange(): { start: string; end: string } { + const d = new Date(currentDate); + let start: Date; + let end: Date; + + if (view === 'month') { + start = new Date(d.getFullYear(), d.getMonth(), 1); + start.setDate(start.getDate() - start.getDay()); + end = new Date(start); + end.setDate(end.getDate() + 42); + } else if (view === 'week') { + start = new Date(d); + start.setDate(start.getDate() - start.getDay()); + start.setHours(0, 0, 0, 0); + end = new Date(start); + end.setDate(end.getDate() + 7); + } else { + start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + end = new Date(start); + end.setDate(end.getDate() + 1); + } + + return { + start: start.toISOString(), + end: end.toISOString() + }; + } + + async function loadCalendars() { + try { + calendars = (await getCalendars(localStorage.token)) ?? []; + visibleCalendarIds = new Set(calendars.map((c) => c.id)); + } catch (err) { + console.error('loadCalendars', err); + calendars = []; + } + } + + async function loadEvents() { + try { + const { start, end } = getVisibleRange(); + events = await getCalendarEvents(localStorage.token, start, end); + } catch (err) { + toast.error(`${err}`); + } + } + + async function refresh() { + await loadEvents(); + } + + function toggleCalendar(id: string) { + const next = new Set(visibleCalendarIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + visibleCalendarIds = next; + } + + function handleCreateEvent(e: CustomEvent<{ start_at: number }>) { + editEvent = null; + defaultStartAt = e.detail.start_at; + showEventModal = true; + } + + function handleEventClick(e: CustomEvent) { + const evt = e.detail; + if (evt.meta?.automation_id) { + if (evt.meta?.chat_id) { + goto(`/c/${evt.meta.chat_id}`); + } else { + goto(`/automations/${evt.meta.automation_id}`); + } + return; + } + editEvent = evt; + defaultStartAt = null; + showEventModal = true; + } + + async function handleNavigate() { + await tick(); + refresh(); + } + + async function handleDateSelect(date: Date) { + currentDate = date; + await tick(); + refresh(); + } + + function handleNewEvent() { + editEvent = null; + defaultStartAt = null; + showEventModal = true; + } + + $: defaultCalendarId = calendars.find((c) => !c.is_system || c.name === 'Personal')?.id || calendars[0]?.id || ''; + + onMount(async () => { + await loadCalendars(); + await refresh(); + loaded = true; + }); + + + + {$i18n.t('Calendar')} • {$WEBUI_NAME} + + + refresh()} + on:delete={() => refresh()} +/> + +
+ {#if loaded} +
+ + + + +
+ +
+
+ {:else} +
+ +
+ {/if} +