python-caldav / caldav

Apache License 2.0
333 stars 98 forks source link

Delete recurring event? #35

Open embie27 opened 5 years ago

embie27 commented 5 years ago

How to delete only a single occurrence of a recurring event? The delete()-method deletes every occurrence.

tobixen commented 5 years ago

Sorry the slow response.

Deleting a single instance of a recurring event is slightly nontrivial, see https://blog.jonudell.net/2008/08/28/specifying-exceptions-to-recurring-calendar-events/amp/ for details.

It is within the scope of the caldav library to support this somehow, but I will not have capacity to look into it anytime soon.

tobixen commented 10 months ago

It's probably several years still until I get time to deal with this.

The problem is as such: when running a .delete() on a recurrence instance (that is defined as a calendar event or todo having a recurrence-id set), then a DELETE-command is sent to the server on the URL. The problem is that the URL for the recurrence instance is the same as the URL for the recurring instance (and all other recurrence instance), hence everything will be deleted, and that's probably not what one wanted.

But what does one actually want to do when running DELETE on a recurrence instance?

Probably it's needed to specify with a separate optional parameter how to deal with recurrence instances - probably with the default being "raise an error".

And yes, this is a bit related to #379, because maybe it's needed to load the full recurrence set and put it back to the server to get things done correctly on all servers.

julien4215 commented 10 months ago

I don't really know the subject but when I looked the references I saw that EXRULE is deprecated (https://icalendar.org/iCalendar-RFC-5545/a-3-deprecated-features.html). I guess that can be useful for the issue.

ptrba commented 6 months ago

Commenting on:

But what does one actually want to do when running DELETE on a recurrence instance?

I have a recurrent series of events but attendees are assigned only on specific instances of the series. The use case is, that I want to remove the assignment of the attendee(s). I came across the issue because I wanted to delete the event in this case. However, this is not needed, I can simply remove the attendee(s) an leave the event without attendees in the calendar.

ptrba commented 6 months ago

There seems to be a deeper issue here. On a related discussion from sabre dav https://groups.google.com/g/sabredav-discuss/c/M82DQRJTr4A?pli=1 they suggest to work on the level of 'calendar' rather than 'event' objects. Rather than fetching single events, icalendar.Calendar object can be fetched and modified.

This solves the problem of deleting single events from a recurrent series of events. Simply get the calendar object which contains the recurrent series and all possible modifications of single events. Then add/remove/update the corresponding items, then put the calendar object back.

Important, there are 2 concepts of calendars.

  1. The calendar used by python-caldav as caldav.DAVClient(...).principal().calendar(). I call it parent here. This is not the calendar we are referring to.
  2. There is an icalendar.Calendar object which is a container for multiple subcomponents. It is basically what you get if you fetch an .ics from a url. This is what we mean by calendar object in what follows.

python-caldav does not seem to support working with calendar objects directly, but it is easy to work around this. Here is a working example. Create a series where just one item has an attendee. In this example we first create the 2 corresponding events wrapped by a calendar object. Notice their identical uid. Tested with SOGo. The will be located at /6B-664A5280-F-5B831280.ics.

data = """
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:recurrence with attendee one single item
DTSTART;TZID=Europe/Zurich:20240101T090000
DTEND;TZID=Europe/Zurich:20240101T180000
UID:6B-664A5280-F-5B831280
DESCRIPTION:this is the recurrent series
TRANSP:OPAQUE
RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH
END:VEVENT
BEGIN:VEVENT
SUMMARY:single item
DTSTART;TZID=Europe/Zurich:20240605T090000
DTEND;TZID=Europe/Zurich:20240605T170000
UID:6B-664A5280-F-5B831280
DESCRIPTION:this is the single item assigning a attendee to just one event
ATTENDEE:foo.bar@corge.baz
RECURRENCE-ID:20240605T070000Z
END:VEVENT
END:VCALENDAR
"""

parent = caldav.DAVClient(...).principal().calendar() 

ical_calendar = icalendar.Calendar.from_ical(data)

events = [event for event in ical_calendar.subcomponents if isinstance(event,icalendar.Event)]

assert len(events) == 2
assert events[0].get('RECURRENCE-ID') is None
assert events[1].get('RECURRENCE-ID').dt == datetime.datetime(2024,6,5,7,0,tzinfo=datetime.timezone.utc)

caldav.CalendarObjectResource(
    client=parent.client,
    data=ical_calendar.to_ical().decode('utf-8'),
    parent=parent
).save()

# Important, do not provide a comp_class here. If we put comp_class='Event' we will only receive
# one of the original 2 events
object_by_id = calendar_.calendar.object_by_uid('6B-664A5280-F-5B831280',comp_class=None)

# object_by_id is an event rather than a Calendar,
# but no problem, the desired calendar is present in the underlying data
ical_calendar_ = icalendar.Calendar.from_ical(object_by_id.data)

events = [event for event in ical_calendar_.subcomponents if isinstance(event,icalendar.Event)]
assert len(events) == 2
assert events[0].get('RECURRENCE-ID') is None
assert events[1].get('RECURRENCE-ID').dt == datetime.datetime(2024, 6, 5, 7, 0, tzinfo=datetime.timezone.utc)

ical_calendar__ = icalendar.Calendar()
ical_calendar__.add_component(events[0])

caldav.CalendarObjectResource(
    client=parent.client,
    data=ical_calendar__.to_ical().decode('utf-8'),
    parent=parent
).save()

object_by_id = calendar_.calendar.object_by_uid('6B-664A5280-F-5B831280', comp_class=None)
ical_calendar_ = icalendar.Calendar.from_ical(object_by_id.data)

events = [event for event in ical_calendar_.subcomponents if isinstance(event, icalendar.Event)]
assert len(events) == 1

python-caldav would definitely benefit from a better support for icalendar.calendar objects, but this is another issue.

tobixen commented 6 months ago

A "calendar" is a collection of events/tasks/journals. In the CalDAV specifications, it's defined to have a CalDAV URL and it supports operations like search, hence the caldav.Calendar class mirrors the CalDAV specification. In the icalendar definition it's possible to bundle together independent events/tasks/journals in a VCALENDAR-object, however the CalDAV protocol will return each event/task/journal as separate VCALENDAR-objects. The exception is with recurring tasks, there a VCALENDAR-object containing the base definition and the instances are returned.

What is probably poorly documented is that a caldav.Event-object in some cases may be a recurrent event with all the recurrence instance data included (and sometimes with the recurrence instance data autogenerated, either at the server side or client side), other times such an object may contain only a recurrence instance, and yet in other cases it may contain only the definition of the recurring event.

The Calendar.search-method has two relevant options, expand will autogenerate instances, and split_expanded will split each of those into separate Event-objects rather than including all the recurrence-instances in one Event-object. The latter is set to true by default.

# Important, do not provide a comp_class here. If we put comp_class='Event' we will only receive
# one of the original 2 events
object_by_id = calendar_.calendar.object_by_uid('6B-664A5280-F-5B831280',comp_class=None)

This sounds like a bug. I should look into it when I'm more awake.

ical_calendar_ = icalendar.Calendar.from_ical(object_by_id.data)

This should be equivalent to:

ical_calendar_ = object_by_id.icalendar_instance

I'm recommending to do this in the documentation:

ical_calendar_ = object_by_id.icalendar_component

Which will give the icalendar component object (event/task/journal) rather than the icalendar calendar object. and hence throw away all the recurrence-data fra object_by_id if there exist any. The documentation is probably a bit weak at this point.

python-caldav would definitely benefit from a better support for icalendar.calendar objects, but this is another issue

I disagree on that one - but the possibility to edit overridden recurrence instances (and handling them at all) is probably not very well thought-through in the python-caldav library.

ptrba commented 6 months ago

Great explanation. So it all boils down to the following:

VCALENDAR objects can be used to fully control recurring events with multiple single instances. Add, modify or delete all or some of the individual instances.

I can confirm the issue with object_by_id for accessing the VCALENDAR object:

# Important, do not provide a comp_class here. If we put comp_class='Event' we will only receive
# one of the original 2 events
object_by_id = calendar_.calendar.object_by_uid('6B-664A5280-F-5B831280',comp_class=caldav.Event)

# object_by_id is an event rather than a Calendar,
# but no problem, the desired calendar is present in the underlying data
ical_calendar_ = object_by_id.icalendar_instance

events = [event for event in ical_calendar_.subcomponents if isinstance(event,icalendar.Event)]
assert len(events) == 1 # expecting 2
tobixen commented 6 months ago

Great explanation. So it all boils down to the following:

VCALENDAR objects can be used to fully control recurring events with multiple single instances. Add, modify or delete all or some of the individual instances.

Yes. By fetching it through object_by_id, or using calendar.search(..., split_expanded=False) and then accessing the icalendar data either through the raw event.data, event.vobject_instance or (recommended) event.icalendar_instance, followed by an event.save() , it should be possible to make any changes on recurrence instances. If it doesn't, it's a bug.

There also exists higher-level methods for editing participants in the caldav library - though, it's very poorly tested as it was done as part of a project that lost traction at some point.

I can confirm the issue with object_by_id for accessing the VCALENDAR object:

I will fork it out as a separate issue.

tobixen commented 6 months ago

@ptrba - could you please have a look at #398? First of all - I haven't slept much over the last two nights, perhaps the text there make sense only in my own head, I'd like a peer-review on weather the text is readable at all :-) The other thing is weather my suggestions make sense at all or not.

tobixen commented 6 months ago

Also, I think I've concluded that an Event.delete() on an Event containing Ionly) (a) recurrence instance(s) should be transformed into an edit, setting STATUS:CANCELLED. This will solve this issue. The actual work will be done when working on #398.

tobixen commented 6 months ago

Also, @ptrba (sorry for the noise here) - why did you consider removing all participants is a better idea than setting status=cancelled?

ptrba commented 6 months ago

Also, @ptrba (sorry for the noise here) - why did you consider removing all participants is a better idea than setting status=cancelled?

Probably equivalent. Have not tried. I went into the direction of editing the events in Calendar object because I have a lot of changes being performed on the object and I wanted to have full control over the entries. Cancelling events will mean they will be dangling around. May or not be problem for the performance, but definitely creates cognitive overhead. The solution presented here works flawlessly and it is clean.

What bothered me most was the following Problem (obfuscated and simplified, but there is a real use case behind it).

I have a recurrent series of events, say lunch from Monday til Friday from 12-13 pm. I have a special date where I add an attendee, say 1st of May. This will create an clone with the recurrence-id on 20xx0501-1200. Now, we decide to change lunch time from 12 to 12.15. This should affect the regular recurrent series but also the special date (it is only special because it has another attendee). Now I need to change the recurrence-id of the special event. I can do this by either:

wrt to your issue #398: I do not fully understand the role of expansion here. But this will of course depend on the use case. In my use case I use a client side representation which is very close to the original icalendar events.

tobixen commented 6 months ago

Honestly, I have no idea what is best - and what is best may depend quite a bit on he client and server. Actually I believe the CalDAV and icalendar standards are a mess. I started using the CalDAV library because I didn't want to get my hands dirty with low-lever work on those protocols, but had to fix some bugs in the library and soon enough the owner of the project put the maintainer-hat on my head :-) That being said, I believe that according to the standards 12:15 is not a valid time for the RECURRENCE-ID because it does not match up with the DTSTART and RRULE of the original object. I believe the right thing to do is to keep the RECURRENCE-ID as it is (because it is the ID of the "1st of may Lunch", even if the time is changed), but move the DTSTART. Similar, if the lunch for some reason or another should be moved from a Friday to a Saturday, I believe the proper thing is to keep the RECURRENCE-ID pointing to Friday but let DTSTART move to Saturday. (Digression: if I'm not mistaken, in Russia and China if a fixed-date public holiday like the 1st of May happens to be on a Thursday, often the Friday after is declared to be a public holiday, but to compensate for this extra holiday, the next Saturday is defined to be a regular working day! Well, the 1s of May lunch is anyway cancelled, but the 2nd of May lunch is postponed with 8 days!)

As for "expansion", say one searches for "all events between 10:00 and 14:00 on Wednesday in a week", how should the lunch be returned? With expand=False the server will return the icalendar data it has stored (lunch at 12:00 on a Monday three years ago, but with RRULE set to "daily, five times a week", possibly with all the special case recurrence instances included, and it's up to the client to . If expand=True then the server will return a recurrence-instance (automatically generated, if it doesn't already exist) covering the lunch Wednesday in a week.