feat(calendar): U1 backend data model, storage & RRULE expansion

Add calendar subsystem foundation mirroring documents/ pattern:
- models.py: 8 dataclasses (CalendarEvent with is_invited, EventType,
  Tag, EventTag, ReminderRule, ReminderDelivery, ExternalCalendarConfig,
  Invitation)
- db.py: aiosqlite bare-connection CRUD for all 8 tables with WAL mode
- recurrence.py: RRULE expansion via dateutil.rrule (RFC 5545)
- 16 unit tests covering DB CRUD and RRULE edge cases (DST, UNTIL, range)
- Add python-dateutil>=2.9 to pyproject.toml
This commit is contained in:
chiguyong 2026-06-23 21:30:39 +08:00
parent 3337589395
commit 2ea799f6c4
8 changed files with 1475 additions and 0 deletions

View File

@ -24,6 +24,8 @@ dependencies = [
"pyjwt>=2.8", "pyjwt>=2.8",
"bcrypt>=4.0", "bcrypt>=4.0",
"aiosqlite>=0.20", "aiosqlite>=0.20",
# Calendar & schedule (RRULE expansion)
"python-dateutil>=2.9",
# Document processing (U1-U9) # Document processing (U1-U9)
"python-docx>=1.1", "python-docx>=1.1",
"openpyxl>=3.1", "openpyxl>=3.1",

View File

@ -0,0 +1,7 @@
"""Calendar & schedule subsystem.
Mirrors the ``documents/`` structure: ``models.py`` (dataclass DTOs),
``db.py`` (aiosqlite persistence), ``service.py`` (business logic),
``recurrence.py`` (RRULE expansion), ``scheduler.py`` (reminder loop),
``extraction.py`` (post-processing), and ``sync/`` (external calendars).
"""

798
src/agentkit/calendar/db.py Normal file
View File

@ -0,0 +1,798 @@
"""SQLite persistence for calendar data.
Follows the aiosqlite bare-connection pattern from ``documents/db.py``:
no SQLAlchemy session injection, just ``async with aiosqlite.connect(...)``.
All timestamps are ISO 8601 UTC (see KTD-11).
"""
from __future__ import annotations
import json
import logging
import os
from collections.abc import Mapping
from pathlib import Path
import aiosqlite
from agentkit.calendar.models import (
CalendarEvent,
EventType,
ExternalCalendarConfig,
Invitation,
ReminderDelivery,
ReminderRule,
Tag,
)
logger = logging.getLogger(__name__)
_PROJECT_ROOT = Path(__file__).parents[3]
DEFAULT_CALENDAR_DB_PATH = Path(
os.environ.get("AGENTKIT_CALENDAR_DB", _PROJECT_ROOT / "data" / "calendar.db")
)
_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS calendar_event_types (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#4A90D9',
is_default INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_event_types_user
ON calendar_event_types(user_id);
CREATE TABLE IF NOT EXISTS calendar_tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tags_user
ON calendar_tags(user_id);
CREATE TABLE IF NOT EXISTS calendar_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
is_all_day INTEGER NOT NULL DEFAULT 0,
location TEXT NOT NULL DEFAULT '',
event_type_id TEXT,
rrule TEXT,
source TEXT NOT NULL DEFAULT 'manual',
is_invited INTEGER NOT NULL DEFAULT 0,
conversation_id TEXT,
external_id TEXT,
external_provider TEXT,
last_modified TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id)
);
CREATE INDEX IF NOT EXISTS idx_events_user
ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_events_start_time
ON calendar_events(start_time);
CREATE INDEX IF NOT EXISTS idx_events_external
ON calendar_events(external_id, external_provider);
CREATE TABLE IF NOT EXISTS calendar_event_tags (
event_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (event_id, tag_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES calendar_tags(id)
);
CREATE TABLE IF NOT EXISTS calendar_reminder_rules (
id TEXT PRIMARY KEY,
event_id TEXT,
event_type_id TEXT,
offset_minutes INTEGER NOT NULL DEFAULT -15,
channels TEXT NOT NULL DEFAULT '["client"]',
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id)
);
CREATE TABLE IF NOT EXISTS calendar_reminder_deliveries (
id TEXT PRIMARY KEY,
reminder_rule_id TEXT NOT NULL,
event_id TEXT NOT NULL,
scheduled_time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
channel TEXT NOT NULL DEFAULT 'client',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
FOREIGN KEY (reminder_rule_id) REFERENCES calendar_reminder_rules(id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_deliveries_status
ON calendar_reminder_deliveries(status, scheduled_time);
CREATE TABLE IF NOT EXISTS calendar_external_configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
credentials TEXT NOT NULL DEFAULT '',
sync_frequency INTEGER NOT NULL DEFAULT 30,
sync_scope TEXT NOT NULL DEFAULT '[]',
last_sync TEXT,
sync_token TEXT
);
CREATE INDEX IF NOT EXISTS idx_external_configs_user
ON calendar_external_configs(user_id);
CREATE TABLE IF NOT EXISTS calendar_invitations (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
inviter_user_id TEXT NOT NULL,
invitee_email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
responded_at TEXT,
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_invitations_email
ON calendar_invitations(invitee_email, status);
"""
async def init_calendar_db(db_path: str | Path | None = None) -> Path:
"""Create all calendar tables if they do not exist. Idempotent."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.executescript(_SCHEMA_SQL)
await db.commit()
logger.info(f"Calendar DB initialized at {path}")
return path
# ---------------------------------------------------------------------------
# Row → dataclass converters
# ---------------------------------------------------------------------------
def _row_to_event_type(row: aiosqlite.Row | Mapping[str, object]) -> EventType:
return EventType(
id=row["id"],
user_id=row["user_id"],
name=row["name"],
color=row["color"],
is_default=bool(row["is_default"]),
)
def _row_to_tag(row: aiosqlite.Row | Mapping[str, object]) -> Tag:
return Tag(id=row["id"], user_id=row["user_id"], name=row["name"])
def _row_to_event(row: aiosqlite.Row | Mapping[str, object]) -> CalendarEvent:
return CalendarEvent(
id=row["id"],
user_id=row["user_id"],
title=row["title"],
description=row["description"],
start_time=row["start_time"],
end_time=row["end_time"],
is_all_day=bool(row["is_all_day"]),
location=row["location"],
event_type_id=row["event_type_id"],
rrule=row["rrule"],
source=row["source"],
is_invited=bool(row["is_invited"]),
conversation_id=row["conversation_id"],
external_id=row["external_id"],
external_provider=row["external_provider"],
last_modified=row["last_modified"],
created_at=row["created_at"],
)
def _row_to_reminder_rule(row: aiosqlite.Row | Mapping[str, object]) -> ReminderRule:
return ReminderRule(
id=row["id"],
event_id=row["event_id"],
event_type_id=row["event_type_id"],
offset_minutes=row["offset_minutes"],
channels=json.loads(row["channels"]),
)
def _row_to_reminder_delivery(row: aiosqlite.Row | Mapping[str, object]) -> ReminderDelivery:
return ReminderDelivery(
id=row["id"],
reminder_rule_id=row["reminder_rule_id"],
event_id=row["event_id"],
scheduled_time=row["scheduled_time"],
status=row["status"],
channel=row["channel"],
attempts=row["attempts"],
last_error=row["last_error"],
)
def _row_to_external_config(row: aiosqlite.Row | Mapping[str, object]) -> ExternalCalendarConfig:
return ExternalCalendarConfig(
id=row["id"],
user_id=row["user_id"],
provider=row["provider"],
credentials=row["credentials"],
sync_frequency=row["sync_frequency"],
sync_scope=json.loads(row["sync_scope"]),
last_sync=row["last_sync"],
sync_token=row["sync_token"],
)
def _row_to_invitation(row: aiosqlite.Row | Mapping[str, object]) -> Invitation:
return Invitation(
id=row["id"],
event_id=row["event_id"],
inviter_user_id=row["inviter_user_id"],
invitee_email=row["invitee_email"],
status=row["status"],
responded_at=row["responded_at"],
)
# ---------------------------------------------------------------------------
# Event CRUD
# ---------------------------------------------------------------------------
async def insert_event(event: CalendarEvent, db_path: str | Path | None = None) -> None:
"""Insert a calendar event."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT INTO calendar_events (id, user_id, title, description, "
"start_time, end_time, is_all_day, location, event_type_id, rrule, "
"source, is_invited, conversation_id, external_id, external_provider, "
"last_modified, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
event.id,
event.user_id,
event.title,
event.description,
event.start_time,
event.end_time,
int(event.is_all_day),
event.location,
event.event_type_id,
event.rrule,
event.source,
int(event.is_invited),
event.conversation_id,
event.external_id,
event.external_provider,
event.last_modified,
event.created_at,
),
)
await db.commit()
async def get_event(event_id: str, db_path: str | Path | None = None) -> CalendarEvent | None:
"""Return a single event by id, or None."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM calendar_events WHERE id = ?", (event_id,))
row = await cursor.fetchone()
return _row_to_event(row) if row else None
async def list_events(
user_id: str,
start: str | None = None,
end: str | None = None,
event_type_id: str | None = None,
tag_id: str | None = None,
db_path: str | Path | None = None,
) -> list[CalendarEvent]:
"""List events for a user with optional filters."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
query = "SELECT DISTINCT e.* FROM calendar_events e"
params: list[str] = []
conditions: list[str] = ["e.user_id = ?"]
params.append(user_id)
if start is not None:
conditions.append("e.start_time >= ?")
params.append(start)
if end is not None:
conditions.append("e.start_time < ?")
params.append(end)
if event_type_id is not None:
conditions.append("e.event_type_id = ?")
params.append(event_type_id)
if tag_id is not None:
query += " JOIN calendar_event_tags et ON et.event_id = e.id"
conditions.append("et.tag_id = ?")
params.append(tag_id)
query += " WHERE " + " AND ".join(conditions) + " ORDER BY e.start_time"
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(query, tuple(params))
rows = await cursor.fetchall()
return [_row_to_event(row) for row in rows]
async def update_event(
event_id: str, fields: dict[str, object], db_path: str | Path | None = None
) -> bool:
"""Update specific fields of an event. Returns True if a row was updated."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
# Map dataclass field names to column names, handle bool → int
column_map = {
"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",
"external_id": "external_id",
"external_provider": "external_provider",
"last_modified": "last_modified",
}
set_clauses: list[str] = []
params: list[object] = []
for field_name, value in fields.items():
col = column_map.get(field_name)
if col is None:
continue
if field_name in ("is_all_day", "is_invited"):
value = int(bool(value))
set_clauses.append(f"{col} = ?")
params.append(value)
if not set_clauses:
return False
params.append(event_id)
sql = f"UPDATE calendar_events SET {', '.join(set_clauses)} WHERE id = ?"
async with aiosqlite.connect(str(path)) as db:
cursor = await db.execute(sql, tuple(params))
await db.commit()
return cursor.rowcount > 0
async def delete_event(event_id: str, db_path: str | Path | None = None) -> bool:
"""Delete an event and its dependent rows. Returns True if deleted."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA foreign_keys = ON")
# Manual cascade for event_tags (no ON DELETE on junction FK in some SQLite versions)
await db.execute("DELETE FROM calendar_event_tags WHERE event_id = ?", (event_id,))
cursor = await db.execute("DELETE FROM calendar_events WHERE id = ?", (event_id,))
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Event Type CRUD
# ---------------------------------------------------------------------------
async def insert_event_type(et: EventType, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute(
"INSERT INTO calendar_event_types (id, user_id, name, color, is_default) "
"VALUES (?, ?, ?, ?, ?)",
(et.id, et.user_id, et.name, et.color, int(et.is_default)),
)
await db.commit()
async def list_event_types(user_id: str, db_path: str | Path | None = None) -> list[EventType]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_event_types WHERE user_id = ? ORDER BY name",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_event_type(row) for row in rows]
async def update_event_type(
type_id: str, fields: dict[str, object], db_path: str | Path | None = None
) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
set_clauses: list[str] = []
params: list[object] = []
for field_name, value in fields.items():
if field_name == "name":
set_clauses.append("name = ?")
params.append(value)
elif field_name == "color":
set_clauses.append("color = ?")
params.append(value)
elif field_name == "is_default":
set_clauses.append("is_default = ?")
params.append(int(bool(value)))
if not set_clauses:
return False
params.append(type_id)
sql = f"UPDATE calendar_event_types SET {', '.join(set_clauses)} WHERE id = ?"
async with aiosqlite.connect(str(path)) as db:
cursor = await db.execute(sql, tuple(params))
await db.commit()
return cursor.rowcount > 0
async def delete_event_type(type_id: str, db_path: str | Path | None = None) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
cursor = await db.execute("DELETE FROM calendar_event_types WHERE id = ?", (type_id,))
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Tag CRUD
# ---------------------------------------------------------------------------
async def insert_tag(tag: Tag, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute(
"INSERT INTO calendar_tags (id, user_id, name) VALUES (?, ?, ?)",
(tag.id, tag.user_id, tag.name),
)
await db.commit()
async def list_tags(user_id: str, db_path: str | Path | None = None) -> list[Tag]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_tags WHERE user_id = ? ORDER BY name",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_tag(row) for row in rows]
async def delete_tag(tag_id: str, db_path: str | Path | None = None) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("DELETE FROM calendar_event_tags WHERE tag_id = ?", (tag_id,))
cursor = await db.execute("DELETE FROM calendar_tags WHERE id = ?", (tag_id,))
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Event-Tag junction
# ---------------------------------------------------------------------------
async def add_tag_to_event(event_id: str, tag_id: str, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute(
"INSERT OR IGNORE INTO calendar_event_tags (event_id, tag_id) VALUES (?, ?)",
(event_id, tag_id),
)
await db.commit()
async def remove_tag_from_event(
event_id: str, tag_id: str, db_path: str | Path | None = None
) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute(
"DELETE FROM calendar_event_tags WHERE event_id = ? AND tag_id = ?",
(event_id, tag_id),
)
await db.commit()
async def get_event_tags(event_id: str, db_path: str | Path | None = None) -> list[Tag]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT t.* FROM calendar_tags t "
"JOIN calendar_event_tags et ON et.tag_id = t.id "
"WHERE et.event_id = ?",
(event_id,),
)
rows = await cursor.fetchall()
return [_row_to_tag(row) for row in rows]
# ---------------------------------------------------------------------------
# Reminder Rule CRUD
# ---------------------------------------------------------------------------
async def insert_reminder_rule(rule: ReminderRule, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute(
"INSERT INTO calendar_reminder_rules "
"(id, event_id, event_type_id, offset_minutes, channels) "
"VALUES (?, ?, ?, ?, ?)",
(
rule.id,
rule.event_id,
rule.event_type_id,
rule.offset_minutes,
json.dumps(rule.channels),
),
)
await db.commit()
async def list_reminder_rules_for_event(
event_id: str, db_path: str | Path | None = None
) -> list[ReminderRule]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_reminder_rules WHERE event_id = ?",
(event_id,),
)
rows = await cursor.fetchall()
return [_row_to_reminder_rule(row) for row in rows]
async def list_reminder_rules_for_type(
event_type_id: str, db_path: str | Path | None = None
) -> list[ReminderRule]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_reminder_rules WHERE event_type_id = ?",
(event_type_id,),
)
rows = await cursor.fetchall()
return [_row_to_reminder_rule(row) for row in rows]
async def delete_reminder_rule(rule_id: str, db_path: str | Path | None = None) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
cursor = await db.execute("DELETE FROM calendar_reminder_rules WHERE id = ?", (rule_id,))
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Reminder Delivery CRUD
# ---------------------------------------------------------------------------
async def insert_reminder_delivery(
delivery: ReminderDelivery, db_path: str | Path | None = None
) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute(
"INSERT INTO calendar_reminder_deliveries "
"(id, reminder_rule_id, event_id, scheduled_time, status, channel, "
"attempts, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
delivery.id,
delivery.reminder_rule_id,
delivery.event_id,
delivery.scheduled_time,
delivery.status,
delivery.channel,
delivery.attempts,
delivery.last_error,
),
)
await db.commit()
async def get_pending_deliveries(
event_id: str, reminder_rule_id: str, db_path: str | Path | None = None
) -> list[ReminderDelivery]:
"""Check idempotency — return existing deliveries for an event+rule."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_reminder_deliveries "
"WHERE event_id = ? AND reminder_rule_id = ?",
(event_id, reminder_rule_id),
)
rows = await cursor.fetchall()
return [_row_to_reminder_delivery(row) for row in rows]
async def update_delivery_status(
delivery_id: str,
status: str,
last_error: str | None = None,
db_path: str | Path | None = None,
) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
cursor = await db.execute(
"UPDATE calendar_reminder_deliveries "
"SET status = ?, attempts = attempts + 1, last_error = ? "
"WHERE id = ?",
(status, last_error, delivery_id),
)
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# External Calendar Config CRUD
# ---------------------------------------------------------------------------
async def insert_external_config(
config: ExternalCalendarConfig, db_path: str | Path | None = None
) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute(
"INSERT INTO calendar_external_configs "
"(id, user_id, provider, credentials, sync_frequency, sync_scope, "
"last_sync, sync_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
config.id,
config.user_id,
config.provider,
config.credentials,
config.sync_frequency,
json.dumps(config.sync_scope),
config.last_sync,
config.sync_token,
),
)
await db.commit()
async def list_external_configs(
user_id: str, db_path: str | Path | None = None
) -> list[ExternalCalendarConfig]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_external_configs WHERE user_id = ?",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_external_config(row) for row in rows]
async def update_external_config(
config_id: str, fields: dict[str, object], db_path: str | Path | None = None
) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
set_clauses: list[str] = []
params: list[object] = []
for field_name, value in fields.items():
if field_name == "credentials":
set_clauses.append("credentials = ?")
params.append(value)
elif field_name == "sync_frequency":
set_clauses.append("sync_frequency = ?")
params.append(value)
elif field_name == "sync_scope":
set_clauses.append("sync_scope = ?")
params.append(json.dumps(value))
elif field_name == "last_sync":
set_clauses.append("last_sync = ?")
params.append(value)
elif field_name == "sync_token":
set_clauses.append("sync_token = ?")
params.append(value)
if not set_clauses:
return False
params.append(config_id)
sql = f"UPDATE calendar_external_configs SET {', '.join(set_clauses)} WHERE id = ?"
async with aiosqlite.connect(str(path)) as db:
cursor = await db.execute(sql, tuple(params))
await db.commit()
return cursor.rowcount > 0
async def delete_external_config(config_id: str, db_path: str | Path | None = None) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
cursor = await db.execute(
"DELETE FROM calendar_external_configs WHERE id = ?", (config_id,)
)
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Invitation CRUD
# ---------------------------------------------------------------------------
async def insert_invitation(invitation: Invitation, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute(
"INSERT INTO calendar_invitations "
"(id, event_id, inviter_user_id, invitee_email, status, responded_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
invitation.id,
invitation.event_id,
invitation.inviter_user_id,
invitation.invitee_email,
invitation.status,
invitation.responded_at,
),
)
await db.commit()
async def get_invitation(
invitation_id: str, db_path: str | Path | None = None
) -> Invitation | None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_invitations WHERE id = ?", (invitation_id,)
)
row = await cursor.fetchone()
return _row_to_invitation(row) if row else None
async def list_invitations(
invitee_email: str, db_path: str | Path | None = None
) -> list[Invitation]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_invitations WHERE invitee_email = ? ORDER BY responded_at DESC",
(invitee_email,),
)
rows = await cursor.fetchall()
return [_row_to_invitation(row) for row in rows]
async def update_invitation_status(
invitation_id: str,
status: str,
responded_at: str,
db_path: str | Path | None = None,
) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
cursor = await db.execute(
"UPDATE calendar_invitations SET status = ?, responded_at = ? WHERE id = ?",
(status, responded_at, invitation_id),
)
await db.commit()
return cursor.rowcount > 0

View File

@ -0,0 +1,221 @@
"""Data models for the calendar subsystem.
All dataclasses are DTOs carried between CalendarService, Agent tools,
REST routes, and the frontend. They mirror the ``calendar_*`` DB rows.
All timestamps are ISO 8601 UTC (see KTD-11).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
def _now_iso() -> str:
"""Return current UTC time as ISO 8601 string."""
return datetime.now(timezone.utc).isoformat()
@dataclass
class EventType:
"""User-defined event type (e.g. "会议", "截止", "个人")."""
id: str
user_id: str
name: str
color: str = "#4A90D9"
is_default: bool = False
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"user_id": self.user_id,
"name": self.name,
"color": self.color,
"is_default": self.is_default,
}
@dataclass
class Tag:
"""User-defined tag for events."""
id: str
user_id: str
name: str
def to_dict(self) -> dict[str, object]:
return {"id": self.id, "user_id": self.user_id, "name": self.name}
@dataclass
class CalendarEvent:
"""A calendar event.
Attributes:
source: "manual" | "agent" | "post_extract" origin traceability (R15).
is_invited: True if this event arrived via invitation (R33 special styling).
rrule: RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10".
external_id: ID in external calendar (for sync).
external_provider: "caldav" | "outlook" | None.
last_modified: ISO 8601 UTC, for conflict resolution (last-write-wins).
"""
id: str
user_id: str
title: str
description: str = ""
start_time: str = "" # ISO 8601 UTC (KTD-11)
end_time: str = "" # ISO 8601 UTC
is_all_day: bool = False
location: str = ""
event_type_id: str | None = None
rrule: str | None = None
source: str = "manual" # "manual" | "agent" | "post_extract"
is_invited: bool = False
conversation_id: str | None = None
external_id: str | None = None
external_provider: str | None = None
last_modified: str = ""
created_at: str = ""
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"user_id": self.user_id,
"title": self.title,
"description": self.description,
"start_time": self.start_time,
"end_time": self.end_time,
"is_all_day": self.is_all_day,
"location": self.location,
"event_type_id": self.event_type_id,
"rrule": self.rrule,
"source": self.source,
"is_invited": self.is_invited,
"conversation_id": self.conversation_id,
"external_id": self.external_id,
"external_provider": self.external_provider,
"last_modified": self.last_modified,
"created_at": self.created_at,
}
@dataclass
class EventTag:
"""Many-to-many junction between events and tags."""
event_id: str
tag_id: str
def to_dict(self) -> dict[str, object]:
return {"event_id": self.event_id, "tag_id": self.tag_id}
@dataclass
class ReminderRule:
"""Reminder rule — per-event or per-event-type default.
Attributes:
event_id: FK to events (nullable for type-level defaults).
event_type_id: FK to event_types (for default reminders).
offset_minutes: -15 = 15 min before, -1440 = 1 day before.
channels: ["client", "email", "webhook"].
"""
id: str
event_id: str | None = None
event_type_id: str | None = None
offset_minutes: int = -15
channels: list[str] = field(default_factory=lambda: ["client"])
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"event_id": self.event_id,
"event_type_id": self.event_type_id,
"offset_minutes": self.offset_minutes,
"channels": self.channels,
}
@dataclass
class ReminderDelivery:
"""Tracks delivery status of a reminder instance."""
id: str
reminder_rule_id: str
event_id: str
scheduled_time: str # ISO 8601 UTC
status: str = "pending" # "pending" | "sent" | "failed" | "read"
channel: str = "client"
attempts: int = 0
last_error: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"reminder_rule_id": self.reminder_rule_id,
"event_id": self.event_id,
"scheduled_time": self.scheduled_time,
"status": self.status,
"channel": self.channel,
"attempts": self.attempts,
"last_error": self.last_error,
}
@dataclass
class ExternalCalendarConfig:
"""Configuration for an external calendar sync provider.
Attributes:
provider: "caldav" | "outlook".
credentials: encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token).
sync_frequency: sync interval in minutes.
sync_scope: event type IDs to sync, empty = all.
sync_token: delta token for incremental sync.
"""
id: str
user_id: str
provider: str # "caldav" | "outlook"
credentials: str = "" # encrypted JSON
sync_frequency: int = 30 # minutes
sync_scope: list[str] = field(default_factory=list)
last_sync: str | None = None
sync_token: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"user_id": self.user_id,
"provider": self.provider,
"credentials": "***", # Never expose credentials
"sync_frequency": self.sync_frequency,
"sync_scope": self.sync_scope,
"last_sync": self.last_sync,
"sync_token": self.sync_token,
}
@dataclass
class Invitation:
"""Event invitation from one user to another."""
id: str
event_id: str
inviter_user_id: str
invitee_email: str
status: str = "pending" # "pending" | "accepted" | "declined" | "tentative"
responded_at: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"event_id": self.event_id,
"inviter_user_id": self.inviter_user_id,
"invitee_email": self.invitee_email,
"status": self.status,
"responded_at": self.responded_at,
}

View File

@ -0,0 +1,67 @@
"""RRULE recurrence expansion wrapper.
Uses ``dateutil.rrule`` for RFC 5545 compliant recurrence rule expansion.
All times are UTC (see KTD-11).
"""
from __future__ import annotations
from datetime import datetime, timezone
from dateutil.rrule import rrulestr
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 expand_rrule(
rrule_str: str | None,
dtstart: str,
range_start: str | None = None,
range_end: str | None = None,
) -> list[str]:
"""Expand a recurrence rule into individual occurrence start times.
Args:
rrule_str: RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=4").
If None or empty, returns ``[dtstart]``.
dtstart: ISO 8601 start time of the first occurrence (UTC).
range_start: Optional ISO 8601 lower bound (inclusive) for filtering.
range_end: Optional ISO 8601 upper bound (exclusive) for filtering.
Returns:
List of ISO 8601 UTC strings for each occurrence's start time
within the given range. If no range is specified, returns all
occurrences (bounded by COUNT or UNTIL in the RRULE).
"""
if not rrule_str:
return [dtstart]
start_dt = _parse_dt(dtstart)
# rrulestr expects the RRULE to have a DTSTART context.
# We prepend DTSTART to ensure the rule starts from the event's start time.
full_rule = f"DTSTART:{start_dt.strftime('%Y%m%dT%H%M%SZ')}\nRRULE:{rrule_str}"
rule = rrulestr(full_rule)
if range_start is not None and range_end is not None:
rs = _parse_dt(range_start)
re_ = _parse_dt(range_end)
# Half-open interval [start, end) — standard date-range convention
occurrences = [dt for dt in rule if rs <= dt < re_]
elif range_start is not None:
rs = _parse_dt(range_start)
occurrences = [dt for dt in rule if dt >= rs]
elif range_end is not None:
re_ = _parse_dt(range_end)
occurrences = [dt for dt in rule if dt < re_]
else:
occurrences = list(rule)
# Convert back to ISO 8601 UTC strings
return [dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") for dt in occurrences]

View File

View File

@ -0,0 +1,305 @@
"""Tests for calendar DB CRUD (U1)."""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from agentkit.calendar.db import (
add_tag_to_event,
delete_event,
delete_event_type,
get_event,
get_event_tags,
init_calendar_db,
insert_event,
insert_event_type,
insert_external_config,
insert_invitation,
insert_reminder_rule,
insert_tag,
list_event_types,
list_events,
list_external_configs,
list_invitations,
list_reminder_rules_for_event,
list_reminder_rules_for_type,
list_tags,
update_event,
update_event_type,
update_invitation_status,
)
from agentkit.calendar.models import (
CalendarEvent,
EventType,
ExternalCalendarConfig,
Invitation,
ReminderRule,
Tag,
_now_iso,
)
@pytest.fixture
def db_path(tmp_path: Path) -> Path:
path = tmp_path / "test_calendar.db"
asyncio.run(init_calendar_db(path))
return path
def _make_event(
event_id: str = "evt-1",
user_id: str = "user-1",
title: str = "Test Event",
start: str = "2026-07-01T10:00:00+00:00",
end: str = "2026-07-01T11:00:00+00:00",
**kwargs,
) -> CalendarEvent:
now = _now_iso()
return CalendarEvent(
id=event_id,
user_id=user_id,
title=title,
start_time=start,
end_time=end,
last_modified=now,
created_at=now,
**kwargs,
)
# ---------------------------------------------------------------------------
# init_calendar_db
# ---------------------------------------------------------------------------
def test_init_calendar_db_creates_all_tables(db_path: Path) -> None:
"""init_calendar_db creates all 8 tables."""
import aiosqlite
async def check():
async with aiosqlite.connect(str(db_path)) as db:
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = {row[0] for row in await cursor.fetchall()}
return tables
tables = asyncio.run(check())
expected = {
"calendar_events",
"calendar_event_types",
"calendar_tags",
"calendar_event_tags",
"calendar_reminder_rules",
"calendar_reminder_deliveries",
"calendar_external_configs",
"calendar_invitations",
}
assert expected.issubset(tables), f"Missing tables: {expected - tables}"
# ---------------------------------------------------------------------------
# Event CRUD
# ---------------------------------------------------------------------------
def test_insert_and_get_event_roundtrip(db_path: Path) -> None:
"""Insert event, fetch by id, all fields preserved including is_invited."""
event = _make_event(is_invited=True, source="agent", conversation_id="conv-1")
asyncio.run(insert_event(event, db_path))
fetched = asyncio.run(get_event("evt-1", db_path))
assert fetched is not None
assert fetched.id == "evt-1"
assert fetched.title == "Test Event"
assert fetched.is_invited is True
assert fetched.source == "agent"
assert fetched.conversation_id == "conv-1"
def test_list_events_by_user_filtered_by_date_range(db_path: Path) -> None:
"""Insert 3 events, query range covering 2."""
for i, day in enumerate([1, 15, 28]):
evt = _make_event(
event_id=f"evt-{i}",
start=f"2026-07-{day:02d}T10:00:00+00:00",
end=f"2026-07-{day:02d}T11:00:00+00:00",
)
asyncio.run(insert_event(evt, db_path))
events = asyncio.run(
list_events(
"user-1",
start="2026-07-10T00:00:00+00:00",
end="2026-07-20T00:00:00+00:00",
db_path=db_path,
)
)
assert len(events) == 1
assert events[0].id == "evt-1"
def test_update_event_modifies_fields(db_path: Path) -> None:
"""Insert, update title, verify."""
asyncio.run(insert_event(_make_event(), db_path))
updated = asyncio.run(update_event("evt-1", {"title": "Updated"}, db_path))
assert updated is True
fetched = asyncio.run(get_event("evt-1", db_path))
assert fetched is not None
assert fetched.title == "Updated"
def test_delete_event_removes_record(db_path: Path) -> None:
"""Insert, delete, verify gone."""
asyncio.run(insert_event(_make_event(), db_path))
deleted = asyncio.run(delete_event("evt-1", db_path))
assert deleted is True
fetched = asyncio.run(get_event("evt-1", db_path))
assert fetched is None
# ---------------------------------------------------------------------------
# Event Type CRUD
# ---------------------------------------------------------------------------
def test_event_type_crud(db_path: Path) -> None:
"""Create/list/update/delete event types."""
et = EventType(id="type-1", user_id="user-1", name="会议", color="#FF0000")
asyncio.run(insert_event_type(et, db_path))
types = asyncio.run(list_event_types("user-1", db_path))
assert len(types) == 1
assert types[0].name == "会议"
assert types[0].color == "#FF0000"
asyncio.run(update_event_type("type-1", {"name": "Meeting"}, db_path))
types = asyncio.run(list_event_types("user-1", db_path))
assert types[0].name == "Meeting"
deleted = asyncio.run(delete_event_type("type-1", db_path))
assert deleted is True
types = asyncio.run(list_event_types("user-1", db_path))
assert len(types) == 0
# ---------------------------------------------------------------------------
# Tag CRUD + many-to-many
# ---------------------------------------------------------------------------
def test_tag_many_to_many(db_path: Path) -> None:
"""Event with 3 tags, query by tag returns event."""
asyncio.run(insert_event(_make_event(), db_path))
for i in range(3):
tag = Tag(id=f"tag-{i}", user_id="user-1", name=f"Tag{i}")
asyncio.run(insert_tag(tag, db_path))
asyncio.run(add_tag_to_event("evt-1", f"tag-{i}", db_path))
# Get tags for event
tags = asyncio.run(get_event_tags("evt-1", db_path))
assert len(tags) == 3
# List events filtered by tag
events = asyncio.run(list_events("user-1", tag_id="tag-1", db_path=db_path))
assert len(events) == 1
assert events[0].id == "evt-1"
# List all tags
all_tags = asyncio.run(list_tags("user-1", db_path))
assert len(all_tags) == 3
# ---------------------------------------------------------------------------
# Reminder Rule CRUD
# ---------------------------------------------------------------------------
def test_reminder_rule_crud(db_path: Path) -> None:
"""Create rule for event, create default rule for type."""
asyncio.run(insert_event(_make_event(), db_path))
asyncio.run(
insert_event_type(EventType(id="type-1", user_id="user-1", name="Meeting"), db_path)
)
# Event-level rule
rule1 = ReminderRule(
id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client", "email"]
)
asyncio.run(insert_reminder_rule(rule1, db_path))
# Type-level default rule
rule2 = ReminderRule(
id="rule-2", event_type_id="type-1", offset_minutes=-1440, channels=["email"]
)
asyncio.run(insert_reminder_rule(rule2, db_path))
event_rules = asyncio.run(list_reminder_rules_for_event("evt-1", db_path))
assert len(event_rules) == 1
assert event_rules[0].offset_minutes == -15
assert event_rules[0].channels == ["client", "email"]
type_rules = asyncio.run(list_reminder_rules_for_type("type-1", db_path))
assert len(type_rules) == 1
assert type_rules[0].offset_minutes == -1440
# ---------------------------------------------------------------------------
# External Config
# ---------------------------------------------------------------------------
def test_external_config_stores_encrypted_credentials(db_path: Path) -> None:
"""Insert config, verify credentials field is opaque (stored as-is)."""
config = ExternalCalendarConfig(
id="cfg-1",
user_id="user-1",
provider="caldav",
credentials='{"url":"https://caldav.icloud.com","user":"alice","pass":"xxx"}',
sync_frequency=15,
sync_scope=["type-1", "type-2"],
)
asyncio.run(insert_external_config(config, db_path))
configs = asyncio.run(list_external_configs("user-1", db_path))
assert len(configs) == 1
assert configs[0].provider == "caldav"
assert configs[0].sync_frequency == 15
assert configs[0].sync_scope == ["type-1", "type-2"]
# Credentials stored as-is (encryption happens at service layer)
assert "caldav.icloud.com" in configs[0].credentials
# ---------------------------------------------------------------------------
# Invitation CRUD
# ---------------------------------------------------------------------------
def test_invitation_crud(db_path: Path) -> None:
"""Create invitation, list by email, update status."""
asyncio.run(insert_event(_make_event(), db_path))
inv = Invitation(
id="inv-1",
event_id="evt-1",
inviter_user_id="user-1",
invitee_email="alice@example.com",
)
asyncio.run(insert_invitation(inv, db_path))
invs = asyncio.run(list_invitations("alice@example.com", db_path))
assert len(invs) == 1
assert invs[0].status == "pending"
now = _now_iso()
asyncio.run(update_invitation_status("inv-1", "accepted", now, db_path))
invs = asyncio.run(list_invitations("alice@example.com", db_path))
assert invs[0].status == "accepted"
assert invs[0].responded_at == now

View File

@ -0,0 +1,75 @@
"""Tests for RRULE recurrence expansion (U1)."""
from __future__ import annotations
from agentkit.calendar.recurrence import expand_rrule
def test_expand_rrule_weekly_count() -> None:
"""FREQ=WEEKLY;BYDAY=MO;COUNT=4 from Monday → 4 occurrences."""
result = expand_rrule(
"FREQ=WEEKLY;BYDAY=MO;COUNT=4",
"2026-07-06T10:00:00+00:00", # Monday
)
assert len(result) == 4
# All should be Mondays
for occ in result:
# 2026-07-06 is Monday, 07-13, 07-20, 07-27
assert "T10:00:00+00:00" in occ
def test_expand_rrule_daily_range_filter() -> None:
"""FREQ=DAILY starting Jan 1, range Jan 3Jan 5 → 3 occurrences."""
result = expand_rrule(
"FREQ=DAILY",
"2026-01-01T00:00:00+00:00",
range_start="2026-01-03T00:00:00+00:00",
range_end="2026-01-06T00:00:00+00:00",
)
assert len(result) == 3 # Jan 3, 4, 5
def test_expand_rrule_until_clause() -> None:
"""FREQ=DAILY;UNTIL=20260131 → occurrences stop at Jan 31."""
result = expand_rrule(
"FREQ=DAILY;UNTIL=20260131T235959Z",
"2026-01-29T00:00:00+00:00",
)
assert len(result) == 3 # Jan 29, 30, 31
def test_expand_rrule_no_rrule_returns_single() -> None:
"""rrule=None → returns [start_time] only."""
result = expand_rrule(None, "2026-07-01T10:00:00+00:00")
assert result == ["2026-07-01T10:00:00+00:00"]
result = expand_rrule("", "2026-07-01T10:00:00+00:00")
assert result == ["2026-07-01T10:00:00+00:00"]
def test_expand_rrule_all_day_event() -> None:
"""All-day event with RRULE, verify date expansion (no time component issues)."""
result = expand_rrule(
"FREQ=DAILY;COUNT=3",
"2026-07-01T00:00:00+00:00",
)
assert len(result) == 3
assert result[0].startswith("2026-07-01")
assert result[1].startswith("2026-07-02")
assert result[2].startswith("2026-07-03")
def test_expand_rrule_dst_boundary() -> None:
"""Event crossing DST transition, verify UTC consistency.
March 8, 2026 is US DST spring-forward. A daily event starting
March 7 should produce correct UTC occurrences across the boundary.
"""
result = expand_rrule(
"FREQ=DAILY;COUNT=3",
"2026-03-07T10:00:00+00:00",
)
assert len(result) == 3
# All should be at 10:00 UTC regardless of DST
for occ in result:
assert "T10:00:00+00:00" in occ