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:
parent
3337589395
commit
2ea799f6c4
|
|
@ -24,6 +24,8 @@ dependencies = [
|
|||
"pyjwt>=2.8",
|
||||
"bcrypt>=4.0",
|
||||
"aiosqlite>=0.20",
|
||||
# Calendar & schedule (RRULE expansion)
|
||||
"python-dateutil>=2.9",
|
||||
# Document processing (U1-U9)
|
||||
"python-docx>=1.1",
|
||||
"openpyxl>=3.1",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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 3–Jan 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
|
||||
Loading…
Reference in New Issue