|
28 | 28 | Sequence, Tuple, Union) |
29 | 29 | from urllib.parse import unquote, urlparse |
30 | 30 |
|
| 31 | +import vobject |
31 | 32 | import vobject.base |
32 | 33 | from vobject.base import ContentLine |
33 | 34 |
|
|
38 | 39 | from radicale.log import logger |
39 | 40 |
|
40 | 41 |
|
| 42 | +def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], |
| 43 | + collection: storage.BaseCollection, encoding: str, |
| 44 | + unlock_storage_fn: Callable[[], None], |
| 45 | + max_occurrence: int |
| 46 | + ) -> Tuple[int, Union[ET.Element, str]]: |
| 47 | + # NOTE: this function returns both an Element and a string because |
| 48 | + # free-busy reports are an edge-case on the return type according |
| 49 | + # to the spec. |
| 50 | + |
| 51 | + multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) |
| 52 | + if xml_request is None: |
| 53 | + return client.MULTI_STATUS, multistatus |
| 54 | + root = xml_request |
| 55 | + if (root.tag == xmlutils.make_clark("C:free-busy-query") and |
| 56 | + collection.tag != "VCALENDAR"): |
| 57 | + logger.warning("Invalid REPORT method %r on %r requested", |
| 58 | + xmlutils.make_human_tag(root.tag), path) |
| 59 | + return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report") |
| 60 | + |
| 61 | + time_range_element = root.find(xmlutils.make_clark("C:time-range")) |
| 62 | + assert isinstance(time_range_element, ET.Element) |
| 63 | + |
| 64 | + # Build a single filter from the free busy query for retrieval |
| 65 | + # TODO: filter for VFREEBUSY in additional to VEVENT but |
| 66 | + # test_filter doesn't support that yet. |
| 67 | + vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"), |
| 68 | + attrib={'name': 'VEVENT'}) |
| 69 | + vevent_cf_element.append(time_range_element) |
| 70 | + vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"), |
| 71 | + attrib={'name': 'VCALENDAR'}) |
| 72 | + vcalendar_cf_element.append(vevent_cf_element) |
| 73 | + filter_element = ET.Element(xmlutils.make_clark("C:filter")) |
| 74 | + filter_element.append(vcalendar_cf_element) |
| 75 | + filters = (filter_element,) |
| 76 | + |
| 77 | + # First pull from storage |
| 78 | + retrieved_items = list(collection.get_filtered(filters)) |
| 79 | + # !!! Don't access storage after this !!! |
| 80 | + unlock_storage_fn() |
| 81 | + |
| 82 | + cal = vobject.iCalendar() |
| 83 | + collection_tag = collection.tag |
| 84 | + while retrieved_items: |
| 85 | + # Second filtering before evaluating occurrences. |
| 86 | + # ``item.vobject_item`` might be accessed during filtering. |
| 87 | + # Don't keep reference to ``item``, because VObject requires a lot of |
| 88 | + # memory. |
| 89 | + item, filter_matched = retrieved_items.pop(0) |
| 90 | + if not filter_matched: |
| 91 | + try: |
| 92 | + if not test_filter(collection_tag, item, filter_element): |
| 93 | + continue |
| 94 | + except ValueError as e: |
| 95 | + raise ValueError("Failed to free-busy filter item %r from %r: %s" % |
| 96 | + (item.href, collection.path, e)) from e |
| 97 | + except Exception as e: |
| 98 | + raise RuntimeError("Failed to free-busy filter item %r from %r: %s" % |
| 99 | + (item.href, collection.path, e)) from e |
| 100 | + |
| 101 | + fbtype = None |
| 102 | + if item.component_name == 'VEVENT': |
| 103 | + transp = getattr(item.vobject_item.vevent, 'transp', None) |
| 104 | + if transp and transp.value != 'OPAQUE': |
| 105 | + continue |
| 106 | + |
| 107 | + status = getattr(item.vobject_item.vevent, 'status', None) |
| 108 | + if not status or status.value == 'CONFIRMED': |
| 109 | + fbtype = 'BUSY' |
| 110 | + elif status.value == 'CANCELLED': |
| 111 | + fbtype = 'FREE' |
| 112 | + elif status.value == 'TENTATIVE': |
| 113 | + fbtype = 'BUSY-TENTATIVE' |
| 114 | + else: |
| 115 | + # Could do fbtype = status.value for x-name, I prefer this |
| 116 | + fbtype = 'BUSY' |
| 117 | + |
| 118 | + # TODO: coalesce overlapping periods |
| 119 | + |
| 120 | + if max_occurrence > 0: |
| 121 | + n_occurrences = max_occurrence+1 |
| 122 | + else: |
| 123 | + n_occurrences = 0 |
| 124 | + occurrences = radicale_filter.time_range_fill(item.vobject_item, |
| 125 | + time_range_element, |
| 126 | + "VEVENT", |
| 127 | + n=n_occurrences) |
| 128 | + if len(occurrences) >= max_occurrence: |
| 129 | + raise ValueError("FREEBUSY occurrences limit of {} hit" |
| 130 | + .format(max_occurrence)) |
| 131 | + |
| 132 | + for occurrence in occurrences: |
| 133 | + vfb = cal.add('vfreebusy') |
| 134 | + vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value |
| 135 | + vfb.add('dtstart').value, vfb.add('dtend').value = occurrence |
| 136 | + if fbtype: |
| 137 | + vfb.add('fbtype').value = fbtype |
| 138 | + return (client.OK, cal.serialize()) |
| 139 | + |
| 140 | + |
41 | 141 | def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], |
42 | 142 | collection: storage.BaseCollection, encoding: str, |
43 | 143 | unlock_storage_fn: Callable[[], None] |
44 | 144 | ) -> Tuple[int, ET.Element]: |
45 | | - """Read and answer REPORT requests. |
| 145 | + """Read and answer REPORT requests that return XML. |
46 | 146 |
|
47 | 147 | Read rfc3253-3.6 for info. |
48 | 148 |
|
@@ -426,13 +526,28 @@ def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, |
426 | 526 | else: |
427 | 527 | assert item.collection is not None |
428 | 528 | collection = item.collection |
429 | | - try: |
430 | | - status, xml_answer = xml_report( |
431 | | - base_prefix, path, xml_content, collection, self._encoding, |
432 | | - lock_stack.close) |
433 | | - except ValueError as e: |
434 | | - logger.warning( |
435 | | - "Bad REPORT request on %r: %s", path, e, exc_info=True) |
436 | | - return httputils.BAD_REQUEST |
437 | | - headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} |
438 | | - return status, headers, self._xml_response(xml_answer) |
| 529 | + |
| 530 | + if xml_content is not None and \ |
| 531 | + xml_content.tag == xmlutils.make_clark("C:free-busy-query"): |
| 532 | + max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence") |
| 533 | + try: |
| 534 | + status, body = free_busy_report( |
| 535 | + base_prefix, path, xml_content, collection, self._encoding, |
| 536 | + lock_stack.close, max_occurrence) |
| 537 | + except ValueError as e: |
| 538 | + logger.warning( |
| 539 | + "Bad REPORT request on %r: %s", path, e, exc_info=True) |
| 540 | + return httputils.BAD_REQUEST |
| 541 | + headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding} |
| 542 | + return status, headers, str(body) |
| 543 | + else: |
| 544 | + try: |
| 545 | + status, xml_answer = xml_report( |
| 546 | + base_prefix, path, xml_content, collection, self._encoding, |
| 547 | + lock_stack.close) |
| 548 | + except ValueError as e: |
| 549 | + logger.warning( |
| 550 | + "Bad REPORT request on %r: %s", path, e, exc_info=True) |
| 551 | + return httputils.BAD_REQUEST |
| 552 | + headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} |
| 553 | + return status, headers, self._xml_response(xml_answer) |
0 commit comments