fischer-agentkit/src/agentkit/calendar/service.py

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