394 lines
14 KiB
Python
394 lines
14 KiB
Python
"""CalendarService — business-logic layer for calendar operations.
|
|
|
|
REST routes (U2) and agent tools are thin wrappers over this service.
|
|
The service dispatches to ``db`` functions for persistence and to
|
|
``recurrence`` for RRULE expansion.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import aiosqlite
|
|
|
|
from agentkit.calendar.db import (
|
|
DEFAULT_CALENDAR_DB_PATH,
|
|
add_tag_to_event,
|
|
delete_event as db_delete_event,
|
|
get_event as db_get_event,
|
|
get_invitation as db_get_invitation,
|
|
insert_event,
|
|
insert_event_type,
|
|
insert_invitation,
|
|
insert_reminder_rule,
|
|
insert_tag,
|
|
list_event_types as db_list_event_types,
|
|
list_events as db_list_events,
|
|
list_invitations as db_list_invitations,
|
|
list_reminder_rules_for_type,
|
|
list_tags as db_list_tags,
|
|
update_event as db_update_event,
|
|
update_event_type as db_update_event_type,
|
|
update_invitation_status,
|
|
)
|
|
from agentkit.calendar.models import (
|
|
CalendarEvent,
|
|
EventType,
|
|
Invitation,
|
|
Tag,
|
|
_now_iso,
|
|
)
|
|
from agentkit.calendar.recurrence import expand_rrule
|
|
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _validate_iso(dt_str: str) -> None:
|
|
"""Validate that *dt_str* is a parseable ISO 8601 string."""
|
|
if not dt_str:
|
|
raise ValueError("start_time must be a valid ISO 8601 string")
|
|
try:
|
|
datetime.fromisoformat(dt_str)
|
|
except ValueError:
|
|
raise ValueError(f"Invalid ISO 8601 format: {dt_str}")
|
|
|
|
|
|
def _parse_dt(dt_str: str) -> datetime:
|
|
"""Parse ISO 8601 string to timezone-aware datetime (UTC)."""
|
|
dt = datetime.fromisoformat(dt_str)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt
|
|
|
|
|
|
def _format_dt(dt: datetime) -> str:
|
|
"""Format datetime as ISO 8601 UTC string."""
|
|
return dt.astimezone(timezone.utc).isoformat()
|
|
|
|
|
|
class CalendarService:
|
|
"""Create, query, and manage calendar events, types, tags, and invitations.
|
|
|
|
Mirrors ``DocumentService``: ``__init__`` stores a db_path, async methods
|
|
delegate to ``calendar.db`` functions. RRULE expansion is handled here
|
|
so routes and tools get a flat list of occurrences.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
db_path: str | Path | None = None,
|
|
auth_db_path: str | Path | None = None,
|
|
) -> None:
|
|
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
|
self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH
|
|
|
|
# ------------------------------------------------------------------
|
|
# Event CRUD
|
|
# ------------------------------------------------------------------
|
|
|
|
async def create_event(
|
|
self,
|
|
user_id: str,
|
|
title: str,
|
|
start_time: str,
|
|
end_time: str,
|
|
description: str = "",
|
|
location: str = "",
|
|
is_all_day: bool = False,
|
|
event_type_id: str | None = None,
|
|
rrule: str | None = None,
|
|
source: str = "manual",
|
|
is_invited: bool = False,
|
|
conversation_id: str | None = None,
|
|
tag_ids: list[str] | None = None,
|
|
) -> CalendarEvent:
|
|
"""Create a calendar event with UUID, timestamps, tags, and cloned reminders."""
|
|
_validate_iso(start_time)
|
|
now = _now_iso()
|
|
event = CalendarEvent(
|
|
id=uuid.uuid4().hex,
|
|
user_id=user_id,
|
|
title=title,
|
|
description=description,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
is_all_day=is_all_day,
|
|
location=location,
|
|
event_type_id=event_type_id,
|
|
rrule=rrule,
|
|
source=source,
|
|
is_invited=is_invited,
|
|
conversation_id=conversation_id,
|
|
last_modified=now,
|
|
created_at=now,
|
|
)
|
|
await insert_event(event, self.db_path)
|
|
|
|
# Link tags if provided — skip any that don't belong to the user
|
|
if tag_ids:
|
|
user_tags = await self.list_tags(user_id)
|
|
user_tag_ids = {t.id for t in user_tags}
|
|
for tag_id in tag_ids:
|
|
if tag_id not in user_tag_ids:
|
|
logger.debug("Skipping tag %s — not owned by user %s", tag_id, user_id)
|
|
continue
|
|
await add_tag_to_event(event.id, tag_id, self.db_path)
|
|
|
|
# Clone type-level default reminder rules to the event — skip if type
|
|
# doesn't belong to the user
|
|
if event_type_id:
|
|
user_types = await self.list_event_types(user_id)
|
|
user_type_ids = {t.id for t in user_types}
|
|
if event_type_id not in user_type_ids:
|
|
logger.debug(
|
|
"Skipping event_type %s — not owned by user %s", event_type_id, user_id
|
|
)
|
|
else:
|
|
type_rules = await list_reminder_rules_for_type(event_type_id, self.db_path)
|
|
for rule in type_rules:
|
|
cloned = dataclasses.replace(
|
|
rule,
|
|
id=uuid.uuid4().hex,
|
|
event_id=event.id,
|
|
event_type_id=None,
|
|
)
|
|
await insert_reminder_rule(cloned, self.db_path)
|
|
|
|
logger.info(f"Created event {event.id} ({title}) for user {user_id}")
|
|
return event
|
|
|
|
async def get_event(self, event_id: str) -> CalendarEvent | None:
|
|
"""Return a single event by id, or None."""
|
|
return await db_get_event(event_id, self.db_path)
|
|
|
|
async def list_events(
|
|
self,
|
|
user_id: str,
|
|
start: str | None = None,
|
|
end: str | None = None,
|
|
event_type_id: str | None = None,
|
|
tag_id: str | None = None,
|
|
) -> list[CalendarEvent]:
|
|
"""List events for a user, expanding recurring events within [start, end].
|
|
|
|
Non-recurring events are filtered by date range manually (not in the
|
|
DB query) so that recurring events whose first occurrence falls
|
|
outside the range are still included and expanded.
|
|
"""
|
|
# Fetch all events for the user with type/tag filters — no date filter
|
|
# at the DB level so recurring events are not excluded.
|
|
events = await db_list_events(
|
|
user_id,
|
|
event_type_id=event_type_id,
|
|
tag_id=tag_id,
|
|
db_path=self.db_path,
|
|
)
|
|
|
|
result: list[CalendarEvent] = []
|
|
for event in events:
|
|
if event.rrule:
|
|
# Expand recurring event within [start, end] range
|
|
try:
|
|
occurrences = expand_rrule(
|
|
event.rrule,
|
|
event.start_time,
|
|
range_start=start,
|
|
range_end=end,
|
|
)
|
|
except ValueError:
|
|
# ponytail: malformed RRULE → fall back to single occurrence
|
|
# so one bad event doesn't crash the whole list. Upgrade
|
|
# path: surface a validation error at create_event time.
|
|
logger.warning(
|
|
"Malformed RRULE %r for event %s; falling back to start_time",
|
|
event.rrule,
|
|
event.id,
|
|
)
|
|
occurrences = [event.start_time]
|
|
for occ_start_str in occurrences:
|
|
occ = self._make_occurrence(event, occ_start_str)
|
|
result.append(occ)
|
|
else:
|
|
# Non-recurring: filter by date range manually
|
|
if _is_in_range(event.start_time, start, end):
|
|
result.append(event)
|
|
|
|
# Sort by start_time for consistent ordering
|
|
result.sort(key=lambda e: e.start_time)
|
|
return result
|
|
|
|
def _make_occurrence(self, event: CalendarEvent, occ_start_str: str) -> CalendarEvent:
|
|
"""Create a copy of *event* with start/end times shifted to the occurrence."""
|
|
occ_start_dt = _parse_dt(occ_start_str)
|
|
original_start = _parse_dt(event.start_time)
|
|
original_end = _parse_dt(event.end_time)
|
|
duration = original_end - original_start
|
|
occ_end_dt = occ_start_dt + duration
|
|
return dataclasses.replace(
|
|
event,
|
|
start_time=_format_dt(occ_start_dt),
|
|
end_time=_format_dt(occ_end_dt),
|
|
)
|
|
|
|
async def update_event(self, event_id: str, fields: dict) -> bool:
|
|
"""Update specific fields of an event. Auto-updates last_modified."""
|
|
fields = {**fields, "last_modified": _now_iso()}
|
|
return await db_update_event(event_id, fields, self.db_path)
|
|
|
|
async def delete_event(self, event_id: str) -> bool:
|
|
"""Delete an event and its dependent rows."""
|
|
return await db_delete_event(event_id, self.db_path)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Event Type CRUD
|
|
# ------------------------------------------------------------------
|
|
|
|
async def list_event_types(self, user_id: str) -> list[EventType]:
|
|
"""List all event types for a user."""
|
|
return await db_list_event_types(user_id, self.db_path)
|
|
|
|
async def get_event_type(self, type_id: str) -> EventType | None:
|
|
"""Return a single event type by id, or None."""
|
|
async with aiosqlite.connect(str(self.db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute(
|
|
"SELECT * FROM calendar_event_types WHERE id = ?",
|
|
(type_id,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
return EventType(
|
|
id=row["id"],
|
|
user_id=row["user_id"],
|
|
name=row["name"],
|
|
color=row["color"],
|
|
is_default=bool(row["is_default"]),
|
|
)
|
|
|
|
async def create_event_type(
|
|
self,
|
|
user_id: str,
|
|
name: str,
|
|
color: str = "#4A90D9",
|
|
) -> EventType:
|
|
"""Create a new event type."""
|
|
et = EventType(
|
|
id=uuid.uuid4().hex,
|
|
user_id=user_id,
|
|
name=name,
|
|
color=color,
|
|
)
|
|
await insert_event_type(et, self.db_path)
|
|
return et
|
|
|
|
async def update_event_type(self, type_id: str, fields: dict) -> bool:
|
|
"""Update specific fields of an event type."""
|
|
return await db_update_event_type(type_id, fields, self.db_path)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tag CRUD
|
|
# ------------------------------------------------------------------
|
|
|
|
async def list_tags(self, user_id: str) -> list[Tag]:
|
|
"""List all tags for a user."""
|
|
return await db_list_tags(user_id, self.db_path)
|
|
|
|
async def create_tag(self, user_id: str, name: str) -> Tag:
|
|
"""Create a new tag."""
|
|
tag = Tag(
|
|
id=uuid.uuid4().hex,
|
|
user_id=user_id,
|
|
name=name,
|
|
)
|
|
await insert_tag(tag, self.db_path)
|
|
return tag
|
|
|
|
# ------------------------------------------------------------------
|
|
# Invitation CRUD
|
|
# ------------------------------------------------------------------
|
|
|
|
async def create_invitation(
|
|
self,
|
|
event_id: str,
|
|
inviter_user_id: str,
|
|
invitee_email: str,
|
|
) -> Invitation:
|
|
"""Create an invitation with status='pending'."""
|
|
invitation = Invitation(
|
|
id=uuid.uuid4().hex,
|
|
event_id=event_id,
|
|
inviter_user_id=inviter_user_id,
|
|
invitee_email=invitee_email,
|
|
status="pending",
|
|
)
|
|
await insert_invitation(invitation, self.db_path)
|
|
return invitation
|
|
|
|
async def respond_to_invitation(self, invitation_id: str, status: str) -> bool:
|
|
"""Update invitation status and set responded_at."""
|
|
return await update_invitation_status(
|
|
invitation_id,
|
|
status,
|
|
_now_iso(),
|
|
self.db_path,
|
|
)
|
|
|
|
async def get_invitation(self, invitation_id: str) -> Invitation | None:
|
|
"""Return a single invitation by id, or None."""
|
|
return await db_get_invitation(invitation_id, self.db_path)
|
|
|
|
async def list_invitations(self, invitee_email: str) -> list[Invitation]:
|
|
"""List all invitations for an invitee email."""
|
|
return await db_list_invitations(invitee_email, self.db_path)
|
|
|
|
# ------------------------------------------------------------------
|
|
# User search (auth DB)
|
|
# ------------------------------------------------------------------
|
|
|
|
async def search_users(self, q: str) -> list[dict]:
|
|
"""Search users by username or email. Returns top 10 matches.
|
|
|
|
Only ``username`` and ``email`` are returned — never user_id or
|
|
password fields (G5/A3 — least-privilege user search).
|
|
"""
|
|
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
pattern = f"%{escaped_q}%"
|
|
async with aiosqlite.connect(str(self.auth_db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute(
|
|
"SELECT username, email FROM users "
|
|
"WHERE username LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' LIMIT 10",
|
|
(pattern, pattern),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [{"username": row["username"], "email": row["email"]} for row in rows]
|
|
|
|
async def get_user_email(self, user_id: str) -> str | None:
|
|
"""Look up a user's email from the auth DB by user_id."""
|
|
async with aiosqlite.connect(str(self.auth_db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute(
|
|
"SELECT email FROM users WHERE id = ?",
|
|
(user_id,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
return row["email"] if row else None
|
|
|
|
|
|
def _is_in_range(dt_str: str, start: str | None, end: str | None) -> bool:
|
|
"""Check if *dt_str* falls within the half-open range [start, end)."""
|
|
dt = _parse_dt(dt_str)
|
|
if start is not None:
|
|
if dt < _parse_dt(start):
|
|
return False
|
|
if end is not None:
|
|
if dt >= _parse_dt(end):
|
|
return False
|
|
return True
|