feat(events): replace end_dt column with duration_minutes (#160)

Structural fix for the "end before start" bug class observed on prod
2026-04-29. Bad data became inexpressible at the schema level instead
of getting trapped in defensive read-path filters.

The hotfix that landed earlier today (94b169f) is reverted by the
preceding revert commit; this commit supersedes it cleanly with a
proper data-model change.

## Schema (migration 0043)

- Add `duration_minutes INTEGER NULLABLE` column on `events`.
- CHECK constraint: ``duration_minutes IS NULL OR duration_minutes >= 0``.
- Backfill from existing `end_dt`:
    - end_dt valid (end > start) → duration_minutes = total minutes
    - end_dt == start → duration_minutes = 0 (zero-duration point)
    - end_dt NULL or end_dt < start → duration_minutes = NULL
      (the corrupt prod row collapses cleanly to a point event)
- Drop the `end_dt` column. The wire format is preserved — `to_dict()`
  emits `end_dt` as a derived `start_dt + duration_minutes`. Existing
  API consumers (Flutter app, web frontend, CalDAV sync) keep
  receiving the same response shape; they just no longer have a way
  to PUT a stored `end_dt` that disagrees with `start_dt`.

## Service layer

- `Event.end_dt` becomes a `@property`. Setting it would require a
  setter we deliberately don't define — writes always go through
  `duration_minutes`.
- `_normalize_duration` is the single source-of-truth for input
  reduction. Accepts (start, end_dt, duration_minutes), returns the
  canonical `duration_minutes`, raises `ValueError` for negative
  durations, end-before-start, or end/duration disagreement.
- `create_event` and `update_event` accept either `end_dt` or
  `duration_minutes` for ergonomic compat; both convert via
  `_normalize_duration`. Update validates the post-update state when
  the patch includes either.
- `list_events` filter is simpler now: a coarse SQL prefilter
  (`start_dt <= date_to`) plus Python-side refinement using the
  derived `end_dt`. Avoids Postgres-specific interval arithmetic in
  the WHERE clause; refinement runs over a per-user result set so
  there's no scan-cost concern at personal scale.
- Recurring-event expansion uses `event.duration_minutes` directly
  instead of computing `end - start`. No more negative-timedelta
  hazard.

## CalDAV sync (incoming + outgoing)

- `caldav_sync.py` (pull) and `calendar_sync.py` (Radicale upsert)
  both convert iCal `DTEND` → `duration_minutes` on the way in.
  Outbound iCal still emits `DTEND` as `start_dt + duration_minutes`
  via the model's derived property. iCal interop is unchanged.

## Behavioral upgrade for `update_event`

Pure end_dt model: moving start past the existing end_dt would either
silently corrupt or hard-reject. Duration model: the duration is
preserved by default, so moving start slides the effective end
forward — which is what users mean when they "move" an event.
Explicit clear is still possible via `end_dt=None`.

## Tests

`tests/test_events_service.py`:
- 6 new `_normalize_duration` unit tests (sugar conversion, zero
  duration valid as point event, end-before-start rejected, negative
  duration rejected, inconsistent end+duration rejected, none → None)
- New behavioral test: `update_event` preserves duration when only
  start_dt changes (sliding semantics)
- New: clearing `end_dt=None` on update collapses to point event
- New: list_events surfaces a point event in the upcoming window
- New: list_events excludes a timed event whose effective end has
  already passed
- Existing mock-event helper updated to use `duration_minutes`
  instead of stored `end_dt`.

44 event-related tests pass; ruff clean.

## Out of scope (separate task)

Fable #161 — `find_events_by_query` returning multiple matches and
silently picking matches[0]. The exact root cause of how event id=2
got mutated in the first place; orthogonal to the storage model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 14:19:44 -04:00
parent b7e7073425
commit 4c58603009
7 changed files with 517 additions and 90 deletions
@@ -0,0 +1,73 @@
"""replace events.end_dt with duration_minutes (Fable #160)
Revision ID: 0043
Revises: 0042
Create Date: 2026-04-29
Structural fix for the "end before start" bug class observed on prod
2026-04-29: an event landed with end_dt 32 days before start_dt due
to a tool-call mishap, then disappeared from upcoming-list filters.
Storing duration instead of end_dt makes the invalid state
inexpressible at the schema level (duration_minutes >= 0).
Backfill rules:
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
or open-ended; treated as a point event from here on)
Existing API consumers continue to receive `end_dt` in responses — the
field is now derived from `start_dt + duration_minutes` in
``Event.to_dict()`` rather than stored.
"""
from alembic import op
import sqlalchemy as sa
revision = "0043"
down_revision = "0042"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"events",
sa.Column("duration_minutes", sa.Integer(), nullable=True),
)
op.create_check_constraint(
"events_duration_minutes_non_negative",
"events",
"duration_minutes IS NULL OR duration_minutes >= 0",
)
# Backfill: convert valid end_dt into a minute count; leave NULL for
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
# cleanly to point events instead of forcing a recovery guess.
op.execute(
"""
UPDATE events
SET duration_minutes = CAST(
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
)
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
"""
)
op.drop_column("events", "end_dt")
def downgrade() -> None:
op.add_column(
"events",
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
)
# Restore end_dt = start_dt + duration_minutes minutes for rows that
# had a duration. NULL duration → NULL end_dt (point event).
op.execute(
"""
UPDATE events
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
WHERE duration_minutes IS NOT NULL
"""
)
op.drop_constraint("events_duration_minutes_non_negative", "events")
op.drop_column("events", "duration_minutes")
+22 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -20,7 +20,11 @@ class Event(Base):
uid: Mapped[str] = mapped_column(Text) uid: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True)) start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Duration in minutes; NULL = point event with no end specified.
# Replaces the prior `end_dt` column (Fable #160 / migration 0043).
# The DB has a CHECK constraint that this is NULL or >= 0, so an
# event whose end is before its start is structurally inexpressible.
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False) all_day: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(Text, default="") location: Mapped[str] = mapped_column(Text, default="")
@@ -38,7 +42,21 @@ class Event(Base):
onupdate=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),
) )
@property
def end_dt(self) -> datetime | None:
"""Derived end datetime: ``start_dt + duration_minutes``.
Returns ``None`` for point events (``duration_minutes is None``).
Computed at access time rather than stored — a stored end was
the source of the "end before start" corruption that motivated
this redesign.
"""
if self.duration_minutes is None:
return None
return self.start_dt + timedelta(minutes=self.duration_minutes)
def to_dict(self) -> dict: def to_dict(self) -> dict:
end_dt = self.end_dt
return { return {
"id": self.id, "id": self.id,
"user_id": self.user_id, "user_id": self.user_id,
@@ -47,7 +65,8 @@ class Event(Base):
"project_id": self.project_id, "project_id": self.project_id,
"title": self.title, "title": self.title,
"start_dt": self.start_dt.isoformat() if self.start_dt else None, "start_dt": self.start_dt.isoformat() if self.start_dt else None,
"end_dt": self.end_dt.isoformat() if self.end_dt else None, "end_dt": end_dt.isoformat() if end_dt else None,
"duration_minutes": self.duration_minutes,
"all_day": self.all_day, "all_day": self.all_day,
"description": self.description, "description": self.description,
"location": self.location, "location": self.location,
+26 -19
View File
@@ -54,19 +54,23 @@ async def create_event():
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
except ValueError: except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400 return jsonify({"error": "Invalid datetime format"}), 400
event = await events_svc.create_event( try:
user_id=_get_current_user_id(), event = await events_svc.create_event(
title=data["title"], user_id=_get_current_user_id(),
start_dt=start_dt, title=data["title"],
end_dt=end_dt, start_dt=start_dt,
all_day=data.get("all_day", False), end_dt=end_dt,
description=data.get("description", ""), duration_minutes=data.get("duration_minutes"),
location=data.get("location", ""), all_day=data.get("all_day", False),
color=data.get("color", ""), description=data.get("description", ""),
recurrence=data.get("recurrence"), location=data.get("location", ""),
project_id=data.get("project_id"), color=data.get("color", ""),
reminder_minutes=data.get("reminder_minutes"), recurrence=data.get("recurrence"),
) project_id=data.get("project_id"),
reminder_minutes=data.get("reminder_minutes"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(event.to_dict()), 201 return jsonify(event.to_dict()), 201
@@ -93,7 +97,7 @@ async def update_event(event_id: int):
for bool_field in ("all_day",): for bool_field in ("all_day",):
if bool_field in data: if bool_field in data:
fields[bool_field] = data[bool_field] fields[bool_field] = data[bool_field]
for int_field in ("project_id", "reminder_minutes"): for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
if int_field in data: if int_field in data:
fields[int_field] = data[int_field] fields[int_field] = data[int_field]
for dt_field in ("start_dt", "end_dt"): for dt_field in ("start_dt", "end_dt"):
@@ -106,11 +110,14 @@ async def update_event(event_id: int):
fields[dt_field] = _parse_dt(data[dt_field]) fields[dt_field] = _parse_dt(data[dt_field])
except ValueError: except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400 return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
event = await events_svc.update_event( try:
user_id=_get_current_user_id(), event = await events_svc.update_event(
event_id=event_id, user_id=_get_current_user_id(),
**fields, event_id=event_id,
) **fields,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if event is None: if event is None:
return jsonify({"error": "Event not found"}), 404 return jsonify({"error": "Event not found"}), 404
return jsonify(event.to_dict()) return jsonify(event.to_dict())
+14 -3
View File
@@ -130,6 +130,17 @@ async def sync_user_events(user_id: int) -> dict:
async with async_session() as session: async with async_session() as session:
for ev in remote_events: for ev in remote_events:
caldav_uid = ev["caldav_uid"] caldav_uid = ev["caldav_uid"]
# Storage uses duration, not end_dt. Convert here so the
# rest of this function can compare/upsert in one shape.
ev_start = ev["start_dt"]
ev_end = ev["end_dt"]
ev_duration = (
int((ev_end - ev_start).total_seconds() // 60)
if ev_end is not None and ev_start is not None and ev_end > ev_start
else None
)
ev["duration_minutes"] = ev_duration
result = await session.execute( result = await session.execute(
select(Event).where( select(Event).where(
Event.user_id == user_id, Event.user_id == user_id,
@@ -145,8 +156,8 @@ async def sync_user_events(user_id: int) -> dict:
uid=str(uuid.uuid4()), uid=str(uuid.uuid4()),
caldav_uid=caldav_uid, caldav_uid=caldav_uid,
title=ev["title"], title=ev["title"],
start_dt=ev["start_dt"], start_dt=ev_start,
end_dt=ev["end_dt"], duration_minutes=ev_duration,
all_day=ev["all_day"], all_day=ev["all_day"],
description=ev["description"], description=ev["description"],
location=ev["location"], location=ev["location"],
@@ -157,7 +168,7 @@ async def sync_user_events(user_id: int) -> dict:
else: else:
# Update if anything changed # Update if anything changed
changed = False changed = False
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"): for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
if getattr(existing, field) != ev[field]: if getattr(existing, field) != ev[field]:
setattr(existing, field, ev[field]) setattr(existing, field, ev[field])
changed = True changed = True
+10 -2
View File
@@ -207,6 +207,14 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
d = dtend.dt d = dtend.dt
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc) end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
# Storage uses duration, not end_dt. Convert iCal DTEND to a
# minute count anchored on DTSTART. Treat invalid (end <= start)
# incoming data as a point event rather than rejecting; we
# don't control external CalDAV writers.
duration_minutes = None
if end_dt is not None and end_dt > start_dt:
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid) select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
@@ -215,7 +223,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
if existing: if existing:
existing.title = title existing.title = title
existing.start_dt = start_dt existing.start_dt = start_dt
existing.end_dt = end_dt existing.duration_minutes = duration_minutes
existing.all_day = all_day existing.all_day = all_day
existing.description = description existing.description = description
existing.location = location existing.location = location
@@ -230,7 +238,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
uid=ical_uid, uid=ical_uid,
title=title, title=title,
start_dt=start_dt, start_dt=start_dt,
end_dt=end_dt, duration_minutes=duration_minutes,
all_day=all_day, all_day=all_day,
description=description, description=description,
location=location, location=location,
+171 -59
View File
@@ -1,4 +1,13 @@
"""Internal event store service with CalDAV push sync.""" """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 from __future__ import annotations
import asyncio import asyncio
@@ -7,7 +16,7 @@ import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from dateutil.rrule import rrulestr from dateutil.rrule import rrulestr
from sqlalchemy import and_, or_, select from sqlalchemy import or_, select
from fabledassistant.models import async_session from fabledassistant.models import async_session
from fabledassistant.models.event import Event from fabledassistant.models.event import Event
@@ -15,11 +24,56 @@ from fabledassistant.models.event import Event
logger = logging.getLogger(__name__) 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 create_event( async def create_event(
user_id: int, user_id: int,
title: str, title: str,
start_dt: datetime, start_dt: datetime,
end_dt: datetime | None = None, end_dt: datetime | None = None,
duration_minutes: int | None = None,
all_day: bool = False, all_day: bool = False,
description: str = "", description: str = "",
location: str = "", location: str = "",
@@ -27,12 +81,25 @@ async def create_event(
recurrence: str | None = None, recurrence: str | None = None,
project_id: int | None = None, project_id: int | None = None,
reminder_minutes: int | None = None, reminder_minutes: int | None = None,
# CalDAV-only fields (not stored in DB, forwarded to push) # ``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, duration: int | None = None,
attendees: list[str] | None = None, attendees: list[str] | None = None,
calendar_name: str | None = None, calendar_name: str | None = None,
) -> Event: ) -> Event:
"""Create an event in the DB, then fire a CalDAV push task.""" """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
duration_minutes = _normalize_duration(
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
)
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
async with async_session() as session: async with async_session() as session:
event = Event( event = Event(
@@ -40,7 +107,7 @@ async def create_event(
uid=uid, uid=uid,
title=title, title=title,
start_dt=start_dt, start_dt=start_dt,
end_dt=end_dt, duration_minutes=duration_minutes,
all_day=all_day, all_day=all_day,
description=description, description=description,
location=location, location=location,
@@ -54,7 +121,7 @@ async def create_event(
await session.refresh(event) await session.refresh(event)
extra_fields = { extra_fields = {
"duration": duration, "duration": duration_minutes,
"reminder_minutes": reminder_minutes, "reminder_minutes": reminder_minutes,
"attendees": attendees, "attendees": attendees,
"calendar_name": calendar_name, "calendar_name": calendar_name,
@@ -80,66 +147,74 @@ async def list_events(
"""List events for user_id that overlap [date_from, date_to]. """List events for user_id that overlap [date_from, date_to].
Recurring events (with an RRULE recurrence string) are expanded into Recurring events (with an RRULE recurrence string) are expanded into
individual occurrences within the range. Non-recurring events are individual occurrences within the range. Non-recurring events are
returned as-is. All results are sorted by start time and returned as returned as-is. All results are sorted by start time and returned as
dicts (same shape as Event.to_dict()). 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: async with async_session() as session:
# Match strategy:
# - Recurring events: fetch all, expand via rrule below.
# - Non-recurring with an end_dt: standard overlap — starts before
# date_to and ends after date_from.
# - Non-recurring with no end_dt: treat as a point event at
# start_dt, include only if start_dt falls within the window.
# (Previously this branch matched any event with a null end_dt,
# returning all past events as "happening today".)
result = await session.execute( result = await session.execute(
select(Event).where( select(Event)
.where(
Event.user_id == user_id, Event.user_id == user_id,
or_( or_(
Event.recurrence.isnot(None), Event.recurrence.isnot(None),
and_( Event.start_dt <= date_to,
Event.recurrence.is_(None),
Event.start_dt <= date_to,
or_(
Event.end_dt >= date_from,
and_(
Event.end_dt.is_(None),
Event.start_dt >= date_from,
),
),
),
), ),
).order_by(Event.start_dt) )
.order_by(Event.start_dt)
) )
events = list(result.scalars().all()) events = list(result.scalars().all())
items: list[dict] = [] items: list[dict] = []
for event in events: for event in events:
if not event.recurrence: if event.recurrence:
items.append(event.to_dict()) 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 continue
# Expand recurring event occurrences within [date_from, date_to] # Non-recurring: refine the coarse prefilter in Python using the
duration = (event.end_dt - event.start_dt) if event.end_dt else None # derived end_dt. A point event (duration None) is included when
try: # its start is at or after date_from. A timed event is included
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False) # when its end is at or after date_from.
occurrences = rule.between(date_from, date_to, inc=True) derived_end = event.end_dt
except Exception: if derived_end is None:
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence) if event.start_dt >= date_from:
items.append(event.to_dict()) items.append(event.to_dict())
continue else:
if derived_end >= date_from:
base = event.to_dict() items.append(event.to_dict())
for occ in occurrences:
# Ensure occurrence is UTC-aware
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)
items.sort(key=lambda x: x["start_dt"]) items.sort(key=lambda x: x["start_dt"])
return items return items
@@ -173,7 +248,13 @@ async def search_events(
async def update_event(user_id: int, event_id: int, **fields) -> Event | None: async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
"""Partial update. Returns updated event or None if not found.""" """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: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Event).where(Event.id == event_id, Event.user_id == user_id) select(Event).where(Event.id == event_id, Event.user_id == user_id)
@@ -182,10 +263,39 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
if event is None: if event is None:
return None return None
old_title = event.title # capture before mutation for CalDAV lookup old_title = event.title # capture before mutation for CalDAV lookup
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
"location", "color", "recurrence", "project_id", "reminder_minutes"} # Resolve any end_dt/duration_minutes inputs against the
# Nullable fields that callers can explicitly set to None to clear # post-update start_dt. If neither is in the patch, leave the
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"} # 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(): for key, value in fields.items():
if key in allowed and (value is not None or key in nullable): if key in allowed and (value is not None or key in nullable):
setattr(event, key, value) setattr(event, key, value)
@@ -255,11 +365,12 @@ async def _push_create(event: Event, user_id: int, extra: dict) -> None:
) )
if not await is_caldav_configured(user_id): if not await is_caldav_configured(user_id):
return return
derived_end = event.end_dt # property: start + duration_minutes
await caldav_create( await caldav_create(
user_id=user_id, user_id=user_id,
title=event.title, title=event.title,
start=event.start_dt.isoformat(), start=event.start_dt.isoformat(),
end=event.end_dt.isoformat() if event.end_dt else None, end=derived_end.isoformat() if derived_end else None,
description=event.description or None, description=event.description or None,
location=event.location or None, location=event.location or None,
all_day=event.all_day, all_day=event.all_day,
@@ -296,12 +407,13 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
return return
# Use old_title so CalDAV can find the event even if the title was changed # Use old_title so CalDAV can find the event even if the title was changed
query_title = old_title or event.title query_title = old_title or event.title
derived_end = event.end_dt
await caldav_update( await caldav_update(
user_id=user_id, user_id=user_id,
query=query_title, query=query_title,
title=event.title, title=event.title,
start=event.start_dt.isoformat(), start=event.start_dt.isoformat(),
end=event.end_dt.isoformat() if event.end_dt else None, end=derived_end.isoformat() if derived_end else None,
description=event.description or None, description=event.description or None,
location=event.location or None, location=event.location or None,
) )
+201 -4
View File
@@ -14,7 +14,7 @@ def _make_mock_session():
def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting", def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
caldav_uid="", color=""): caldav_uid="", color="", duration_minutes=60):
e = MagicMock() e = MagicMock()
e.id = id e.id = id
e.user_id = user_id e.user_id = user_id
@@ -23,7 +23,14 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
e.caldav_uid = caldav_uid e.caldav_uid = caldav_uid
e.color = color e.color = color
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc) e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
e.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc) e.duration_minutes = duration_minutes
# end_dt is derived; mirror the property's behavior on the mock so
# service code that reads `event.end_dt` gets a sensible value.
if duration_minutes is None:
e.end_dt = None
else:
from datetime import timedelta
e.end_dt = e.start_dt + timedelta(minutes=duration_minutes)
e.all_day = False e.all_day = False
e.description = "" e.description = ""
e.location = "" e.location = ""
@@ -32,8 +39,9 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
e.to_dict.return_value = { e.to_dict.return_value = {
"id": id, "uid": uid, "title": title, "id": id, "uid": uid, "title": title,
"caldav_uid": caldav_uid, "color": color, "caldav_uid": caldav_uid, "color": color,
"start_dt": datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc).isoformat(), "start_dt": e.start_dt.isoformat(),
"end_dt": datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc).isoformat(), "end_dt": e.end_dt.isoformat() if e.end_dt else None,
"duration_minutes": duration_minutes,
} }
return e return e
@@ -124,6 +132,195 @@ async def test_update_event_fires_caldav_push():
assert mock_task.called assert mock_task.called
# ── Duration-model write-side guarantees (Fable #160) ─────────────────────────
def test_normalize_duration_from_end_dt():
"""end_dt sugar converts to a positive minute count anchored on start."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc)
assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90
def test_normalize_duration_zero_is_valid_point_event():
"""end_dt == start_dt → duration 0. The point-with-zero-duration case
is rare but legal (e.g. an instant marker); the duration model treats
it the same as duration None for display purposes."""
from fabledassistant.services.events import _normalize_duration
same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0
def test_normalize_duration_rejects_end_before_start():
"""The exact 2026-04-29 prod failure: end 32 days before start.
The duration model makes this inexpressible at the schema level
via a CHECK constraint, but write-path callers still get a
helpful ValueError if they construct an inconsistent (start, end)
pair via the end_dt sugar."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="at or after start_dt"):
_normalize_duration(
start_dt=start, end_dt=end_before, duration_minutes=None,
)
def test_normalize_duration_rejects_negative_duration():
"""Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK
constraint at the service boundary so callers get a clean error
rather than a constraint violation from psycopg."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="must be >= 0"):
_normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15)
def test_normalize_duration_rejects_inconsistent_end_and_duration():
"""If a caller passes both end_dt AND duration_minutes that disagree,
the inconsistency is surfaced rather than silently picking one."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min
with pytest.raises(ValueError, match="implies 60 minutes"):
_normalize_duration(
start_dt=start, end_dt=end, duration_minutes=30,
)
def test_normalize_duration_none_for_open_ended():
"""Both inputs None → None duration (open-ended event)."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
assert _normalize_duration(
start_dt=start, end_dt=None, duration_minutes=None,
) is None
@pytest.mark.asyncio
async def test_create_event_rejects_end_before_start():
"""Service-level rejection — same scenario as the prod bug, surfaced
cleanly for tool / route callers via ValueError."""
from fabledassistant.services.events import create_event
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="at or after start_dt"):
await create_event(
user_id=1, title="Bad",
start_dt=start, end_dt=end_before,
)
@pytest.mark.asyncio
async def test_update_event_preserves_duration_when_only_start_changes():
"""Sliding semantics: when the user moves an event by changing only
start_dt, the existing duration_minutes is preserved as-is. The new
effective end_dt slides forward with the start. This is a behavioral
upgrade vs. the old end_dt model, where moving start past the
stored end made the event 'go backward in time'."""
mock_event = _make_mock_event(duration_minutes=60) # start 10:00, end 11:00
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls, \
patch("fabledassistant.services.events.asyncio.create_task"):
mock_cls.return_value = mock_session
from fabledassistant.services.events import update_event
# Move start to 12:00; effective end becomes 13:00 automatically.
result = await update_event(
user_id=1, event_id=1,
start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
)
assert result is not None
# duration_minutes was NOT touched; mock_event still has 60.
assert mock_event.duration_minutes == 60
@pytest.mark.asyncio
async def test_update_event_clearing_end_dt_clears_duration():
"""Passing end_dt=None on update is the documented way to clear the
end (turn a timed event into a point event). The service must
translate that into duration_minutes=None, not leave the prior
value in place."""
mock_event = _make_mock_event(duration_minutes=60)
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls, \
patch("fabledassistant.services.events.asyncio.create_task"):
mock_cls.return_value = mock_session
from fabledassistant.services.events import update_event
await update_event(user_id=1, event_id=1, end_dt=None)
assert mock_event.duration_minutes is None
@pytest.mark.asyncio
async def test_list_events_includes_point_event_in_window():
"""A point event (duration_minutes=None) surfaces when its start
is in the window. Replaces the prior 'corrupt end_dt' regression
test — the duration model can't represent that state, but the
same code path is exercised here for point events."""
mock_event = _make_mock_event(duration_minutes=None)
# Point event in the upcoming window
mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = None
mock_event.to_dict.return_value = {
"id": 1, "title": "Point",
"start_dt": mock_event.start_dt.isoformat(),
"end_dt": None,
"duration_minutes": None,
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
assert len(results) == 1
assert results[0]["id"] == 1
@pytest.mark.asyncio
async def test_list_events_excludes_timed_event_that_already_ended():
"""A timed event whose start + duration is before the window must
NOT surface. Verifies the Python-side refinement actually works
against the coarse SQL prefilter."""
mock_event = _make_mock_event(duration_minutes=60)
# Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past.
mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc)
mock_event.to_dict.return_value = {
"id": 1, "start_dt": mock_event.start_dt.isoformat(),
"end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60,
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
assert results == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tools_calendar_always_available(): async def test_tools_calendar_always_available():
"""Calendar tools must appear in get_tools_for_user even without CalDAV.""" """Calendar tools must appear in get_tools_for_user even without CalDAV."""