Skip to content

Commit 867d0a9

Browse files
authored
Methods to verify and fix missing reverse relationships
1 parent 272792b commit 867d0a9

2 files changed

Lines changed: 168 additions & 59 deletions

File tree

caldav/objects.py

Lines changed: 140 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
A "DAV object" is anything we get from the caldav server or push into the
44
caldav server, notably principal, calendars and calendar events.
55
6-
(This file has become huge and will be split up prior to the next
7-
release. I think it makes sense moving the CalendarObjectResource
8-
class hierarchy into a separate file)
6+
(This file has become huge and will be split up when the list of
7+
pull requests have been cleared. I think it makes sense moving the
8+
CalendarObjectResource class hierarchy into a separate file)
99
"""
1010
import re
1111
import sys
@@ -43,7 +43,7 @@ class hierarchy into a separate file)
4343
from caldav.lib.python_utilities import to_wire
4444

4545
try:
46-
from typing import ClassVar, Optional, Union
46+
from typing import ClassVar, Optional, Union, Type
4747

4848
TimeStamp = Optional[Union[date, datetime]]
4949
except:
@@ -849,14 +849,14 @@ def _use_or_create_ics(self, ical, objtype, **ical_data):
849849
return vcal.create_ical(objtype=objtype, **ical_data)
850850
return ical
851851

852-
## TODO: consolidate save_* - too much code duplication here
853-
def save_event(
852+
def save_object(
854853
self,
854+
objclass: Type[DAVObject],
855855
ical: Optional[str] = None,
856856
no_overwrite: bool = False,
857857
no_create: bool = False,
858858
**ical_data,
859-
) -> "Event":
859+
) -> "CalendarResourceObject":
860860
"""
861861
Add a new event to the calendar, with the given ical.
862862
@@ -866,73 +866,48 @@ def save_event(
866866
* no_create - don't create a new object, existing calendar objects should be updated
867867
* ical_data - passed to lib.vcal.create_ical
868868
"""
869-
e = Event(
869+
o = objclass(
870870
self.client,
871-
data=self._use_or_create_ics(ical, objtype="VEVENT", **ical_data),
871+
data=self._use_or_create_ics(
872+
ical, objtype=f"V{objclass.__name__.upper()}", **ical_data
873+
),
872874
parent=self,
873875
)
874-
e.save(no_overwrite=no_overwrite, no_create=no_create, obj_type="event")
875-
self._handle_relations(e.id, ical_data)
876-
return e
876+
o = o.save(no_overwrite=no_overwrite, no_create=no_create)
877+
## TODO: Saving nothing is currently giving an object with None as URL.
878+
## This should probably be changed in some future version to raise an error
879+
## See also CalendarObjectResource.save()
880+
if o.url is not None:
881+
o._handle_reverse_relations(fix=True)
882+
return o
877883

878-
def save_todo(
879-
self,
880-
ical: Optional[str] = None,
881-
no_overwrite: bool = False,
882-
no_create: bool = False,
883-
**ical_data,
884-
) -> "Todo":
884+
## It could still be possible to refactor even more, but
885+
## readability would be harder
886+
def save_event(self, *largs, **kwargs) -> "Event":
885887
"""
886-
Add a new task to the calendar, with the given ical.
887-
888-
Parameters:
889-
* ical - ical object (text)
888+
See save_object
890889
"""
891-
t = Todo(
892-
self.client,
893-
data=self._use_or_create_ics(ical, objtype="VTODO", **ical_data),
894-
parent=self,
895-
)
896-
t.save(no_overwrite=no_overwrite, no_create=no_create, obj_type="todo")
897-
self._handle_relations(t.id, ical_data)
898-
return t
890+
return self.save_object(Event, *largs, **kwargs)
899891

900-
def save_journal(
901-
self,
902-
ical: Optional[str] = None,
903-
no_overwrite: bool = False,
904-
no_create: bool = False,
905-
**ical_data,
906-
) -> "Journal":
892+
def save_todo(self, *largs, **kwargs) -> "Todo":
907893
"""
908-
Add a new journal entry to the calendar, with the given ical.
909-
910-
Parameters:
911-
* ical - ical object (text)
894+
See save_object
912895
"""
913-
j = Journal(
914-
self.client,
915-
data=self._use_or_create_ics(ical, objtype="VJOURNAL", **ical_data),
916-
parent=self,
917-
)
918-
j.save(no_overwrite=no_overwrite, no_create=no_create, obj_type="journal")
919-
self._handle_relations(j.id, ical_data)
920-
return j
896+
return self.save_object(Todo, *largs, **kwargs)
921897

922-
def _handle_relations(self, uid, ical_data) -> None:
923-
for reverse_reltype, other_uid in [
924-
("parent", x) for x in ical_data.get("child", ())
925-
] + [("child", x) for x in ical_data.get("parent", ())]:
926-
other = self.object_by_uid(other_uid)
927-
other.set_relation(other=uid, reltype=reverse_reltype, set_reverse=False)
898+
def save_journal(self, *largs, **kwargs) -> "Journal":
899+
"""
900+
See save_object
901+
"""
902+
return self.save_object(Journal, *largs, **kwargs)
928903

929904
## legacy aliases
930905
## TODO: should be deprecated
931906

932907
## TODO: think more through this - is `save_foo` better than `add_foo`?
933908
## `save_foo` should not be used for updating existing content on the
934909
## calendar!
935-
910+
add_object = save_object
936911
add_event = save_event
937912
add_todo = save_todo
938913
add_journal = save_journal
@@ -1938,10 +1913,24 @@ class CalendarObjectResource(DAVObject):
19381913
event, a todo-item, a journal entry, or a free/busy entry
19391914
"""
19401915

1941-
RELTYPE_REVERSER: ClassVar = {
1916+
## There is also STARTTOFINISH, STARTTOSTART and FINISHTOFINISH in RFC9253,
1917+
## those do not seem to have any reverse
1918+
## (FINISHTOSTART and STARTTOFINISH may seem like reverse relations, but
1919+
## as I read the RFC, FINISHTOSTART seems like the reverse of DEPENDS-ON)
1920+
## (STARTTOSTART and FINISHTOFINISH may also seem like symmetric relations,
1921+
## meaning they are their own reverse, but as I read the RFC they are
1922+
## asymmetric)
1923+
RELTYPE_REVERSE_MAP: ClassVar = {
19421924
"PARENT": "CHILD",
19431925
"CHILD": "PARENT",
19441926
"SIBLING": "SIBLING",
1927+
## this is how Tobias Brox inteprets RFC9253:
1928+
"DEPENDS-ON": "FINISHTOSTART",
1929+
"FINISHTOSTART": "DEPENDENT",
1930+
## next/first is a special case, linked list
1931+
## it needs special handling when length of list<>2
1932+
# "NEXT": "FIRST",
1933+
# "FIRST": "NEXT",
19451934
}
19461935

19471936
_ENDPARAM = None
@@ -2072,7 +2061,9 @@ def set_relation(
20722061
if set_reverse:
20732062
other = self.parent.object_by_uid(uid)
20742063
if set_reverse:
2075-
reltype_reverse = self.RELTYPE_REVERSER[reltype]
2064+
## TODO: special handling of NEXT/FIRST.
2065+
## STARTTOFINISH does not have any equivalent "reverse".
2066+
reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype]
20762067
other.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)
20772068

20782069
existing_relation = self.icalendar_component.get("related-to", None)
@@ -2119,6 +2110,10 @@ def get_relatives(
21192110
21202111
TODO: this is partially overlapped by plann.lib._relships_by_type
21212112
in the plann tool. Should consolidate the code.
2113+
2114+
TODO: should probably return some kind of object instead of a weird dict structure.
2115+
(but due to backward compatibility requirement, such an object should behave like
2116+
the current dict)
21222117
"""
21232118
ret = defaultdict(set)
21242119
relations = self.icalendar_component.get("RELATED-TO", [])
@@ -2156,6 +2151,88 @@ def get_relatives(
21562151

21572152
return ret
21582153

2154+
def _set_reverse_relation(self, other, reltype):
2155+
## TODO: handle RFC9253 better! Particularly next/first-lists
2156+
reverse_reltype = self.RELTYPE_REVERSE_MAP.get(reltype)
2157+
if not reverse_reltype:
2158+
logging.error(
2159+
"Reltype %s not supported in object uid %s" % (reltype, self.id)
2160+
)
2161+
return
2162+
other.set_relation(self, reverse_reltype, other)
2163+
2164+
def _verify_reverse_relation(self, other, reltype) -> tuple:
2165+
revreltype = self.RELTYPE_REVERSE_MAP[reltype]
2166+
## TODO: special case FIRST/NEXT needs special handling
2167+
other_relations = other.get_relatives(
2168+
fetch_objects=False, reltypes={revreltype}
2169+
)
2170+
if not str(self.icalendar_component["uid"]) in other_relations[revreltype]:
2171+
## I don't remember why we need to return a tuple
2172+
## but it's propagated through the "public" methods, so we'll
2173+
## have to leave it like this.
2174+
return (other, revreltype)
2175+
return False
2176+
2177+
def _handle_reverse_relations(
2178+
self, verify: bool = False, fix: bool = False, pdb: bool = False
2179+
) -> list:
2180+
"""
2181+
Goes through all relations and verifies that the return relation is set
2182+
if verify is set:
2183+
Returns a list of objects missing a reverse.
2184+
Use public method check_reverse_relations instead
2185+
if verify and fix is set:
2186+
Fixup all objects missing a reverse.
2187+
Use public method fix_reverse_relations instead.
2188+
If fix but not verify is set:
2189+
Assume all reverse relations are missing.
2190+
Used internally when creating new objects.
2191+
"""
2192+
ret = []
2193+
assert verify or fix
2194+
relations = self.get_relatives()
2195+
for reltype in relations:
2196+
for other in relations[reltype]:
2197+
if verify:
2198+
foobar = self._verify_reverse_relation(other, reltype)
2199+
if foobar:
2200+
ret.append(foobar)
2201+
if pdb:
2202+
breakpoint()
2203+
if fix:
2204+
self._set_reverse_relation(other, reltype)
2205+
elif fix:
2206+
self._set_reverse_relation(other, reltype)
2207+
return ret
2208+
2209+
def check_reverse_relations(self, pdb: bool = False) -> List[tuple]:
2210+
"""
2211+
Will verify that for all the objects we point at though
2212+
the RELATED-TO property, the other object points back to us as
2213+
well.
2214+
2215+
Returns a list of tuples. Each tuple contains an object that
2216+
do not point back as expected, and the expected reltype
2217+
"""
2218+
return self._handle_reverse_relations(verify=True, fix=False, pdb=pdb)
2219+
2220+
def fix_reverse_relations(self, pdb: bool = False) -> list:
2221+
"""
2222+
Will ensure that for all the objects we point at though
2223+
the RELATED-TO property, the other object points back to us as
2224+
well.
2225+
2226+
Returns a list of tuples. Each tuple contains an object that
2227+
did not point back as expected, and the expected reltype
2228+
"""
2229+
return self._handle_reverse_relations(verify=True, fix=True, pdb=pdb)
2230+
2231+
## TODO: fix this (and consolidate with _handle_relations / set_relation?)
2232+
# def ensure_reverse_relations(self):
2233+
# missing_relations = self.check_reverse_relations()
2234+
# ...
2235+
21592236
def _get_icalendar_component(self, assert_one=False):
21602237
"""Returns the icalendar subcomponent - which should be an
21612238
Event, Journal, Todo or FreeBusy from the icalendar class
@@ -2526,11 +2603,15 @@ def save(
25262603
* self
25272604
25282605
"""
2606+
if not obj_type:
2607+
obj_type = self.__class__.__name__.lower()
25292608
if (
25302609
self._vobject_instance is None
25312610
and self._data is None
25322611
and self._icalendar_instance is None
25332612
):
2613+
## TODO: This makes no sense. We should probably raise an error.
2614+
## But the behaviour should be officially deprecated first.
25342615
return self
25352616

25362617
path = self.url.path if self.url else None

tests/test_caldav.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,6 +1758,34 @@ def testCreateChildParent(self):
17581758
assert len(foo["PARENT"]) == 1
17591759
foo = parent_.get_relatives(relfilter=lambda x: x.params.get("GAP"))
17601760

1761+
## verify the check_reverse_relations method (TODO: move to a separate test)
1762+
assert parent_.check_reverse_relations() == []
1763+
assert child_.check_reverse_relations() == []
1764+
assert grandparent_.check_reverse_relations() == []
1765+
1766+
## My grandchild is also my child ... that sounds fishy
1767+
grandparent_.set_relation(child, reltype="CHILD", set_reverse=False)
1768+
1769+
## The check_reverse should tell that something is amiss
1770+
missing_parent = grandparent_.check_reverse_relations()
1771+
assert len(missing_parent) == 1
1772+
assert missing_parent[0][0].icalendar_component["uid"] == "ctuid2"
1773+
assert missing_parent[0][1] == "PARENT"
1774+
## But only when run on the grandparent. The child is blissfully
1775+
## unaware who the second parent is (even if reloading it).
1776+
child_.load()
1777+
assert child_.check_reverse_relations() == []
1778+
1779+
## We should be able to fix the missing parent
1780+
grandparent_.fix_reverse_relations()
1781+
assert not grandparent_.check_reverse_relations()
1782+
1783+
## TODO:
1784+
## This does not work out. A relation to some object that is not on
1785+
## the calendar is not flagged - but perhaps it shouldn't be flagged?
1786+
# child.delete()
1787+
# assert parent_.check_reverse_relations()
1788+
17611789
def testSetDue(self):
17621790
self.skip_on_compatibility_flag("read_only")
17631791
self.skip_on_compatibility_flag("no_todo")

0 commit comments

Comments
 (0)