Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
478 lines
18 KiB
Python
478 lines
18 KiB
Python
"""Internal event store service with CalDAV push sync.
|
|
|
|
Storage model: an event is anchored at ``start_dt`` and has an optional
|
|
``duration_minutes``. The end of the event is *derived* via
|
|
``Event.end_dt`` (a Python property), never stored. Callers may still
|
|
pass ``end_dt`` on writes for ergonomic compatibility — the service
|
|
converts to ``duration_minutes`` internally. This rules out the entire
|
|
"end before start" bug class structurally (Fable #160 / migration
|
|
0043). Open-ended events use ``duration_minutes = None``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from dateutil.rrule import rrulestr
|
|
from sqlalchemy import or_, select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.event import Event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _normalize_duration(
|
|
*,
|
|
start_dt: datetime,
|
|
end_dt: datetime | None,
|
|
duration_minutes: int | None,
|
|
) -> int | None:
|
|
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
|
|
``duration_minutes`` value.
|
|
|
|
Resolution order:
|
|
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
|
|
If ``end_dt`` is also given, validate the two agree.
|
|
2. Otherwise, derive from ``end_dt - start_dt``.
|
|
3. Otherwise None (point event with no end).
|
|
|
|
Raises ``ValueError`` for any invalid combination — duration < 0,
|
|
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
|
|
"""
|
|
if duration_minutes is not None:
|
|
if duration_minutes < 0:
|
|
raise ValueError(
|
|
f"duration_minutes must be >= 0, got {duration_minutes}"
|
|
)
|
|
if end_dt is not None:
|
|
expected = int((end_dt - start_dt).total_seconds() // 60)
|
|
if expected != duration_minutes:
|
|
raise ValueError(
|
|
f"end_dt ({end_dt.isoformat()}) implies "
|
|
f"{expected} minutes but duration_minutes={duration_minutes} "
|
|
f"was passed; pass only one or make them agree."
|
|
)
|
|
return duration_minutes
|
|
if end_dt is not None:
|
|
delta_seconds = (end_dt - start_dt).total_seconds()
|
|
if delta_seconds < 0:
|
|
raise ValueError(
|
|
f"end_dt ({end_dt.isoformat()}) must be at or after "
|
|
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
|
|
f"or omit it for point events."
|
|
)
|
|
return int(delta_seconds // 60)
|
|
return None
|
|
|
|
|
|
async def _localize_naive(user_id: int, dt: datetime | None) -> datetime | None:
|
|
"""Anchor a naive datetime in the user's timezone; pass tz-aware through.
|
|
|
|
Naive datetimes are the user's local wall-clock time (the MCP create/update
|
|
tools combine date+time without a zone). Attaching the user's tzinfo lets
|
|
asyncpg store the correct UTC instant, matching the REST/UI path.
|
|
"""
|
|
if dt is not None and dt.tzinfo is None:
|
|
from scribe.services.tz import get_user_tz # noqa: PLC0415
|
|
return dt.replace(tzinfo=await get_user_tz(user_id))
|
|
return dt
|
|
|
|
|
|
async def create_event(
|
|
user_id: int,
|
|
title: str,
|
|
start_dt: datetime,
|
|
end_dt: datetime | None = None,
|
|
duration_minutes: int | None = None,
|
|
all_day: bool = False,
|
|
description: str = "",
|
|
location: str = "",
|
|
color: str = "",
|
|
recurrence: str | None = None,
|
|
project_id: int | None = None,
|
|
reminder_minutes: int | None = None,
|
|
# ``duration`` is a legacy alias kept for the calendar tool layer
|
|
# and CalDAV pass-through callers; promotes to duration_minutes
|
|
# when duration_minutes isn't otherwise specified.
|
|
duration: int | None = None,
|
|
attendees: list[str] | None = None,
|
|
calendar_name: str | None = None,
|
|
) -> Event:
|
|
"""Create an event in the DB, then fire a CalDAV push task.
|
|
|
|
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
|
|
service converts to ``duration_minutes`` internally. Raises
|
|
``ValueError`` on invalid combinations (negative duration, end
|
|
before start, end/duration disagreement).
|
|
"""
|
|
if duration is not None and duration_minutes is None:
|
|
duration_minutes = duration
|
|
# Canonical localization point: a naive datetime (e.g. from the MCP tool's
|
|
# date+time split) is the user's wall-clock time, so anchor it in their
|
|
# timezone before storage. tz-aware inputs (REST, CalDAV pass-through) are
|
|
# left untouched. Without this, MCP-created events landed at the same
|
|
# wall-clock numerals in UTC and drifted from UI-created ones by the offset.
|
|
start_dt = await _localize_naive(user_id, start_dt)
|
|
end_dt = await _localize_naive(user_id, end_dt)
|
|
duration_minutes = _normalize_duration(
|
|
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
|
|
)
|
|
uid = str(uuid.uuid4())
|
|
async with async_session() as session:
|
|
event = Event(
|
|
user_id=user_id,
|
|
uid=uid,
|
|
title=title,
|
|
start_dt=start_dt,
|
|
duration_minutes=duration_minutes,
|
|
all_day=all_day,
|
|
description=description,
|
|
location=location,
|
|
color=color,
|
|
recurrence=recurrence,
|
|
project_id=project_id,
|
|
reminder_minutes=reminder_minutes,
|
|
)
|
|
session.add(event)
|
|
await session.commit()
|
|
await session.refresh(event)
|
|
|
|
extra_fields = {
|
|
"duration": duration_minutes,
|
|
"reminder_minutes": reminder_minutes,
|
|
"attendees": attendees,
|
|
"calendar_name": calendar_name,
|
|
}
|
|
asyncio.create_task(_push_create(event, user_id, extra_fields))
|
|
return event
|
|
|
|
|
|
async def get_event(user_id: int, event_id: int) -> Event | None:
|
|
"""Return event owned by user_id, or None."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(
|
|
Event.id == event_id, Event.user_id == user_id, Event.deleted_at.is_(None)
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def list_events(
|
|
user_id: int,
|
|
date_from: datetime,
|
|
date_to: datetime,
|
|
) -> list[dict]:
|
|
"""List events for user_id that overlap [date_from, date_to].
|
|
|
|
Recurring events (with an RRULE recurrence string) are expanded into
|
|
individual occurrences within the range. Non-recurring events are
|
|
returned as-is. All results are sorted by start time and returned as
|
|
dicts (same shape as ``Event.to_dict()``).
|
|
|
|
Filtering strategy: a coarse SQL prefilter (events that start on or
|
|
before ``date_to``), then refine in Python using the event's derived
|
|
end (``start_dt + duration_minutes``). Doing the end-of-event math
|
|
in SQL would require Postgres-specific interval arithmetic; the
|
|
Python-side refinement is a few row-loops over a small per-user
|
|
result set, which is fine for personal-scale data and avoids
|
|
coupling the query to a specific dialect.
|
|
"""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event)
|
|
.where(
|
|
Event.user_id == user_id,
|
|
Event.deleted_at.is_(None),
|
|
or_(
|
|
Event.recurrence.isnot(None),
|
|
Event.start_dt <= date_to,
|
|
),
|
|
)
|
|
.order_by(Event.start_dt)
|
|
)
|
|
events = list(result.scalars().all())
|
|
|
|
items: list[dict] = []
|
|
for event in events:
|
|
if event.recurrence:
|
|
duration = (
|
|
timedelta(minutes=event.duration_minutes)
|
|
if event.duration_minutes is not None
|
|
else None
|
|
)
|
|
try:
|
|
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
|
occurrences = rule.between(date_from, date_to, inc=True)
|
|
except Exception:
|
|
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
|
# Fall back to canonical event row; still apply the
|
|
# window check so a far-future canonical row doesn't
|
|
# leak into today's list.
|
|
if date_from <= event.start_dt <= date_to:
|
|
items.append(event.to_dict())
|
|
continue
|
|
|
|
base = event.to_dict()
|
|
for occ in occurrences:
|
|
if occ.tzinfo is None:
|
|
occ = occ.replace(tzinfo=timezone.utc)
|
|
occurrence_dict = dict(base)
|
|
occurrence_dict["start_dt"] = occ.isoformat()
|
|
if duration is not None:
|
|
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
|
items.append(occurrence_dict)
|
|
continue
|
|
|
|
# Non-recurring: refine the coarse prefilter in Python using the
|
|
# derived end_dt. A point event (duration None) is included when
|
|
# its start is at or after date_from. A timed event is included
|
|
# when its end is at or after date_from.
|
|
derived_end = event.end_dt
|
|
if derived_end is None:
|
|
if event.start_dt >= date_from:
|
|
items.append(event.to_dict())
|
|
else:
|
|
if derived_end >= date_from:
|
|
items.append(event.to_dict())
|
|
|
|
items.sort(key=lambda x: x["start_dt"])
|
|
return items
|
|
|
|
|
|
async def search_events(
|
|
user_id: int,
|
|
query: str,
|
|
days_ahead: int = 90,
|
|
include_past: bool = False,
|
|
) -> list[Event]:
|
|
"""Search events by keyword in title, description, or location."""
|
|
now = datetime.now(timezone.utc)
|
|
q = f"%{query}%"
|
|
async with async_session() as session:
|
|
where = [
|
|
Event.user_id == user_id,
|
|
Event.deleted_at.is_(None),
|
|
or_(
|
|
Event.title.ilike(q),
|
|
Event.description.ilike(q),
|
|
Event.location.ilike(q),
|
|
),
|
|
]
|
|
if not include_past:
|
|
date_to = now + timedelta(days=days_ahead)
|
|
where.extend([Event.start_dt >= now, Event.start_dt <= date_to])
|
|
result = await session.execute(
|
|
select(Event).where(*where).order_by(Event.start_dt)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
|
"""Partial update. Returns updated event or None if not found.
|
|
|
|
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
|
|
agreement). The service converts to ``duration_minutes`` before
|
|
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
|
|
invalid combinations against the post-update state.
|
|
"""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(
|
|
Event.id == event_id, Event.user_id == user_id,
|
|
Event.deleted_at.is_(None),
|
|
)
|
|
)
|
|
event = result.scalar_one_or_none()
|
|
if event is None:
|
|
return None
|
|
old_title = event.title # capture before mutation for CalDAV lookup
|
|
|
|
# Localize a naive start_dt patch to the user's timezone (same canonical
|
|
# rule as create_event) before it's used or persisted.
|
|
if fields.get("start_dt") is not None:
|
|
fields["start_dt"] = await _localize_naive(user_id, fields["start_dt"])
|
|
|
|
# Resolve any end_dt/duration_minutes inputs against the
|
|
# post-update start_dt. If neither is in the patch, leave the
|
|
# existing duration_minutes alone.
|
|
post_update_start = (
|
|
fields["start_dt"]
|
|
if fields.get("start_dt") is not None
|
|
else event.start_dt
|
|
)
|
|
if "end_dt" in fields or "duration_minutes" in fields:
|
|
new_end = fields.pop("end_dt", None)
|
|
new_duration = fields.pop("duration_minutes", None)
|
|
# If end_dt is in the patch but explicitly None, that's a
|
|
# clear → duration_minutes = None. Same shape duration_minutes=None.
|
|
if new_end is None and new_duration is None:
|
|
fields["duration_minutes"] = None
|
|
else:
|
|
fields["duration_minutes"] = _normalize_duration(
|
|
start_dt=post_update_start,
|
|
end_dt=new_end,
|
|
duration_minutes=new_duration,
|
|
)
|
|
|
|
allowed = {
|
|
"title", "start_dt", "duration_minutes", "all_day",
|
|
"description", "location", "color", "recurrence",
|
|
"project_id", "reminder_minutes",
|
|
}
|
|
# Nullable fields callers can explicitly clear by passing None
|
|
nullable = {
|
|
"duration_minutes", "recurrence", "project_id",
|
|
"reminder_minutes",
|
|
}
|
|
for key, value in fields.items():
|
|
if key in allowed and (value is not None or key in nullable):
|
|
setattr(event, key, value)
|
|
# Re-arm the reminder when the timing changes, so an event moved to a
|
|
# new (future) time — or given a new lead time — fires again instead of
|
|
# being permanently suppressed by a stale reminder_sent_at.
|
|
if "start_dt" in fields or "reminder_minutes" in fields:
|
|
event.reminder_sent_at = None
|
|
await session.commit()
|
|
await session.refresh(event)
|
|
|
|
asyncio.create_task(_push_update(event, user_id, old_title=old_title))
|
|
return event
|
|
|
|
|
|
async def delete_event(user_id: int, event_id: int) -> None:
|
|
"""Delete event. Fires CalDAV delete push if caldav_uid is set."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
|
)
|
|
event = result.scalar_one_or_none()
|
|
if event is None:
|
|
return
|
|
caldav_uid = event.caldav_uid
|
|
event_title = event.title # needed to find the event on CalDAV by title
|
|
await session.delete(event)
|
|
await session.commit()
|
|
|
|
if caldav_uid:
|
|
asyncio.create_task(_push_delete(caldav_uid, event_title, user_id))
|
|
|
|
|
|
async def find_events_by_query(user_id: int, query: str) -> list[Event]:
|
|
"""ILIKE search on title — used by AI update/delete tools.
|
|
|
|
Returns upcoming events first (start_dt >= now), falling back to
|
|
past events so the AI operates on the most relevant match.
|
|
"""
|
|
q = f"%{query}%"
|
|
now = datetime.now(timezone.utc)
|
|
async with async_session() as session:
|
|
# Prefer events at or after now; fall back to past events
|
|
upcoming = (await session.execute(
|
|
select(Event).where(
|
|
Event.user_id == user_id,
|
|
Event.deleted_at.is_(None),
|
|
Event.title.ilike(q),
|
|
Event.start_dt >= now,
|
|
).order_by(Event.start_dt)
|
|
)).scalars().all()
|
|
if upcoming:
|
|
return list(upcoming)
|
|
past = (await session.execute(
|
|
select(Event).where(
|
|
Event.user_id == user_id,
|
|
Event.deleted_at.is_(None),
|
|
Event.title.ilike(q),
|
|
Event.start_dt < now,
|
|
).order_by(Event.start_dt.desc())
|
|
)).scalars().all()
|
|
return list(past)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CalDAV push helpers (fire-and-forget)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _push_create(event: Event, user_id: int, extra: dict) -> None:
|
|
try:
|
|
from scribe.services.caldav import (
|
|
create_event as caldav_create,
|
|
is_caldav_configured,
|
|
)
|
|
if not await is_caldav_configured(user_id):
|
|
return
|
|
derived_end = event.end_dt # property: start + duration_minutes
|
|
await caldav_create(
|
|
user_id=user_id,
|
|
title=event.title,
|
|
start=event.start_dt.isoformat(),
|
|
end=derived_end.isoformat() if derived_end else None,
|
|
description=event.description or None,
|
|
location=event.location or None,
|
|
all_day=event.all_day,
|
|
recurrence=event.recurrence,
|
|
uid=event.uid,
|
|
duration=extra.get("duration"),
|
|
reminder_minutes=extra.get("reminder_minutes"),
|
|
attendees=extra.get("attendees"),
|
|
calendar_name=extra.get("calendar_name"),
|
|
)
|
|
# Mark as synced
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(Event.id == event.id)
|
|
)
|
|
ev = result.scalar_one_or_none()
|
|
if ev:
|
|
ev.caldav_uid = event.uid
|
|
await session.commit()
|
|
except Exception:
|
|
logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True)
|
|
|
|
|
|
async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
|
"""Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY."""
|
|
if not event.caldav_uid:
|
|
return
|
|
try:
|
|
from scribe.services.caldav import (
|
|
update_event as caldav_update,
|
|
is_caldav_configured,
|
|
)
|
|
if not await is_caldav_configured(user_id):
|
|
return
|
|
# Use old_title so CalDAV can find the event even if the title was changed
|
|
query_title = old_title or event.title
|
|
derived_end = event.end_dt
|
|
await caldav_update(
|
|
user_id=user_id,
|
|
query=query_title,
|
|
title=event.title,
|
|
start=event.start_dt.isoformat(),
|
|
end=derived_end.isoformat() if derived_end else None,
|
|
description=event.description or None,
|
|
location=event.location or None,
|
|
# Propagate the (possibly cleared) RRULE so a local recurrence edit
|
|
# isn't overwritten by the stale remote rule on the next pull.
|
|
recurrence=event.recurrence,
|
|
)
|
|
except Exception:
|
|
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
|
|
|
|
|
|
async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None:
|
|
"""Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY."""
|
|
try:
|
|
from scribe.services.caldav import (
|
|
delete_event as caldav_delete,
|
|
is_caldav_configured,
|
|
)
|
|
if not await is_caldav_configured(user_id):
|
|
return
|
|
await caldav_delete(user_id=user_id, query=event_title)
|
|
except Exception:
|
|
logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True)
|