4c58603009
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>
149 lines
4.8 KiB
Python
149 lines
4.8 KiB
Python
"""Calendar events REST API."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from quart import Blueprint, g, jsonify, request
|
|
|
|
from fabledassistant.auth import login_required
|
|
import fabledassistant.services.events as events_svc
|
|
|
|
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
|
|
|
|
|
|
def _parse_dt(value: str) -> datetime:
|
|
"""Parse ISO 8601 datetime string, ensuring UTC-awareness."""
|
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt
|
|
|
|
|
|
def _get_current_user_id() -> int:
|
|
return g.user.id
|
|
|
|
|
|
@events_bp.get("")
|
|
@login_required
|
|
async def list_events():
|
|
date_from_str = request.args.get("from")
|
|
date_to_str = request.args.get("to")
|
|
if not date_from_str or not date_to_str:
|
|
return jsonify({"error": "from and to query params are required"}), 400
|
|
try:
|
|
date_from = _parse_dt(date_from_str)
|
|
date_to = _parse_dt(date_to_str)
|
|
except ValueError:
|
|
return jsonify({"error": "Invalid datetime format"}), 400
|
|
events = await events_svc.list_events(
|
|
user_id=_get_current_user_id(),
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
)
|
|
return jsonify(events)
|
|
|
|
|
|
@events_bp.post("")
|
|
@login_required
|
|
async def create_event():
|
|
data = await request.get_json() or {}
|
|
if not data.get("title") or not data.get("start_dt"):
|
|
return jsonify({"error": "title and start_dt are required"}), 400
|
|
try:
|
|
start_dt = _parse_dt(data["start_dt"])
|
|
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
|
|
except ValueError:
|
|
return jsonify({"error": "Invalid datetime format"}), 400
|
|
try:
|
|
event = await events_svc.create_event(
|
|
user_id=_get_current_user_id(),
|
|
title=data["title"],
|
|
start_dt=start_dt,
|
|
end_dt=end_dt,
|
|
duration_minutes=data.get("duration_minutes"),
|
|
all_day=data.get("all_day", False),
|
|
description=data.get("description", ""),
|
|
location=data.get("location", ""),
|
|
color=data.get("color", ""),
|
|
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
|
|
|
|
|
|
@events_bp.get("/<int:event_id>")
|
|
@login_required
|
|
async def get_event(event_id: int):
|
|
event = await events_svc.get_event(
|
|
user_id=_get_current_user_id(),
|
|
event_id=event_id,
|
|
)
|
|
if event is None:
|
|
return jsonify({"error": "Event not found"}), 404
|
|
return jsonify(event.to_dict())
|
|
|
|
|
|
@events_bp.patch("/<int:event_id>")
|
|
@login_required
|
|
async def update_event(event_id: int):
|
|
data = await request.get_json() or {}
|
|
fields: dict = {}
|
|
for str_field in ("title", "description", "location", "color", "recurrence"):
|
|
if str_field in data:
|
|
fields[str_field] = data[str_field]
|
|
for bool_field in ("all_day",):
|
|
if bool_field in data:
|
|
fields[bool_field] = data[bool_field]
|
|
for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
|
|
if int_field in data:
|
|
fields[int_field] = data[int_field]
|
|
for dt_field in ("start_dt", "end_dt"):
|
|
if dt_field in data:
|
|
if data[dt_field] is None:
|
|
# Explicit null clears the field (e.g. removing end_dt)
|
|
fields[dt_field] = None
|
|
elif data[dt_field]:
|
|
try:
|
|
fields[dt_field] = _parse_dt(data[dt_field])
|
|
except ValueError:
|
|
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
|
try:
|
|
event = await events_svc.update_event(
|
|
user_id=_get_current_user_id(),
|
|
event_id=event_id,
|
|
**fields,
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
if event is None:
|
|
return jsonify({"error": "Event not found"}), 404
|
|
return jsonify(event.to_dict())
|
|
|
|
|
|
@events_bp.delete("/<int:event_id>")
|
|
@login_required
|
|
async def delete_event(event_id: int):
|
|
event = await events_svc.get_event(
|
|
user_id=_get_current_user_id(),
|
|
event_id=event_id,
|
|
)
|
|
if event is None:
|
|
return jsonify({"error": "Event not found"}), 404
|
|
await events_svc.delete_event(
|
|
user_id=_get_current_user_id(),
|
|
event_id=event_id,
|
|
)
|
|
return "", 204
|
|
|
|
|
|
@events_bp.post("/sync")
|
|
@login_required
|
|
async def sync_caldav():
|
|
"""Trigger a CalDAV pull sync for the current user."""
|
|
from fabledassistant.services.caldav_sync import sync_user_events
|
|
result = await sync_user_events(user_id=_get_current_user_id())
|
|
return jsonify(result)
|