|
|
@@ -28,6 +28,7 @@ from typing import (Any, Callable, Iterable, Iterator, List, Optional,
|
|
|
Sequence, Tuple, Union)
|
|
|
from urllib.parse import unquote, urlparse
|
|
|
|
|
|
+import vobject
|
|
|
import vobject.base
|
|
|
from vobject.base import ContentLine
|
|
|
|
|
|
@@ -38,11 +39,110 @@ from radicale.item import filter as radicale_filter
|
|
|
from radicale.log import logger
|
|
|
|
|
|
|
|
|
+def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
|
|
+ collection: storage.BaseCollection, encoding: str,
|
|
|
+ unlock_storage_fn: Callable[[], None],
|
|
|
+ max_occurrence: int
|
|
|
+ ) -> Tuple[int, Union[ET.Element, str]]:
|
|
|
+ # NOTE: this function returns both an Element and a string because
|
|
|
+ # free-busy reports are an edge-case on the return type according
|
|
|
+ # to the spec.
|
|
|
+
|
|
|
+ multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
|
|
|
+ if xml_request is None:
|
|
|
+ return client.MULTI_STATUS, multistatus
|
|
|
+ root = xml_request
|
|
|
+ if (root.tag == xmlutils.make_clark("C:free-busy-query") and
|
|
|
+ collection.tag != "VCALENDAR"):
|
|
|
+ logger.warning("Invalid REPORT method %r on %r requested",
|
|
|
+ xmlutils.make_human_tag(root.tag), path)
|
|
|
+ return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
|
|
|
+
|
|
|
+ time_range_element = root.find(xmlutils.make_clark("C:time-range"))
|
|
|
+ assert isinstance(time_range_element, ET.Element)
|
|
|
+
|
|
|
+ # Build a single filter from the free busy query for retrieval
|
|
|
+ # TODO: filter for VFREEBUSY in additional to VEVENT but
|
|
|
+ # test_filter doesn't support that yet.
|
|
|
+ vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
|
|
|
+ attrib={'name': 'VEVENT'})
|
|
|
+ vevent_cf_element.append(time_range_element)
|
|
|
+ vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
|
|
|
+ attrib={'name': 'VCALENDAR'})
|
|
|
+ vcalendar_cf_element.append(vevent_cf_element)
|
|
|
+ filter_element = ET.Element(xmlutils.make_clark("C:filter"))
|
|
|
+ filter_element.append(vcalendar_cf_element)
|
|
|
+ filters = (filter_element,)
|
|
|
+
|
|
|
+ # First pull from storage
|
|
|
+ retrieved_items = list(collection.get_filtered(filters))
|
|
|
+ # !!! Don't access storage after this !!!
|
|
|
+ unlock_storage_fn()
|
|
|
+
|
|
|
+ cal = vobject.iCalendar()
|
|
|
+ collection_tag = collection.tag
|
|
|
+ while retrieved_items:
|
|
|
+ # Second filtering before evaluating occurrences.
|
|
|
+ # ``item.vobject_item`` might be accessed during filtering.
|
|
|
+ # Don't keep reference to ``item``, because VObject requires a lot of
|
|
|
+ # memory.
|
|
|
+ item, filter_matched = retrieved_items.pop(0)
|
|
|
+ if not filter_matched:
|
|
|
+ try:
|
|
|
+ if not test_filter(collection_tag, item, filter_element):
|
|
|
+ continue
|
|
|
+ except ValueError as e:
|
|
|
+ raise ValueError("Failed to free-busy filter item %r from %r: %s" %
|
|
|
+ (item.href, collection.path, e)) from e
|
|
|
+ except Exception as e:
|
|
|
+ raise RuntimeError("Failed to free-busy filter item %r from %r: %s" %
|
|
|
+ (item.href, collection.path, e)) from e
|
|
|
+
|
|
|
+ fbtype = None
|
|
|
+ if item.component_name == 'VEVENT':
|
|
|
+ transp = getattr(item.vobject_item.vevent, 'transp', None)
|
|
|
+ if transp and transp.value != 'OPAQUE':
|
|
|
+ continue
|
|
|
+
|
|
|
+ status = getattr(item.vobject_item.vevent, 'status', None)
|
|
|
+ if not status or status.value == 'CONFIRMED':
|
|
|
+ fbtype = 'BUSY'
|
|
|
+ elif status.value == 'CANCELLED':
|
|
|
+ fbtype = 'FREE'
|
|
|
+ elif status.value == 'TENTATIVE':
|
|
|
+ fbtype = 'BUSY-TENTATIVE'
|
|
|
+ else:
|
|
|
+ # Could do fbtype = status.value for x-name, I prefer this
|
|
|
+ fbtype = 'BUSY'
|
|
|
+
|
|
|
+ # TODO: coalesce overlapping periods
|
|
|
+
|
|
|
+ if max_occurrence > 0:
|
|
|
+ n_occurrences = max_occurrence+1
|
|
|
+ else:
|
|
|
+ n_occurrences = 0
|
|
|
+ occurrences = radicale_filter.time_range_fill(item.vobject_item,
|
|
|
+ time_range_element,
|
|
|
+ "VEVENT",
|
|
|
+ n=n_occurrences)
|
|
|
+ if len(occurrences) >= max_occurrence:
|
|
|
+ raise ValueError("FREEBUSY occurrences limit of {} hit"
|
|
|
+ .format(max_occurrence))
|
|
|
+
|
|
|
+ for occurrence in occurrences:
|
|
|
+ vfb = cal.add('vfreebusy')
|
|
|
+ vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value
|
|
|
+ vfb.add('dtstart').value, vfb.add('dtend').value = occurrence
|
|
|
+ if fbtype:
|
|
|
+ vfb.add('fbtype').value = fbtype
|
|
|
+ return (client.OK, cal.serialize())
|
|
|
+
|
|
|
+
|
|
|
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
|
|
collection: storage.BaseCollection, encoding: str,
|
|
|
unlock_storage_fn: Callable[[], None]
|
|
|
) -> Tuple[int, ET.Element]:
|
|
|
- """Read and answer REPORT requests.
|
|
|
+ """Read and answer REPORT requests that return XML.
|
|
|
|
|
|
Read rfc3253-3.6 for info.
|
|
|
|
|
|
@@ -426,13 +526,28 @@ class ApplicationPartReport(ApplicationBase):
|
|
|
else:
|
|
|
assert item.collection is not None
|
|
|
collection = item.collection
|
|
|
- try:
|
|
|
- status, xml_answer = xml_report(
|
|
|
- base_prefix, path, xml_content, collection, self._encoding,
|
|
|
- lock_stack.close)
|
|
|
- except ValueError as e:
|
|
|
- logger.warning(
|
|
|
- "Bad REPORT request on %r: %s", path, e, exc_info=True)
|
|
|
- return httputils.BAD_REQUEST
|
|
|
- headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
|
|
- return status, headers, self._xml_response(xml_answer)
|
|
|
+
|
|
|
+ if xml_content is not None and \
|
|
|
+ xml_content.tag == xmlutils.make_clark("C:free-busy-query"):
|
|
|
+ max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence")
|
|
|
+ try:
|
|
|
+ status, body = free_busy_report(
|
|
|
+ base_prefix, path, xml_content, collection, self._encoding,
|
|
|
+ lock_stack.close, max_occurrence)
|
|
|
+ except ValueError as e:
|
|
|
+ logger.warning(
|
|
|
+ "Bad REPORT request on %r: %s", path, e, exc_info=True)
|
|
|
+ return httputils.BAD_REQUEST
|
|
|
+ headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
|
|
|
+ return status, headers, str(body)
|
|
|
+ else:
|
|
|
+ try:
|
|
|
+ status, xml_answer = xml_report(
|
|
|
+ base_prefix, path, xml_content, collection, self._encoding,
|
|
|
+ lock_stack.close)
|
|
|
+ except ValueError as e:
|
|
|
+ logger.warning(
|
|
|
+ "Bad REPORT request on %r: %s", path, e, exc_info=True)
|
|
|
+ return httputils.BAD_REQUEST
|
|
|
+ headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
|
|
+ return status, headers, self._xml_response(xml_answer)
|