mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
Fix odysseus-calendar list dropping in-progress / multi-day events (#2065)
cmd_list filtered on the event START falling inside the window (dtstart >= start AND dtstart < end). The canonical web route (routes/calendar_routes.py) and the recurrence contract test use OVERLAP semantics for non-recurring events: dtstart < end AND dtend > start. So an event that began before the window but is still ongoing inside it — e.g. a 09:00-17:00 conference listed at 14:00, or any multi-day event spanning the window — was silently dropped by the CLI even though the web UI shows it. Use overlap, matching the route. dtend is NOT NULL in the schema, so no null-end regression.
This commit is contained in:
@@ -103,9 +103,13 @@ def cmd_list(args) -> None:
|
||||
end = _parse_dt(args.end) if args.end else (start + timedelta(days=30))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Overlap semantics, matching the web route (routes/calendar_routes.py)
|
||||
# and the recurring-expansion contract: an event is in the window when
|
||||
# it starts before the window end AND ends after the window start. This
|
||||
# includes multi-day / in-progress events that began before `start`.
|
||||
q = db.query(CalendarEvent).filter(
|
||||
CalendarEvent.dtstart >= start,
|
||||
CalendarEvent.dtstart < end,
|
||||
CalendarEvent.dtend > start,
|
||||
)
|
||||
if args.calendar:
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.name == args.calendar).first()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Regression: `odysseus-calendar list` must select events that OVERLAP the
|
||||
query window, matching the canonical web-route filter in
|
||||
routes/calendar_routes.py (`dtstart < end AND dtend > start`) and the
|
||||
recurring-expansion contract asserted in test_calendar_recurrence.py
|
||||
(test_expand_multi_day_crossing_range_start).
|
||||
|
||||
The buggy CLI filtered on `dtstart >= start AND dtstart < end`, which drops a
|
||||
multi-day / in-progress event that started before the window but is still
|
||||
running inside it (e.g. an all-day-running conference when you call
|
||||
`odysseus-calendar list` with the default start=now()).
|
||||
"""
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class _Col:
|
||||
"""A fake SQLAlchemy column that records comparison clauses instead of
|
||||
building SQL. `Col >= x` / `Col < x` / `Col > x` evaluate against a row
|
||||
later via .matches(row)."""
|
||||
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __ge__(self, other):
|
||||
return _Clause(self.name, ">=", other)
|
||||
|
||||
def __lt__(self, other):
|
||||
return _Clause(self.name, "<", other)
|
||||
|
||||
def __gt__(self, other):
|
||||
return _Clause(self.name, ">", other)
|
||||
|
||||
# asc()/order_by helpers used by cmd_list — return self, harmless.
|
||||
def asc(self):
|
||||
return self
|
||||
|
||||
|
||||
class _Clause:
|
||||
def __init__(self, col, op, value):
|
||||
self.col = col
|
||||
self.op = op
|
||||
self.value = value
|
||||
|
||||
def matches(self, row):
|
||||
actual = getattr(row, self.col)
|
||||
if self.op == ">=":
|
||||
return actual >= self.value
|
||||
if self.op == "<":
|
||||
return actual < self.value
|
||||
if self.op == ">":
|
||||
return actual > self.value
|
||||
raise AssertionError(self.op)
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.clauses = []
|
||||
|
||||
def filter(self, *conds):
|
||||
self.clauses.extend(conds)
|
||||
return self
|
||||
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
|
||||
def limit(self, n):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
def all(self):
|
||||
out = []
|
||||
for r in self.rows:
|
||||
if all(c.matches(r) for c in self.clauses if isinstance(c, _Clause)):
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def _load_cli(monkeypatch, rows):
|
||||
db = types.ModuleType("core.database")
|
||||
session = MagicMock()
|
||||
session.query.return_value = _Query(rows)
|
||||
db.SessionLocal = MagicMock(return_value=session)
|
||||
cal_event = types.SimpleNamespace(dtstart=_Col("dtstart"), dtend=_Col("dtend"))
|
||||
db.CalendarEvent = cal_event
|
||||
db.CalendarCal = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "core.database", db)
|
||||
path = ROOT / "scripts" / "odysseus-calendar"
|
||||
loader = importlib.machinery.SourceFileLoader("odysseus_calendar_cli", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_list_includes_event_overlapping_window_start(monkeypatch, capsys):
|
||||
# Conference running 09:00–17:00; we list from 14:00 onward (default now()).
|
||||
ongoing = types.SimpleNamespace(
|
||||
dtstart=datetime(2026, 6, 3, 9, 0),
|
||||
dtend=datetime(2026, 6, 3, 17, 0),
|
||||
)
|
||||
cli = _load_cli(monkeypatch, [ongoing])
|
||||
|
||||
# Serialize to something trivial so emit() doesn't choke on the namespace.
|
||||
cli._serialize_event = lambda e: {"dtstart": e.dtstart.isoformat()}
|
||||
|
||||
args = types.SimpleNamespace(
|
||||
start="2026-06-03T14:00:00",
|
||||
end="2026-06-03T23:00:00",
|
||||
calendar=None,
|
||||
limit=100,
|
||||
pretty=False,
|
||||
)
|
||||
cli.cmd_list(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "2026-06-03T09:00:00" in out, (
|
||||
"An event that started before the window but is still running inside "
|
||||
"it must be listed (overlap semantics), but it was dropped."
|
||||
)
|
||||
Reference in New Issue
Block a user