python-caldav / caldav

Apache License 2.0
312 stars 91 forks source link

Recurrent event with an exception instance leads to multiple `RECURRENCE-ID` values in the exception instance #394

Open dozed opened 2 months ago

dozed commented 2 months ago

I created an iCalendar object as follow:

This leads to an iCalendar object with two events, one master and one exception instance:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VEVENT
UID:c26921f4-0653-11ef-b756-58ce2a14e2e5
DTSTART;VALUE=DATE:20240411
DTEND;VALUE=DATE:20240412
DTSTAMP:20240429T181103Z
LAST-MODIFIED:20240429T181103Z
RRULE:FREQ=WEEKLY;INTERVAL=2
SEQUENCE:1
SUMMARY:Test 1
X-MOZ-GENERATION:1
END:VEVENT
BEGIN:VEVENT
UID:c26921f4-0653-11ef-b756-58ce2a14e2e5
RECURRENCE-ID;VALUE=DATE:20240425
DTSTART;VALUE=DATE:20240425
DTEND;VALUE=DATE:20240426
CREATED:20240429T181031Z
DTSTAMP:20240429T181103Z
LAST-MODIFIED:20240429T181103Z
SEQUENCE:1
SUMMARY:Test 1 (edited)
X-MOZ-GENERATION:1
END:VEVENT
END:VCALENDAR

When searching for the event via caldav, two instances are returned. The second instance contains the RECURRENCE-ID field twice as shown in the following test case:

def testRecurringDateWithExceptionSearch(self):
    self.skip_on_compatibility_flag("read_only")
    self.skip_on_compatibility_flag("no_recurring")
    c = self._fixCalendar()

    # evr2 is a bi-weekly event starting 2024-04-11
    e = c.save_event(evr2)

    r = c.search(
        start=datetime(2024, 3, 31, 0, 0),
        end=datetime(2024, 5, 4, 0, 0, 0),
        event=True,
        expand=True,
    )

    assert len(r) == 2

    assert 'RRULE' not in r[0].data
    assert 'RRULE' not in r[1].data

    assert isinstance(r[0].icalendar_component['RECURRENCE-ID'], icalendar.vDDDTypes)
    # OK:
    # vDDDTypes(2024-04-11, Parameters({'VALUE': 'DATE'}))

    assert len(r[1].icalendar_component['RECURRENCE-ID']) == 1
    # fails, since there are multiple values:
    # [vDDDTypes(2024-04-25, Parameters({'VALUE': 'DATE'})),
    #  vDDDTypes(2024-04-25, Parameters({'VALUE': 'DATE'}))]

A possible solution would be to add a check for an already existing RECURRENCE-ID value here: https://github.com/python-caldav/caldav/blob/f4d5cd2844df99d15a07b72d4f3adf67635ea03b/caldav/objects.py#L2005-L2007

Another option would be to just overwrite the value.

dozed commented 2 months ago

Here is a minimal working example test case:

import socket
import tempfile
import threading
import time
from datetime import datetime

import icalendar
import pytest
import radicale
import radicale.server
import requests
from caldav import DAVClient
from caldav.objects import Calendar

@pytest.fixture
def radicale_calendar():
    radicale_host = 'localhost'
    radicale_port = 52121
    url = f'http://{radicale_host}:{radicale_port}/'
    username = 'user'
    password = 'some-password'

    serverdir = tempfile.TemporaryDirectory()
    serverdir.__enter__()

    configuration = radicale.config.load()
    configuration.update(
        {
            'server': {'hosts': f'127.0.0.1:{radicale_port}'},
            'storage': {'filesystem_folder': serverdir.name}
        }
    )

    shutdown_socket, shutdown_socket_out = socket.socketpair()
    radicale_thread = threading.Thread(
        target=radicale.server.serve,
        args=(configuration, shutdown_socket_out),
    )
    radicale_thread.start()

    i = 0
    while True:
        try:
            requests.get(url)
            break
        except:
            time.sleep(0.05)
            i += 1
            assert i < 100

    client = DAVClient(url=url, username=username, password=password)
    principal = client.principal()
    calendar = principal.make_calendar(
        name='Yep', cal_id='123'
    )

    yield calendar

    shutdown_socket.close()
    serverdir.__exit__(None, None, None)

recurring_event_with_exception = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VEVENT
UID:c26921f4-0653-11ef-b756-58ce2a14e2e5
DTSTART;VALUE=DATE:20240411
DTEND;VALUE=DATE:20240412
DTSTAMP:20240429T181103Z
LAST-MODIFIED:20240429T181103Z
RRULE:FREQ=WEEKLY;INTERVAL=2
SEQUENCE:1
SUMMARY:Test 1
X-MOZ-GENERATION:1
END:VEVENT
BEGIN:VEVENT
UID:c26921f4-0653-11ef-b756-58ce2a14e2e5
RECURRENCE-ID;VALUE=DATE:20240425
DTSTART;VALUE=DATE:20240425
DTEND;VALUE=DATE:20240426
CREATED:20240429T181031Z
DTSTAMP:20240429T181103Z
LAST-MODIFIED:20240429T181103Z
SEQUENCE:1
SUMMARY:Test (edited)
X-MOZ-GENERATION:1
END:VEVENT
END:VCALENDAR"""

def test_expanding_caldav_search_with_recurrent_event_having_exception(radicale_calendar: Calendar):
    radicale_calendar.save_event(recurring_event_with_exception)

    r = radicale_calendar.search(
        start=datetime(2024, 3, 31, 0, 0),
        end=datetime(2024, 5, 4, 0, 0, 0),
        event=True,
        expand=True,
    )

    assert len(r) == 2

    assert 'RRULE' not in r[0].data
    assert 'RRULE' not in r[1].data

    assert isinstance(r[0].icalendar_component['RECURRENCE-ID'], icalendar.vDDDTypes)
    # vDDDTypes(2024-04-11, Parameters({'VALUE': 'DATE'}))

    # Fails:
    assert isinstance(r[1].icalendar_component['RECURRENCE-ID'], icalendar.vDDDTypes)
    # [vDDDTypes(2024-04-25, Parameters({'VALUE': 'DATE'})),
    #  vDDDTypes(2024-04-25, Parameters({'VALUE': 'DATE'}))]
tobixen commented 2 months ago

Thanks for your report, you've done a good job debugging and pinpointing the issue as well as writing up test code. Would you care to make it into a pull request? :-)

dozed commented 2 months ago

Sure, I created a draft pull request: https://github.com/python-caldav/caldav/pull/395

There is still an issue with Xandikos on my machine. Do you have an idea maybe what this could be?

tobixen commented 2 months ago

I've had different problems with different versions of Xandikos. Do you get an error message?