Skip to content

Commit 435f248

Browse files
authored
Fix CalDAV connect / disconnect, list calendar refresh and external_connection_id (#1212)
* fix connect and disconnect caldav on frontend * connect caldav calendars with their external_connection_id * read caldav attributes directly from vevent * removing unnecessary check * guard vevent attributes with hasattr * add test for CalDAV list_events
1 parent ddd147e commit 435f248

File tree

7 files changed

+263
-23
lines changed

7 files changed

+263
-23
lines changed

backend/src/appointment/controller/calendar.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ def test_connection(self) -> bool:
422422
# They need at least VEVENT support for appointment to work.
423423
return supports_vevent
424424

425-
def sync_calendars(self):
425+
def sync_calendars(self, external_connection_id: int | None = None):
426426
error_occurred = False
427427

428428
principal = self.client.principal()
@@ -443,7 +443,11 @@ def sync_calendars(self):
443443
# add calendar
444444
try:
445445
repo.calendar.update_or_create(
446-
db=self.db, calendar=calendar, calendar_url=calendar.url, subscriber_id=self.subscriber_id
446+
db=self.db,
447+
calendar=calendar,
448+
calendar_url=calendar.url,
449+
subscriber_id=self.subscriber_id,
450+
external_connection_id=external_connection_id,
447451
)
448452
except Exception as err:
449453
logging.warning(f'[calendar.sync_calendars] Error occurred while creating calendar. Error: {str(err)}')
@@ -495,18 +499,24 @@ def list_events(self, start, end):
495499
if not vevent:
496500
continue
497501

498-
event_components = vevent.components()
502+
# Ignore events with missing dtstart
503+
if not hasattr(vevent, 'dtstart') or not vevent.dtstart:
504+
continue
505+
506+
# Check for either dtend or duration (need at least one to determine event end)
507+
has_dtend = hasattr(vevent, 'dtend') and vevent.dtend
508+
has_duration = hasattr(vevent, 'duration') and vevent.duration
499509

500-
# Ignore events with missing datetime data
501-
if 'dtstart' not in event_components or (
502-
'dtend' not in event_components and 'duration' not in event_components
503-
):
510+
if not has_dtend and not has_duration:
504511
continue
505512

513+
# Check for event summary or use default title
514+
has_summary = hasattr(vevent, 'summary') and vevent.summary
515+
title = vevent.summary.value if has_summary else l10n('event-summary-default')
516+
506517
# Mark tentative events
507518
tentative = status == 'tentative'
508519

509-
title = vevent.summary.value if 'summary' in event_components else l10n('event-summary-default')
510520
start = vevent.dtstart.value
511521
# get_duration grabs either end or duration into a timedelta
512522
end = start + e.get_duration()
@@ -565,7 +575,12 @@ def delete_events(self, start):
565575
result = calendar.events()
566576
count = 0
567577
for e in result:
568-
if str(e.vobject_instance.vevent.dtstart.value).startswith(start):
578+
vevent = e.vobject_instance.vevent
579+
if not vevent:
580+
continue
581+
582+
has_dtstart = hasattr(vevent, 'dtstart') and vevent.dtstart
583+
if has_dtstart and str(vevent.dtstart.value).startswith(start):
569584
e.delete()
570585
count += 1
571586

backend/src/appointment/routes/caldav.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,13 @@ def caldav_autodiscover_auth(
118118
token=connection.password,
119119
)
120120

121-
repo.external_connection.create(db, external_connection_schema)
121+
external_connection = repo.external_connection.create(db, external_connection_schema)
122122
else:
123-
repo.external_connection.update_token(
123+
external_connection = repo.external_connection.update_token(
124124
db, connection.password, subscriber.id, models.ExternalConnectionType.caldav, caldav_id
125125
)
126126

127-
con.sync_calendars()
127+
con.sync_calendars(external_connection_id=external_connection.id)
128128
return True
129129

130130

backend/test/factory/calendar_factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def _make_caldav_calendar(
1616
connected=False,
1717
user=FAKER_RANDOM_VALUE,
1818
password=FAKER_RANDOM_VALUE,
19+
external_connection_id=None,
1920
):
2021
with with_db() as db:
2122
title = title if factory_has_value(title) else fake.name()
@@ -31,6 +32,7 @@ def _make_caldav_calendar(
3132
password=password if factory_has_value(password) else fake.password(),
3233
),
3334
subscriber_id,
35+
external_connection_id=external_connection_id,
3436
)
3537

3638
return _make_caldav_calendar

backend/test/integration/test_appointment.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
import dateutil.parser
33
from unittest.mock import patch, MagicMock
4+
from datetime import datetime
45

56
from defines import DAY1, DAY3, auth_headers, TEST_USER_ID
67
from appointment.database.repo import appointment as appointment_repo
@@ -53,6 +54,131 @@ def list_events(self, start, end):
5354
assert data[0]['start'] == generated_appointment.slots[0].start.isoformat()
5455
assert data[0]['end'] == dateutil.parser.parse(DAY3).isoformat()
5556

57+
def test_caldav_list_events_with_missing_properties(self, make_appointment, monkeypatch):
58+
"""Test that CalDavConnector.list_events handles events with missing properties gracefully"""
59+
from appointment.controller.calendar import CalDavConnector
60+
61+
# Create mock events with various missing properties
62+
class MockVEvent:
63+
def __init__(self, has_summary=True, has_dtend=True, has_duration=False):
64+
self.dtstart = MagicMock()
65+
self.dtstart.value = datetime(2023, 12, 1, 10, 0, 0)
66+
67+
if has_summary:
68+
self.summary = MagicMock()
69+
self.summary.value = "Test Event"
70+
71+
if has_dtend:
72+
self.dtend = MagicMock()
73+
self.dtend.value = datetime(2023, 12, 1, 11, 0, 0)
74+
elif has_duration:
75+
self.duration = MagicMock()
76+
self.duration.value = "PT1H" # 1 hour
77+
78+
class MockVObjectInstance:
79+
def __init__(self, has_summary=True, has_dtend=True, has_duration=False):
80+
self.vevent = MockVEvent(has_summary, has_dtend, has_duration)
81+
82+
class MockEvent:
83+
def __init__(self, has_summary=True, has_dtend=True, has_duration=False):
84+
self.icalendar_component = {'status': 'confirmed'}
85+
self.vobject_instance = MockVObjectInstance(has_summary, has_dtend, has_duration)
86+
87+
def get_duration(self):
88+
from datetime import timedelta
89+
return timedelta(hours=1)
90+
91+
# Create various test events
92+
mock_events = [
93+
MockEvent(has_summary=True, has_dtend=True), # Normal event
94+
MockEvent(has_summary=False, has_dtend=True), # Missing summary
95+
MockEvent(has_summary=True, has_dtend=False, has_duration=True), # Has duration instead of dtend
96+
MockEvent(has_summary=False, has_dtend=False, has_duration=True), # Missing summary and dtend
97+
]
98+
99+
# Add events that should be filtered out by our guards
100+
class MockBadVEvent:
101+
"""VEvent with missing critical properties"""
102+
def __init__(self, missing_dtstart=False, missing_both_end_props=False):
103+
if not missing_dtstart:
104+
self.dtstart = MagicMock()
105+
self.dtstart.value = datetime(2023, 12, 1, 10, 0, 0)
106+
107+
if not missing_both_end_props:
108+
self.dtend = MagicMock()
109+
self.dtend.value = datetime(2023, 12, 1, 11, 0, 0)
110+
111+
class MockBadVObjectInstance:
112+
def __init__(self, missing_dtstart=False, missing_both_end_props=False):
113+
self.vevent = MockBadVEvent(missing_dtstart, missing_both_end_props)
114+
115+
class MockBadEvent:
116+
def __init__(self, missing_dtstart=False, missing_both_end_props=False):
117+
self.icalendar_component = {'status': 'confirmed'}
118+
self.vobject_instance = MockBadVObjectInstance(missing_dtstart, missing_both_end_props)
119+
120+
def get_duration(self):
121+
from datetime import timedelta
122+
return timedelta(hours=1)
123+
124+
# Add events that should be filtered out
125+
mock_events.extend([
126+
MockBadEvent(missing_dtstart=True), # Missing dtstart - should be filtered
127+
MockBadEvent(missing_both_end_props=True), # Missing both dtend and duration - should be filtered
128+
])
129+
130+
def mock_search(start, end, event=True, expand=True):
131+
return mock_events
132+
133+
def mock_calendar(url):
134+
calendar_mock = MagicMock()
135+
calendar_mock.search = mock_search
136+
return calendar_mock
137+
138+
def mock_client_calendar(url):
139+
return mock_calendar(url)
140+
141+
# Set up the CalDavConnector with mocked components
142+
connector = CalDavConnector(
143+
db=None,
144+
subscriber_id=1,
145+
calendar_id=1,
146+
redis_instance=None,
147+
url="https://test.com/caldav",
148+
user="test",
149+
password="test"
150+
)
151+
152+
# Mock the client.calendar method
153+
connector.client = MagicMock()
154+
connector.client.calendar = mock_client_calendar
155+
156+
# Mock the caching methods
157+
connector.get_cached_events = MagicMock(return_value=None)
158+
connector.put_cached_events = MagicMock()
159+
160+
# Test the method with problematic events
161+
start_str = "2023-12-01"
162+
end_str = "2023-12-02"
163+
164+
# This should not raise any exceptions despite missing properties
165+
events = connector.list_events(start_str, end_str)
166+
167+
# Verify the results - should only process 4 valid events, filtering out 2 bad ones
168+
assert len(events) == 4, "Should process 4 valid events and filter out 2 invalid ones"
169+
170+
# Check that events with missing summary get default title
171+
events_with_default_title = [e for e in events if 'event-summary-default' in e.title]
172+
assert len(events_with_default_title) == 2, "Events without summary should get default title"
173+
174+
# Check that all events have required fields
175+
for event in events:
176+
assert event.title is not None
177+
assert event.start is not None
178+
assert event.end is not None
179+
assert isinstance(event.all_day, bool)
180+
assert isinstance(event.tentative, bool)
181+
56182
def test_get_remote_caldav_events_invalid_calendar(self, with_client, make_appointment):
57183
generated_appointment = make_appointment()
58184

backend/test/integration/test_calendar.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from sqlalchemy import select
1111

12-
from defines import auth_headers
12+
from defines import auth_headers, TEST_USER_ID
1313

1414

1515
def get_calendar_factory():
@@ -423,3 +423,93 @@ def test_update_existing_caldav_calendar_without_password(self, with_client, wit
423423
assert cal.url == os.getenv('CALDAV_TEST_CALENDAR_URL')
424424
assert cal.user == os.getenv('CALDAV_TEST_USER')
425425
assert cal.password == ''
426+
427+
def test_caldav_auth_links_external_connection_id(self, with_client, with_db, monkeypatch):
428+
"""Test that creating calendars through /caldav/auth correctly links external_connection_id"""
429+
430+
# Mock the CalDAV client and its methods
431+
class MockCalendar:
432+
def __init__(self, name, url):
433+
self.name = name
434+
self.url = url
435+
436+
class MockPrincipal:
437+
def calendars(self):
438+
return [
439+
MockCalendar("Test Calendar 1", "https://test-server.com/calendar1/"),
440+
MockCalendar("Test Calendar 2", "https://test-server.com/calendar2/"),
441+
]
442+
443+
class MockClient:
444+
def principal(self):
445+
return MockPrincipal()
446+
447+
from appointment.controller.calendar import CalDavConnector
448+
449+
def mock_init(self, db, redis_instance, url, user, password, subscriber_id, calendar_id):
450+
# Store the parameters we need
451+
self.db = db
452+
self.subscriber_id = subscriber_id
453+
self.user = user
454+
self.password = password
455+
self.client = MockClient()
456+
457+
def mock_test_connection(self):
458+
return True
459+
460+
def mock_is_supported(self, cal):
461+
return True
462+
463+
def mock_bust_cached_events(self, all_calendars=False):
464+
# Mock implementation - no-op for testing
465+
pass
466+
467+
# Apply all the mocks
468+
monkeypatch.setattr(CalDavConnector, '__init__', mock_init)
469+
monkeypatch.setattr(CalDavConnector, 'test_connection', mock_test_connection)
470+
monkeypatch.setattr(CalDavConnector, '_is_supported', mock_is_supported)
471+
monkeypatch.setattr(CalDavConnector, 'bust_cached_events', mock_bust_cached_events)
472+
473+
# Call the /caldav/auth endpoint
474+
response = with_client.post(
475+
'/caldav/auth',
476+
json={
477+
'url': 'https://test-caldav-server.com',
478+
'user': 'test_user',
479+
'password': 'test_password',
480+
},
481+
headers=auth_headers,
482+
)
483+
484+
assert response.status_code == 200, response.text
485+
486+
with with_db() as db:
487+
# Check that an external connection was created
488+
external_connections_query = select(models.ExternalConnections).where(
489+
models.ExternalConnections.owner_id == TEST_USER_ID
490+
)
491+
external_connections = db.scalars(external_connections_query).all()
492+
assert len(external_connections) == 1
493+
494+
external_connection = external_connections[0]
495+
assert external_connection.type == models.ExternalConnectionType.caldav
496+
assert external_connection.name == 'test_user'
497+
498+
# Check that calendars were created with the correct external_connection_id
499+
calendars_query = select(models.Calendar).where(
500+
models.Calendar.owner_id == TEST_USER_ID
501+
)
502+
calendars = db.scalars(calendars_query).all()
503+
assert len(calendars) == 2 # We mocked 2 calendars
504+
505+
# Verify both calendars have the correct external_connection_id
506+
for calendar in calendars:
507+
assert calendar.external_connection_id == external_connection.id
508+
assert calendar.provider == models.CalendarProvider.caldav
509+
assert calendar.user == 'test_user'
510+
assert calendar.password == 'test_password'
511+
512+
# Verify calendar titles match our mock data
513+
calendar_titles = [cal.title for cal in calendars]
514+
assert "Test Calendar 1" in calendar_titles
515+
assert "Test Calendar 2" in calendar_titles

frontend/src/views/DashboardView/index.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ onMounted(async () => {
5555
}
5656
5757
await refresh();
58-
await calendarStore.getRemoteEvents(activeDate.value);
58+
await calendarStore.getRemoteEvents(activeDate.value, true);
5959
});
6060
</script>
6161

0 commit comments

Comments
 (0)