fix: re-importing an ICS file duplicates every tz-aware timed event (#1683)

This commit is contained in:
Afonso Coutinho
2026-06-03 06:22:49 +01:00
committed by GitHub
parent 1161040efe
commit fbb52a73a0
2 changed files with 66 additions and 1 deletions

View File

@@ -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(

View 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