Skip to content

Commit a45435f

Browse files
authored
Allow recurrence objects to be changed. (#500)
* Fixes #379 - editing a recurrence object and saving it will do what it ought to do on all servers - change only that recurrence and nothing else * The `save`-method now takes a boolean `all_recurrences` which * piggybacking some code refactoring, fixes #476 #500
1 parent 2f61dc7 commit a45435f

4 files changed

Lines changed: 221 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,19 @@ In version 2.0, support for python 3.7 and python 3.8 will be officially dropped
1414

1515
## [1.6.0] - [Unreleased]
1616

17+
### Added
18+
19+
* New option `event.save(all_recurrences=True)` to edit the whole series when saving a modified recurrence.
20+
1721
### Fixed
1822

23+
* Save single recurrence. I can't find any information in the RFCs on this, but all servers I've tested does the wrong thing - when saving a single recurrence (with RECURRENCE-ID set but without RRULE), then the original event (or task) will be overwritten (and the RRULE disappear), which is most likely not what one wants. New logic in place (with good test coverage) to ensure only the single instance is saved. Issue https://github.com/python-caldav/caldav/issues/379, pull request https://github.com/python-caldav/caldav/pull/500
1924
* Scheduling support. It was work in progress many years ago, but uncompleted work was eventually committed to the project. I managed to get a DAViCal test server up and running with three test accounts, ran through the tests, found quite some breakages, but managed to fix up. https://github.com/python-caldav/caldav/pull/497
2025

26+
### Refactoring
27+
28+
* Partially tossed out all internal usage of vobject, https://github.com/python-caldav/caldav/issues/476. Refactoring and removing unuseful code. Parts of this work was accidentally committed directly to master, 2f61dc7adbe044eaf43d0d2c78ba96df09201542, the rest was piggybaced in through https://github.com/python-caldav/caldav/pull/500.
29+
2130
## [1.5.0] - 2025-05-24
2231

2332
Version 1.5 comes with support for alarms (searching for alarms if the server permits and easy interface for adding alamrs when creating events), lots of workarounds and fixes ensuring compatibility with various servers, refactored some code, and done some preparations for the upcoming server compatibility hints project.

caldav/calendarobjectresource.py

Lines changed: 113 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,6 @@ def expand_rrule(
225225
):
226226
continue
227227
if "RECURRENCE-ID" not in occurrence:
228-
## we should not get here?
229-
import pdb
230-
231-
pdb.set_trace()
232228
occurrence.add("RECURRENCE-ID", occurrence.get("DTSTART").dt)
233229
calendar.add_component(occurrence)
234230

@@ -723,9 +719,7 @@ def _put(self, retry_on_failure=True):
723719
raise error.PutError(errmsg(r))
724720

725721
def _create(self, id=None, path=None, retry_on_failure=True) -> None:
726-
## We're efficiently running the icalendar code through the icalendar
727-
## library. This may cause data modifications and may "unfix"
728-
## https://github.com/python-caldav/caldav/issues/43
722+
## TODO: Find a better method name
729723
self._find_id_path(id=id, path=path)
730724
self._put()
731725

@@ -784,9 +778,10 @@ def save(
784778
obj_type: Optional[str] = None,
785779
increase_seqno: bool = True,
786780
if_schedule_tag_match: bool = False,
781+
only_this_recurrence: bool = True,
782+
all_recurrences: bool = False,
787783
) -> Self:
788-
"""
789-
Save the object, can be used for creation and update.
784+
"""Save the object, can be used for creation and update.
790785
791786
no_overwrite and no_create will check if the object exists.
792787
Those two are mutually exclusive. Some servers don't support
@@ -795,10 +790,36 @@ def save(
795790
obj_type is only used in conjunction with no_overwrite and
796791
no_create.
797792
793+
is_schedule_tag_match is currently ignored. (TODO - fix or remove)
794+
795+
The SEQUENCE should be increased when saving a new version of
796+
the object. If this behaviour is unwanted, then
797+
increase_seqno should be set to False. Also, if SEQUENCE is
798+
not set, then this will be ignored.
799+
800+
The behaviour when saving a single recurrence object to the
801+
server is as far as I can understand not defined in the RFCs,
802+
but all servers I've tested against will overwrite the full
803+
event with the recurrence instance (effectively deleting the
804+
recurrence rule). That's almost for sure not what the caller
805+
intended. only_this_recurrence and all_recurrences only
806+
applies when trying to save a recurrence object. They are by
807+
nature mutually exclusive, but since only_this_recurrence is
808+
True by default, it will be ignored if all_recurrences is set.
809+
810+
If you want to sent the recurrence as it is to the server,
811+
you should set both all_recurrences and only_this_recurrence
812+
to False.
813+
798814
Returns:
799815
* self
800816
801817
"""
818+
## Rather than passing the icalendar data verbatimely, we're
819+
## efficiently running the icalendar code through the icalendar
820+
## library. This may cause data modifications and may "unfix"
821+
## https://github.com/python-caldav/caldav/issues/43
822+
## TODO: think more about this
802823
if not obj_type:
803824
obj_type = self.__class__.__name__.lower()
804825
if (
@@ -812,6 +833,18 @@ def save(
812833

813834
path = self.url.path if self.url else None
814835

836+
def get_self():
837+
self.id = self.id or self.icalendar_component.get("uid")
838+
if self.id:
839+
try:
840+
if obj_type:
841+
return getattr(self.parent, "%s_by_uid" % obj_type)(self.id)
842+
else:
843+
return self.parent.object_by_uid(self.id)
844+
except error.NotFoundError:
845+
return None
846+
return None
847+
815848
if no_overwrite or no_create:
816849
## SECURITY TODO: path names on the server does not
817850
## necessarily map cleanly to UUIDs. We need to do quite
@@ -824,43 +857,86 @@ def save(
824857
## to do a PUT instead of POST when creating new data).
825858
## TODO: the "find id"-logic is duplicated in _create,
826859
## should be refactored
827-
if not self.id:
828-
for component in self.vobject_instance.getChildren():
829-
if hasattr(component, "uid"):
830-
self.id = component.uid.value
860+
existing = get_self()
831861
if not self.id and no_create:
832862
raise error.ConsistencyError("no_create flag was set, but no ID given")
833-
existing = None
834-
## some servers require one to explicitly search for the right kind of object.
835-
## todo: would arguably be nicer to verify the type of the object and take it from there
836-
if not self.id:
837-
methods = []
838-
elif obj_type:
839-
methods = (getattr(self.parent, "%s_by_uid" % obj_type),)
840-
else:
841-
methods = (
842-
self.parent.object_by_uid,
843-
self.parent.event_by_uid,
844-
self.parent.todo_by_uid,
845-
self.parent.journal_by_uid,
863+
if no_overwrite and existing:
864+
raise error.ConsistencyError(
865+
"no_overwrite flag was set, but object already exists"
846866
)
847-
for method in methods:
848-
try:
849-
existing = method(self.id)
850-
if no_overwrite:
851-
raise error.ConsistencyError(
852-
"no_overwrite flag was set, but object already exists"
853-
)
854-
break
855-
except error.NotFoundError:
856-
pass
857867

858868
if no_create and not existing:
859869
raise error.ConsistencyError(
860870
"no_create flag was set, but object does not exists"
861871
)
862872

863-
if increase_seqno and b"SEQUENCE" in to_wire(self.data):
873+
## Save a single recurrence-id and all calendars servers seems
874+
## to overwrite the full object, effectively deleting the
875+
## RRULE. I can't find this behaviour specified in the RFC.
876+
## That's probably not what the caller intended intended.
877+
if (
878+
only_this_recurrence or all_recurrences
879+
) and "RECURRENCE-ID" in self.icalendar_component:
880+
obj = get_self() ## get the full object, not only the recurrence
881+
ici = obj.icalendar_instance # ical instance
882+
if all_recurrences:
883+
occ = obj.icalendar_component ## original calendar component
884+
ncc = self.icalendar_component.copy() ## new calendar component
885+
for prop in ["exdate", "exrule", "rdate", "rrule"]:
886+
if prop in occ:
887+
ncc[prop] = occ[prop]
888+
889+
## dtstart_diff = how much we've moved the time
890+
## TODO: we may easily have timezone problems here and events shifting some hours ...
891+
dtstart_diff = (
892+
ncc.start.astimezone() - ncc["recurrence-id"].dt.astimezone()
893+
)
894+
new_duration = ncc.duration
895+
ncc.pop("dtstart")
896+
ncc.add("dtstart", occ.start + dtstart_diff)
897+
for ep in ("duration", "dtend"):
898+
if ep in ncc:
899+
ncc.pop(ep)
900+
ncc.add("dtend", ncc.start + new_duration)
901+
ncc.pop("recurrence-id")
902+
s = ici.subcomponents
903+
904+
## Replace the "root" subcomponent
905+
comp_idxes = (
906+
i
907+
for i in range(0, len(s))
908+
if not isinstance(s[i], icalendar.Timezone)
909+
)
910+
comp_idx = next(comp_idxes)
911+
s[comp_idx] = ncc
912+
913+
## The recurrence-ids of all objects has to be
914+
## recalculated (this is probably not quite right. If
915+
## we move the time of a daily meeting from 8 to 10,
916+
## then we need to do this. If we move the date of
917+
## the first instance, then probably we shouldn't
918+
## ... oh well ... so many complications)
919+
if dtstart_diff:
920+
for i in comp_idxes:
921+
rid = s[i].pop("recurrence-id")
922+
s[i].add("recurrence-id", rid.dt + dtstart_diff)
923+
924+
return obj.save(increase_seqno=increase_seqno)
925+
if only_this_recurrence:
926+
existing_idx = [
927+
i
928+
for i in range(0, len(ici.subcomponents))
929+
if ici.subcomponents[i].get("recurrence-id")
930+
== self.icalendar_component["recurrence-id"]
931+
]
932+
error.assert_(len(existing_idx) <= 1)
933+
if existing_idx:
934+
ici.subcomponents[existing_idx[0]] = self.icalendar_component
935+
else:
936+
ici.add_component(self.icalendar_component)
937+
return obj.save(increase_seqno=increase_seqno)
938+
939+
if "SEQUENCE" in self.icalendar_component:
864940
seqno = self.icalendar_component.pop("SEQUENCE", None)
865941
if seqno is not None:
866942
self.icalendar_component.add("SEQUENCE", seqno + 1)

caldav/compatibility_hints.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,10 @@
370370
"no_alarmsearch",
371371
"no_events_and_tasks_on_same_calendar",
372372

373+
## TODO: I just discovered that when searching for a date some
374+
## years after a recurring daily event was made, the event does
375+
## not appear.
376+
373377
## extra features not specified in RFC5545
374378
"calendar_order",
375379
"calendar_color"

tests/test_caldav.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2910,6 +2910,101 @@ def testRecurringDateWithExceptionSearch(self):
29102910
tzinfo=None
29112911
) == datetime(2024, 4, 25, 12, 30, 00)
29122912

2913+
def testEditSingleRecurrence(self):
2914+
"""
2915+
It should be possible to fetch a single recurrence from
2916+
the calendar using search and expand, edit it and save it.
2917+
Only the recurrence should be edited, not the rest of the
2918+
event.
2919+
"""
2920+
self.skip_on_compatibility_flag("no_recurring")
2921+
cal = self._fixCalendar()
2922+
2923+
## Create a daily recurring event
2924+
cal.save_event(
2925+
uid="test1",
2926+
summary="daily test",
2927+
dtstart=datetime(2015, 1, 1, 8, 7, 6),
2928+
dtend=datetime(2015, 1, 1, 9, 7, 6),
2929+
rrule={"FREQ": "DAILY"},
2930+
)
2931+
2932+
def search(month):
2933+
"""
2934+
Internal function to find one recurrence object
2935+
"""
2936+
recurrence = cal.search(
2937+
event=True,
2938+
start=datetime(2015, month, 1),
2939+
end=datetime(2015, month, 2),
2940+
expand="client", ## client will be default from 2.0
2941+
)
2942+
assert len(recurrence) == 1
2943+
return recurrence[0]
2944+
2945+
def summary_by_month(month):
2946+
return search(month).icalendar_component["summary"]
2947+
2948+
## Search for a recurrence
2949+
recurrence = search(7)
2950+
2951+
## Modify it and save it
2952+
recurrence.icalendar_component["summary"] = "half a year of daily testing"
2953+
recurrence.save()
2954+
2955+
## Only one day should be affected
2956+
assert summary_by_month(6) == "daily test"
2957+
assert summary_by_month(7) == "half a year of daily testing"
2958+
assert summary_by_month(8) == "daily test"
2959+
2960+
## let's try to set several recurrence exceptions
2961+
recurrence = search(2)
2962+
recurrence.icalendar_component["summary"] = "one month of daily testing"
2963+
recurrence.save()
2964+
2965+
assert summary_by_month(1) == "daily test"
2966+
assert summary_by_month(2) == "one month of daily testing"
2967+
assert summary_by_month(7) == "half a year of daily testing"
2968+
2969+
## Changing any of the exceptions should also work
2970+
recurrence = search(7)
2971+
recurrence.icalendar_component["summary"] = "six months of daily testing"
2972+
recurrence.save()
2973+
assert summary_by_month(7) == "six months of daily testing"
2974+
2975+
## this new feature does not workk on python 3.8. We will soon enough
2976+
## release 2.0 and shed the 3.8-dependency. As for now, just skip the rest of the test.
2977+
if sys.version_info < (3, 9):
2978+
return
2979+
2980+
## parameter all_recurrences should change all recurrences -
2981+
## except February and July
2982+
recurrence = search(9)
2983+
recurrence.icalendar_component["summary"] = "daily testing"
2984+
recurrence.save(all_recurrences=True)
2985+
assert summary_by_month(1) == "daily testing"
2986+
assert summary_by_month(2) == "one month of daily testing"
2987+
assert summary_by_month(3) == "daily testing"
2988+
assert summary_by_month(7) == "six months of daily testing"
2989+
2990+
## Last ... let's change the dtend and dtstart of the recurrence
2991+
recurrence = search(9)
2992+
recurrence.icalendar_component.pop("dtstart")
2993+
recurrence.icalendar_component.add("dtstart", datetime(2015, 9, 1, 8, 0, 0))
2994+
recurrence.icalendar_component.pop("dtend")
2995+
recurrence.icalendar_component.add("dtend", datetime(2015, 9, 1, 10, 0, 0))
2996+
recurrence.save(all_recurrences=True)
2997+
2998+
recurrence = search(8)
2999+
assert (
3000+
recurrence.icalendar_component.start.astimezone()
3001+
== datetime(2015, 8, 1, 8, 0, 0).astimezone()
3002+
)
3003+
assert (
3004+
recurrence.icalendar_component.end.astimezone()
3005+
== datetime(2015, 8, 1, 10, 0, 0).astimezone()
3006+
)
3007+
29133008
def testOffsetURL(self):
29143009
"""
29153010
pass a URL pointing to a calendar or a user to the DAVClient class,

0 commit comments

Comments
 (0)