Skip to content

Commit 13daaa2

Browse files
committed
feat: freebusy scheduling
The scheduling freebusy-requests were completely untested and didn't work at all. logic was human-written, test-code by Claude Prompt: look into https://datatracker.ietf.org/doc/html/rfc6638#appendix-B.5 and make a pure unit test with a mocked-up response to a freebusy scheduling request, exercising the handling part of it. This will break with a NotImplementedError as for now. Only fix the test, do not fix the code logic. Consider the TODO-comment in response.py, line 247, and give me an opinion on weather it makes sense to reuse the _find_objects_and_props for scheduling response or if it's better to create a dedicated separate method for this.
1 parent ee04b13 commit 13daaa2

10 files changed

Lines changed: 276 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
3535

3636
### Test framework, compatibility hints, documentation, examples
3737

38+
* Open-ended time-range search compatibility hints: new `search.time-range.open`, `search.time-range.open.end`, `search.time-range.open.start`, and `search.time-range.open.start.duration` features (RFC4791 section 9.9). Old `no_search_openended` flag and `search.time-range.todo.duration`/`search.time-range.todo.open-start` features migrated. `testTodoSearch` updated to use `is_supported("search.time-range.open.end")` instead of the old compatibility flag.
3839
* RFC 6638 scheduling feature-detection infrastructure: new `scheduling`, `scheduling.mailbox`, and `scheduling.calendar-user-address-set` compatibility hints; legacy `no_scheduling` flags migrated. Default scheduling hints set for all the servers tested.
3940
* Calendar owner example (`examples/calendar_owner_examples.py`) demonstrating how to retrieve the owner of a calendar via `DAV:owner` and resolve their calendar-user address. `testFindCalendarOwner` now exercises the full owner → principal → `get_vcal_address()` chain. Closes https://github.com/python-caldav/caldav/issues/544
4041
* `testInviteAndRespond` implemented end-to-end: organizer creates an event, invites an attendee, attendee accepts, and the organizer verifies the updated `PARTSTAT`. Per-server compatibility flags applied for known quirks (Baikal, Cyrus, SOGo).

caldav/collection.py

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -477,15 +477,17 @@ def calendars(self) -> list["Calendar"]:
477477
"""
478478
return self.get_calendars()
479479

480-
def freebusy_request(self, dtstart, dtend, attendees):
480+
## TODO: we have code in lib.vcal for constructing icalendar objects,
481+
## and from icalendar 7 there is also code in the icalendar library
482+
## for this. The cruft below for constructing the request should be
483+
## eliminated. Also, the async diversion should happen closer to the
484+
## bottom of the method, reducing the need of duplicating code
485+
def freebusy_request(self, dtstart, dtend, attendees) -> dict[str, FreeBusy]:
481486
"""Sends a freebusy-request for some attendee to the server
482487
as per RFC6638.
483488
484489
For async clients, returns a coroutine that must be awaited.
485490
"""
486-
if self.is_async_client:
487-
return self._async_freebusy_request(dtstart, dtend, attendees)
488-
489491
freebusy_ical = icalendar.Calendar()
490492
freebusy_ical.add("prodid", "-//tobixen/python-caldav//EN")
491493
freebusy_ical.add("version", "2.0")
@@ -498,42 +500,34 @@ def freebusy_request(self, dtstart, dtend, attendees):
498500
freebusy_comp.add("dtend", dtend)
499501
freebusy_ical.add_component(freebusy_comp)
500502
outbox = self.schedule_outbox()
501-
caldavobj = FreeBusy(data=freebusy_ical, parent=outbox)
502-
caldavobj.add_organizer()
503+
caldavobj = FreeBusy(data=freebusy_ical, parent=self)
503504
for attendee in attendees:
504505
caldavobj.add_attendee(attendee, no_default_parameters=True)
505506

507+
if self.is_async_client:
508+
return self._async_freebusy_request(outbox, caldavobj)
509+
510+
caldavobj.add_organizer()
511+
506512
response = self.client.post(
507513
outbox.url,
508514
caldavobj.data,
509515
headers={"Content-Type": "text/calendar; charset=utf-8"},
510516
)
511-
return response._find_objects_and_props()
517+
return response._parse_scheduling_response_objects(parent=self)
512518

513-
async def _async_freebusy_request(self, dtstart, dtend, attendees):
519+
async def _async_freebusy_request(self, outbox, fb_obj) -> dict:
514520
"""Async implementation of freebusy_request() for async clients."""
515-
freebusy_ical = icalendar.Calendar()
516-
freebusy_ical.add("prodid", "-//tobixen/python-caldav//EN")
517-
freebusy_ical.add("version", "2.0")
518-
freebusy_ical.add("method", "REQUEST")
519-
uid = uuid.uuid4()
520-
freebusy_comp = icalendar.FreeBusy()
521-
freebusy_comp.add("uid", uid)
522-
freebusy_comp.add("dtstamp", datetime.now())
523-
freebusy_comp.add("dtstart", dtstart)
524-
freebusy_comp.add("dtend", dtend)
525-
freebusy_ical.add_component(freebusy_comp)
526-
outbox = await self._async_schedule_outbox()
527-
caldavobj = FreeBusy(data=freebusy_ical, parent=outbox)
528-
await caldavobj.add_organizer()
529-
for attendee in attendees:
530-
caldavobj.add_attendee(attendee, no_default_parameters=True)
531-
response = await self.client.post(
532-
outbox.url,
533-
caldavobj.data,
534-
headers={"Content-Type": "text/calendar; charset=utf-8"},
535-
)
536-
return response._find_objects_and_props()
521+
## TODO: could we have common headers as global variable?
522+
headers = {"Content-Type": "text/calendar; charset=utf-8"}
523+
outbox = await outbox
524+
## TODO: it's really bad that arbitrary methods returns
525+
## a coroutine in async mode. It's needed to make it much
526+
## more clear what methods involves I/O and what methods
527+
## doesn't involve I/O in 4.0
528+
await fb_obj.add_organizer()
529+
response = await self.client.post(outbox.url, fb_obj.data, headers)
530+
return response._parse_scheduling_response_objects(parent=self)
537531

538532
def calendar_user_address_set(self) -> list[str | None]:
539533
"""
@@ -1473,7 +1467,7 @@ def freebusy_request(self, start: datetime, end: datetime) -> "FreeBusy":
14731467
Returns:
14741468
[FreeBusy(), ...]
14751469
"""
1476-
1470+
## TODO: async variant?
14771471
root = cdav.FreeBusyQuery() + [cdav.TimeRange(start, end)]
14781472
response = self._query(root, 1, "report")
14791473
return FreeBusy(self, response.raw)

caldav/datastate.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
representations of calendar data (raw string, icalendar object, vobject object).
66
77
See https://github.com/python-caldav/caldav/issues/613 for design discussion.
8+
9+
TODO: verify that we have sufficient test coverage - both through unit tests
10+
and integration tests
811
"""
912

1013
from __future__ import annotations
@@ -61,18 +64,18 @@ def get_uid(self) -> str | None:
6164
"""
6265
cal = self.get_icalendar_copy()
6366
for comp in cal.subcomponents:
64-
if comp.name in ("VEVENT", "VTODO", "VJOURNAL") and "UID" in comp:
67+
if comp.name in ("VEVENT", "VTODO", "VJOURNAL", "FREEBUSY") and "UID" in comp:
6568
return str(comp["UID"])
6669
return None
6770

6871
def get_component_type(self) -> str | None:
69-
"""Get the component type (VEVENT, VTODO, VJOURNAL) without full parsing.
72+
"""Get the component type (VEVENT, VTODO, VJOURNAL, FREEBUSY) without full parsing.
7073
7174
Default implementation parses the data, but subclasses can optimize.
7275
"""
7376
cal = self.get_icalendar_copy()
7477
for comp in cal.subcomponents:
75-
if comp.name in ("VEVENT", "VTODO", "VJOURNAL"):
78+
if comp.name in ("VEVENT", "VTODO", "VJOURNAL", "FREEBUSY"):
7679
return comp.name
7780
return None
7881

@@ -146,6 +149,8 @@ def get_component_type(self) -> str | None:
146149
return "VTODO"
147150
elif "BEGIN:VJOURNAL" in self._data:
148151
return "VJOURNAL"
152+
elif "BEGIN:FREEBUSY" in self._data:
153+
return "VFREEBUSY"
149154
return None
150155

151156

@@ -182,13 +187,13 @@ def get_vobject_copy(self) -> vobject.base.Component:
182187

183188
def get_uid(self) -> str | None:
184189
for comp in self._calendar.subcomponents:
185-
if comp.name in ("VEVENT", "VTODO", "VJOURNAL") and "UID" in comp:
190+
if comp.name in ("VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY") and "UID" in comp:
186191
return str(comp["UID"])
187192
return None
188193

189194
def get_component_type(self) -> str | None:
190195
for comp in self._calendar.subcomponents:
191-
if comp.name in ("VEVENT", "VTODO", "VJOURNAL"):
196+
if comp.name in ("VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"):
192197
return comp.name
193198
return None
194199

@@ -232,6 +237,8 @@ def get_uid(self) -> str | None:
232237
return str(self._vobject.vtodo.uid.value)
233238
elif hasattr(self._vobject, "vjournal"):
234239
return str(self._vobject.vjournal.uid.value)
240+
elif hasattr(self._vobject, "vfreebusy"):
241+
return str(self._vobject.vfreebusy.uid.value)
235242
except AttributeError:
236243
pass
237244
return None
@@ -243,4 +250,6 @@ def get_component_type(self) -> str | None:
243250
return "VTODO"
244251
elif hasattr(self._vobject, "vjournal"):
245252
return "VJOURNAL"
253+
elif hasattr(self._vobject, "vfreebusy"):
254+
return "VFREEBUSY"
246255
return None

caldav/elements/cdav.py

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ def _to_utc_date_string(ts):
4141
return ts.strftime("%Y%m%dT%H%M%SZ")
4242

4343

44+
## TODO: add RFC references to every class, like it's done in the Response class
45+
46+
4447
# Operations
4548
class CalendarQuery(BaseElement):
4649
tag: ClassVar[str] = ns("C", "calendar-query")
@@ -58,14 +61,6 @@ class CalendarMultiGet(BaseElement):
5861
tag: ClassVar[str] = ns("C", "calendar-multiget")
5962

6063

61-
class ScheduleInboxURL(BaseElement):
62-
tag: ClassVar[str] = ns("C", "schedule-inbox-URL")
63-
64-
65-
class ScheduleOutboxURL(BaseElement):
66-
tag: ClassVar[str] = ns("C", "schedule-outbox-URL")
67-
68-
6964
# Filters
7065
class Filter(BaseElement):
7166
tag: ClassVar[str] = ns("C", "filter")
@@ -143,13 +138,6 @@ class Comp(NamedBaseElement):
143138
tag: ClassVar[str] = ns("C", "comp")
144139

145140

146-
# Uhhm ... can't find any references to calendar-collection in rfc4791.txt
147-
# and newer versions of baikal gives 403 forbidden when this one is
148-
# encountered
149-
# class CalendarCollection(BaseElement):
150-
# tag = ns("C", "calendar-collection")
151-
152-
153141
# Properties
154142
class CalendarUserAddressSet(BaseElement):
155143
tag: ClassVar[str] = ns("C", "calendar-user-address-set")
@@ -208,5 +196,47 @@ class Allprop(BaseElement):
208196
tag: ClassVar[str] = ns("C", "allprop")
209197

210198

199+
# Scheduling
200+
201+
211202
class ScheduleTag(BaseElement):
212203
tag: ClassVar[str] = ns("C", "schedule-tag")
204+
205+
206+
class ScheduleInboxURL(BaseElement):
207+
tag: ClassVar[str] = ns("C", "schedule-inbox-URL")
208+
209+
210+
class ScheduleOutboxURL(BaseElement):
211+
tag: ClassVar[str] = ns("C", "schedule-outbox-URL")
212+
213+
214+
class ScheduleResponse(BaseElement):
215+
tag: ClassVar[str] = ns("C", "schedule-response")
216+
217+
218+
class Response(BaseElement):
219+
"""
220+
https://datatracker.ietf.org/doc/html/rfc6638#section-10.2
221+
Child of schedule-response
222+
"""
223+
224+
tag: ClassVar[str] = ns("C", "response")
225+
226+
227+
class Recipient(BaseElement):
228+
"""
229+
https://datatracker.ietf.org/doc/html/rfc6638#section-10.3
230+
Child of response
231+
"""
232+
233+
tag: ClassVar[str] = ns("C", "recipient")
234+
235+
236+
class RequestStatus(BaseElement):
237+
"""
238+
https://datatracker.ietf.org/doc/html/rfc6638#section-10.4
239+
Child of response
240+
"""
241+
242+
tag: ClassVar[str] = ns("C", "request-status")

caldav/elements/dav.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from .base import BaseElement, ValuedBaseElement
77

8+
## TODO: add RFC references to every class, consistently with the cdav.py
9+
810

911
# Operations
1012
class Propfind(BaseElement):

caldav/elements/ical.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from .base import ValuedBaseElement
77

88

9-
# Properties
9+
# Properties - those are non-standard but implemented in several calendar servers
1010
class CalendarColor(ValuedBaseElement):
1111
tag: ClassVar[str] = ns("I", "calendar-color")
1212

caldav/protocol/xml_parsers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ def _parse_calendar_query_response(
162162
return results
163163

164164

165+
## TODO: the purpose of the xml_parsers was to consolidate common code to be used by sync and async code paths, to avoid duplicated code. Why cannot this code snippet be used for async? The code here is very similar to _parse_calendar_query_response - we should consolidate common code
165166
def _parse_sync_collection_response(
166167
body: bytes,
167168
status_code: int = 207,

caldav/response.py

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
from lxml import etree
1414
from lxml.etree import _Element
1515

16-
from caldav.elements import dav
16+
from caldav.calendarobjectresource import FreeBusy
17+
from caldav.elements import cdav, dav
1718
from caldav.elements.base import BaseElement
1819
from caldav.lib import error
1920
from caldav.lib.python_utilities import to_normal_str
@@ -230,17 +231,90 @@ def _parse_response(self, response: _Element) -> tuple[str, list[_Element], Any
230231
error.assert_("404" in status)
231232
return (cast(str, href), propstats, status)
232233

233-
## TODO: there is currently quite some overlapping with the protocol.xml_parsers
234-
## we should refactor
234+
def _parse_scheduling_response_objects(self, parent) -> dict:
235+
"""Parses an RFC6638 freebusy scheduling request response
236+
237+
The response from the server is asserted to be a
238+
scheduling-response, with freebusy status for one or more wanted
239+
attendee - potentially with error status for all or some
240+
of the wanted attendees.
241+
242+
TODO: some asserts here - should make better error handling
243+
244+
Returns:
245+
Dict with:
246+
* email addresses -> FreeBusy status (raw data)
247+
* errors - dict with email addresses -> error messages
248+
249+
"""
250+
self.objects = {}
251+
self.objects["errors"] = {}
252+
assert self.tree.tag == cdav.ScheduleResponse.tag
253+
for response in self.tree:
254+
assert response.tag == cdav.Response.tag
255+
parsed_response = self._parse_scheduling_response(response)
256+
for x in parsed_response:
257+
if x.endswith(":err"):
258+
self.objects["errors"][x[:-4]] = parsed_response[x]
259+
else:
260+
self.objects[x] = FreeBusy(parent=parent, data=parsed_response[x])
261+
262+
return self.objects
263+
264+
def _parse_scheduling_response(self, response) -> dict[str, str]:
265+
"""
266+
TODO: lots of asserts here - should make better error handling
267+
268+
Parses one attendee response from a RFC6638 freebusy scheduling request
269+
270+
Returns:
271+
* ``{ recipient => calendar_data }`` if everything is OK,
272+
* ``{f"{recipient}:err": status}`` if things are not OK,
273+
* a dict with both elements if things are partially OK
274+
"""
275+
ret = {}
276+
recipient = None
277+
status = None
278+
calendar_data = None
279+
for x in response:
280+
if x.tag == cdav.Recipient.tag:
281+
if len(x) == 1:
282+
assert x[0].tag == dav.Href.tag
283+
recipient = x[0].text
284+
else:
285+
recipient = x.text
286+
elif x.tag == cdav.RequestStatus.tag:
287+
status = x.text
288+
elif x.tag == cdav.CalendarData.tag:
289+
calendar_data = x.text
290+
else:
291+
raise error.DAVError(f"unexpected attribute {x.tag}")
292+
assert recipient
293+
assert status
294+
if not status.startswith("2.0"):
295+
ret[f"{recipient}:err"] = status
296+
if calendar_data:
297+
ret[recipient] = calendar_data
298+
return ret
299+
300+
## TODO: there is currently quite some overlapping with the
301+
## protocol.xml_parsers we should refactor. I'm not 100% sure the
302+
## protocol.xml_parsers layer is a better approach. Look for more
303+
## cases of old code that was is still remaining after the
304+
## protocol layer refactoring
235305
def _find_objects_and_props(self) -> dict[str, dict[str, _Element]]:
236306
"""Internal implementation of find_objects_and_props without deprecation warning."""
237307
self.objects: dict[str, dict[str, _Element]] = {}
238308
self.statuses: dict[str, str] = {}
239309

310+
## TODO: the schedule_tag is not used anywhere as for now
311+
## TODO: should it be set somewhere else? (now it's not
312+
## covered by the scheduling freebusy requests)
240313
if "Schedule-Tag" in self.headers:
241314
self.schedule_tag = self.headers["Schedule-Tag"]
242315

243316
responses = self._strip_to_multistatus()
317+
244318
for r in responses:
245319
if r.tag == dav.SyncToken.tag:
246320
self.sync_token = r.text
@@ -312,7 +386,7 @@ def _expand_simple_prop(
312386
if proptag in props_found:
313387
prop_xml = props_found[proptag]
314388
for item in prop_xml.items():
315-
if proptag == "{urn:ietf:params:xml:ns:caldav}calendar-data":
389+
if proptag == cdav.CalendarData.tag:
316390
if (
317391
item[0].lower().endswith("content-type")
318392
and item[1].lower() == "text/calendar"

0 commit comments

Comments
 (0)