fix: re-importing an ICS file duplicates every tz-aware timed event (#1683)
This commit is contained in:
@@ -16,6 +16,23 @@ from src.auth_helpers import get_current_user, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ics_naive_dtstart(dt):
|
||||
"""Naive value matching how import_ics STORES CalendarEvent.dtstart.
|
||||
|
||||
Timed tz-aware events are stored as UTC with tzinfo stripped, all-day
|
||||
dates as midnight datetimes, naive datetimes unchanged. The ICS dedup
|
||||
must compute the same value or a re-import never matches the stored row.
|
||||
"""
|
||||
if isinstance(dt, datetime):
|
||||
if dt.tzinfo is not None:
|
||||
from datetime import timezone as _tz
|
||||
return dt.astimezone(_tz.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
if isinstance(dt, date):
|
||||
return datetime(dt.year, dt.month, dt.day)
|
||||
return dt
|
||||
|
||||
# Single-user fallback identity. Used only when:
|
||||
# 1. The app is configured for single-user (no auth middleware), AND
|
||||
# 2. The request didn't resolve to an authenticated user.
|
||||
@@ -1023,7 +1040,12 @@ def setup_calendar_routes() -> APIRouter:
|
||||
source_uid = str(comp.get("uid", "")) or None
|
||||
if source_uid:
|
||||
src_dtstart = dtstart.dt
|
||||
naive_src = src_dtstart.replace(tzinfo=None) if hasattr(src_dtstart, 'tzinfo') and src_dtstart.tzinfo else src_dtstart
|
||||
# Normalize to the SAME naive form import_ics stores, so a
|
||||
# re-import of a tz-aware event matches the existing row.
|
||||
# The old code stripped tzinfo WITHOUT converting to UTC
|
||||
# (wall clock), while storage converts to UTC first, so
|
||||
# every re-import of a TZID event created a duplicate.
|
||||
naive_src = _ics_naive_dtstart(src_dtstart)
|
||||
existing = (
|
||||
db.query(CalendarEvent)
|
||||
.filter(
|
||||
|
||||
43
tests/test_ics_import_dedup_tz.py
Normal file
43
tests/test_ics_import_dedup_tz.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""ICS re-import must dedup tz-aware timed events.
|
||||
|
||||
import_ics stores a tz-aware DTSTART as naive UTC (e.g. 09:00 America/
|
||||
New_York becomes 13:00), but the dedup key stripped tzinfo WITHOUT the UTC
|
||||
conversion (kept 09:00 wall clock). So the dedup query never matched the
|
||||
stored row and every re-import of a TZID event inserted a duplicate. The
|
||||
shared _ics_naive_dtstart helper now drives both.
|
||||
"""
|
||||
from datetime import date, datetime, timezone, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("sqlalchemy")
|
||||
|
||||
from routes.calendar_routes import _ics_naive_dtstart
|
||||
|
||||
|
||||
def test_tz_aware_dedup_key_matches_utc_storage_form():
|
||||
zi = pytest.importorskip("zoneinfo")
|
||||
ny = zi.ZoneInfo("America/New_York")
|
||||
dt = datetime(2026, 6, 15, 9, 0, tzinfo=ny) # EDT = UTC-4 -> 13:00 UTC
|
||||
assert _ics_naive_dtstart(dt) == datetime(2026, 6, 15, 13, 0)
|
||||
|
||||
|
||||
def test_fixed_offset_dedup_key_is_utc():
|
||||
dt = datetime(2026, 6, 15, 9, 0, tzinfo=timezone(timedelta(hours=2)))
|
||||
assert _ics_naive_dtstart(dt) == datetime(2026, 6, 15, 7, 0)
|
||||
|
||||
|
||||
def test_naive_datetime_unchanged():
|
||||
dt = datetime(2026, 6, 15, 9, 0)
|
||||
assert _ics_naive_dtstart(dt) == dt
|
||||
|
||||
|
||||
def test_all_day_date_becomes_midnight_datetime():
|
||||
assert _ics_naive_dtstart(date(2026, 6, 15)) == datetime(2026, 6, 15, 0, 0)
|
||||
|
||||
|
||||
def test_dedup_key_equals_storage_conversion():
|
||||
zi = pytest.importorskip("zoneinfo")
|
||||
dt_val = datetime(2026, 11, 1, 9, 30, tzinfo=zi.ZoneInfo("America/New_York"))
|
||||
stored = dt_val.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
assert _ics_naive_dtstart(dt_val) == stored
|
||||
Reference in New Issue
Block a user