Revert "fix(events): tolerate corrupt end_dt + reject end<=start at write time"
This reverts commit 94b169f31c.
This commit is contained in:
@@ -54,22 +54,19 @@ async def create_event():
|
||||
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,
|
||||
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
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
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"),
|
||||
)
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
|
||||
@@ -109,14 +106,11 @@ async def update_event(event_id: int):
|
||||
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
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
@@ -32,17 +32,7 @@ async def create_event(
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> Event:
|
||||
"""Create an event in the DB, then fire a CalDAV push task.
|
||||
|
||||
Raises ValueError if `end_dt` is set but not strictly after `start_dt`.
|
||||
Catching this at write time prevents the "end before start" data state
|
||||
that breaks downstream filters (Fable #160).
|
||||
"""
|
||||
if end_dt is not None and end_dt <= start_dt:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) must be after start_dt "
|
||||
f"({start_dt.isoformat()}); pass end_dt=None for point events."
|
||||
)
|
||||
"""Create an event in the DB, then fire a CalDAV push task."""
|
||||
uid = str(uuid.uuid4())
|
||||
async with async_session() as session:
|
||||
event = Event(
|
||||
@@ -97,16 +87,12 @@ async def list_events(
|
||||
async with async_session() as session:
|
||||
# Match strategy:
|
||||
# - Recurring events: fetch all, expand via rrule below.
|
||||
# - Non-recurring with a VALID end_dt (end > start): standard
|
||||
# overlap — starts before date_to and ends after date_from.
|
||||
# - Non-recurring with no end_dt OR an invalid end_dt (end <=
|
||||
# start; almost always a tool-call mishap): treat as a point
|
||||
# event at start_dt and include only if start_dt is in the
|
||||
# window. The fallback for invalid end_dt is critical — without
|
||||
# it, an end_dt accidentally set in the past makes the event
|
||||
# vanish from every list_events call regardless of where its
|
||||
# start_dt actually is. Discovered 2026-04-29; see Fable #160
|
||||
# for the structural fix that replaces end_dt with duration.
|
||||
# - 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(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
@@ -116,16 +102,9 @@ async def list_events(
|
||||
Event.recurrence.is_(None),
|
||||
Event.start_dt <= date_to,
|
||||
or_(
|
||||
Event.end_dt >= date_from,
|
||||
and_(
|
||||
Event.end_dt.isnot(None),
|
||||
Event.end_dt > Event.start_dt,
|
||||
Event.end_dt >= date_from,
|
||||
),
|
||||
and_(
|
||||
or_(
|
||||
Event.end_dt.is_(None),
|
||||
Event.end_dt <= Event.start_dt,
|
||||
),
|
||||
Event.end_dt.is_(None),
|
||||
Event.start_dt >= date_from,
|
||||
),
|
||||
),
|
||||
@@ -141,14 +120,8 @@ async def list_events(
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
# Expand recurring event occurrences within [date_from, date_to].
|
||||
# Treat end_dt <= start_dt as no-end (invalid data); falling back
|
||||
# to point events instead of computing a negative duration.
|
||||
duration = (
|
||||
(event.end_dt - event.start_dt)
|
||||
if event.end_dt and event.end_dt > event.start_dt
|
||||
else None
|
||||
)
|
||||
# Expand recurring event occurrences within [date_from, date_to]
|
||||
duration = (event.end_dt - event.start_dt) if event.end_dt else None
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
@@ -200,13 +173,7 @@ async def search_events(
|
||||
|
||||
|
||||
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
"""Partial update. Returns updated event or None if not found.
|
||||
|
||||
Raises ValueError if the resulting state would have `end_dt <= start_dt`.
|
||||
Validation runs against the *post-update* values so updates that fix
|
||||
one side of the constraint without explicitly clearing the other still
|
||||
fail loudly instead of persisting invalid data.
|
||||
"""
|
||||
"""Partial update. Returns updated event or None if not found."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
||||
@@ -219,17 +186,6 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
"location", "color", "recurrence", "project_id", "reminder_minutes"}
|
||||
# Nullable fields that callers can explicitly set to None to clear
|
||||
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"}
|
||||
|
||||
# Pre-validate the *resulting* start/end against the post-update
|
||||
# state, not against either field's pre-update value alone.
|
||||
new_start = fields["start_dt"] if "start_dt" in fields and fields["start_dt"] is not None else event.start_dt
|
||||
new_end = fields["end_dt"] if "end_dt" in fields else event.end_dt
|
||||
if new_end is not None and new_end <= new_start:
|
||||
raise ValueError(
|
||||
f"end_dt ({new_end.isoformat()}) must be after start_dt "
|
||||
f"({new_start.isoformat()}); pass end_dt=None for point events."
|
||||
)
|
||||
|
||||
for key, value in fields.items():
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
|
||||
@@ -213,25 +213,22 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
proj = await resolve_project(user_id, project_name)
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
try:
|
||||
event = await events_create_event(
|
||||
user_id=user_id,
|
||||
title=arguments.get("title", "Untitled Event"),
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
all_day=all_day,
|
||||
description=arguments.get("description") or "",
|
||||
location=arguments.get("location") or "",
|
||||
color=arguments.get("color") or "",
|
||||
recurrence=arguments.get("recurrence"),
|
||||
project_id=project_id,
|
||||
duration=arguments.get("duration"),
|
||||
reminder_minutes=arguments.get("reminder_minutes"),
|
||||
attendees=arguments.get("attendees"),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
event = await events_create_event(
|
||||
user_id=user_id,
|
||||
title=arguments.get("title", "Untitled Event"),
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
all_day=all_day,
|
||||
description=arguments.get("description") or "",
|
||||
location=arguments.get("location") or "",
|
||||
color=arguments.get("color") or "",
|
||||
recurrence=arguments.get("recurrence"),
|
||||
project_id=project_id,
|
||||
duration=arguments.get("duration"),
|
||||
reminder_minutes=arguments.get("reminder_minutes"),
|
||||
attendees=arguments.get("attendees"),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {"success": True, "type": "event", "data": event.to_dict()}
|
||||
|
||||
|
||||
@@ -363,10 +360,7 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
return {"success": False, "error": f"Invalid end: {exc}"}
|
||||
if end_dt is not None:
|
||||
fields["end_dt"] = end_dt
|
||||
try:
|
||||
updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields)
|
||||
if updated is None:
|
||||
return {"success": False, "error": "Event not found or update failed."}
|
||||
return {"success": True, "type": "event_updated", "data": updated.to_dict()}
|
||||
|
||||
Reference in New Issue
Block a user