Browse Source

Merge pull request #1783 from pbiering/issue-1782

Implement timerange filter for VALARM
Peter Bieringer 9 months ago
parent
commit
c0fd66eda6

+ 1 - 1
CHANGELOG.md

@@ -1,7 +1,7 @@
 # Changelog
 # Changelog
 
 
 ## 3.5.4.dev
 ## 3.5.4.dev
-* Improve: item filter enhanced for 3rd level supporting VALARM and VFREEBUSY (only component existence so far)
+* Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute)
 
 
 ## 3.5.3
 ## 3.5.3
 * Add: [auth] htpasswd: support for Argon2 hashes
 * Add: [auth] htpasswd: support for Argon2 hashes

+ 1 - 1
radicale/app/report.py

@@ -177,7 +177,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
 
 
     props: Union[ET.Element, List]
     props: Union[ET.Element, List]
     if root.find(xmlutils.make_clark("D:prop")) is not None:
     if root.find(xmlutils.make_clark("D:prop")) is not None:
-        props = root.find(xmlutils.make_clark("D:prop")) # type: ignore[assignment]
+        props = root.find(xmlutils.make_clark("D:prop"))  # type: ignore[assignment]
     else:
     else:
         props = []
         props = []
 
 

+ 1 - 1
radicale/auth/htpasswd.py

@@ -96,7 +96,7 @@ class Auth(auth.BaseAuth):
         self._has_bcrypt = False
         self._has_bcrypt = False
         self._has_argon2 = False
         self._has_argon2 = False
         self._htpasswd_ok = False
         self._htpasswd_ok = False
-        self._htpasswd_not_ok_reminder_seconds = 60 # currently hardcoded
+        self._htpasswd_not_ok_reminder_seconds = 60  # currently hardcoded
         (self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd_argon2_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(True, False)
         (self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd_argon2_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(True, False)
         self._lock = threading.Lock()
         self._lock = threading.Lock()
 
 

+ 6 - 1
radicale/auth/imap.py

@@ -17,6 +17,8 @@
 
 
 import imaplib
 import imaplib
 import ssl
 import ssl
+import sys
+from typing import Union
 
 
 from radicale import auth
 from radicale import auth
 from radicale.log import logger
 from radicale.log import logger
@@ -49,7 +51,10 @@ class Auth(auth.BaseAuth):
 
 
     def _login(self, login, password) -> str:
     def _login(self, login, password) -> str:
         try:
         try:
-            connection: imaplib.IMAP4 | imaplib.IMAP4_SSL
+            if sys.version_info < (3, 10):
+                connection: Union[imaplib.IMAP4, imaplib.IMAP4_SSL]
+            else:
+                connection: imaplib.IMAP4 | imaplib.IMAP4_SSL
             if self._security == "tls":
             if self._security == "tls":
                 connection = imaplib.IMAP4_SSL(
                 connection = imaplib.IMAP4_SSL(
                     host=self._host, port=self._port,
                     host=self._host, port=self._port,

+ 38 - 8
radicale/item/filter.py

@@ -21,11 +21,12 @@
 
 
 
 
 import math
 import math
+import sys
 import xml.etree.ElementTree as ET
 import xml.etree.ElementTree as ET
 from datetime import date, datetime, timedelta, timezone
 from datetime import date, datetime, timedelta, timezone
 from itertools import chain
 from itertools import chain
 from typing import (Callable, Iterable, Iterator, List, Optional, Sequence,
 from typing import (Callable, Iterable, Iterator, List, Optional, Sequence,
-                    Tuple)
+                    Tuple, Union)
 
 
 import vobject
 import vobject
 
 
@@ -39,6 +40,11 @@ DATETIME_MAX: datetime = datetime.max.replace(tzinfo=timezone.utc)
 TIMESTAMP_MIN: int = math.floor(DATETIME_MIN.timestamp())
 TIMESTAMP_MIN: int = math.floor(DATETIME_MIN.timestamp())
 TIMESTAMP_MAX: int = math.ceil(DATETIME_MAX.timestamp())
 TIMESTAMP_MAX: int = math.ceil(DATETIME_MAX.timestamp())
 
 
+if sys.version_info < (3, 10):
+    TRIGGER = Union[datetime, None]
+else:
+    TRIGGER = datetime | None
+
 
 
 def date_to_datetime(d: date) -> datetime:
 def date_to_datetime(d: date) -> datetime:
     """Transform any date to a UTC datetime.
     """Transform any date to a UTC datetime.
@@ -88,8 +94,7 @@ def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
 
 
     """
     """
 
 
-    # TODO: Improve filtering for VALARM and VFREEBUSY
-    #       so far only filtering based on existence of such component is implemented
+    # TODO: Filtering VFREEBUSY is not implemented
     # HACK: the filters are tested separately against all components
     # HACK: the filters are tested separately against all components
 
 
     name = filter_.get("name", "").upper()
     name = filter_.get("name", "").upper()
@@ -117,10 +122,11 @@ def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
         return False
         return False
     if ((level == 0 and name != "VCALENDAR") or
     if ((level == 0 and name != "VCALENDAR") or
             (level == 1 and name not in ("VTODO", "VEVENT", "VJOURNAL")) or
             (level == 1 and name not in ("VTODO", "VEVENT", "VJOURNAL")) or
-            (level == 2 and name not in ("VALARM", "VFREEBUSY"))):
+            (level == 2 and name not in ("VALARM"))):
         logger.warning("Filtering %s is not supported", name)
         logger.warning("Filtering %s is not supported", name)
         return True
         return True
     # Point #3 and #4 of rfc4791-9.7.1
     # Point #3 and #4 of rfc4791-9.7.1
+    trigger = None
     if level == 0:
     if level == 0:
         components = [item.vobject_item]
         components = [item.vobject_item]
     elif level == 1:
     elif level == 1:
@@ -128,15 +134,19 @@ def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
     elif level == 2:
     elif level == 2:
         components = list(getattr(item.vobject_item, "%s_list" % tag.lower()))
         components = list(getattr(item.vobject_item, "%s_list" % tag.lower()))
         for comp in components:
         for comp in components:
-            if not hasattr(comp, name.lower()):
+            subcomp = getattr(comp, name.lower(), None)
+            if not subcomp:
                 return False
                 return False
+            if hasattr(subcomp, "trigger"):
+                # rfc4791-7.8.5:
+                trigger = subcomp.trigger.value
     for child in filter_:
     for child in filter_:
         if child.tag == xmlutils.make_clark("C:prop-filter"):
         if child.tag == xmlutils.make_clark("C:prop-filter"):
             if not any(prop_match(comp, child, "C")
             if not any(prop_match(comp, child, "C")
                        for comp in components):
                        for comp in components):
                 return False
                 return False
         elif child.tag == xmlutils.make_clark("C:time-range"):
         elif child.tag == xmlutils.make_clark("C:time-range"):
-            if not time_range_match(item.vobject_item, filter_[0], tag):
+            if not time_range_match(item.vobject_item, filter_[0], tag, trigger):
                 return False
                 return False
         elif child.tag == xmlutils.make_clark("C:comp-filter"):
         elif child.tag == xmlutils.make_clark("C:comp-filter"):
             if not comp_match(item, child, level=level + 1):
             if not comp_match(item, child, level=level + 1):
@@ -166,7 +176,7 @@ def prop_match(vobject_item: vobject.base.Component,
     # Point #3 and #4 of rfc4791-9.7.2
     # Point #3 and #4 of rfc4791-9.7.2
     for child in filter_:
     for child in filter_:
         if ns == "C" and child.tag == xmlutils.make_clark("C:time-range"):
         if ns == "C" and child.tag == xmlutils.make_clark("C:time-range"):
-            if not time_range_match(vobject_item, child, name):
+            if not time_range_match(vobject_item, child, name, None):
                 return False
                 return False
         elif child.tag == xmlutils.make_clark("%s:text-match" % ns):
         elif child.tag == xmlutils.make_clark("%s:text-match" % ns):
             if not text_match(vobject_item, child, name, ns):
             if not text_match(vobject_item, child, name, ns):
@@ -180,9 +190,10 @@ def prop_match(vobject_item: vobject.base.Component,
 
 
 
 
 def time_range_match(vobject_item: vobject.base.Component,
 def time_range_match(vobject_item: vobject.base.Component,
-                     filter_: ET.Element, child_name: str) -> bool:
+                     filter_: ET.Element, child_name: str, trigger: TRIGGER) -> bool:
     """Check whether the component/property ``child_name`` of
     """Check whether the component/property ``child_name`` of
        ``vobject_item`` matches the time-range ``filter_``."""
        ``vobject_item`` matches the time-range ``filter_``."""
+    # supporting since 3.5.4 now optional trigger (either absolute or relative offset)
 
 
     if not filter_.get("start") and not filter_.get("end"):
     if not filter_.get("start") and not filter_.get("end"):
         return False
         return False
@@ -193,6 +204,25 @@ def time_range_match(vobject_item: vobject.base.Component,
     def range_fn(range_start: datetime, range_end: datetime,
     def range_fn(range_start: datetime, range_end: datetime,
                  is_recurrence: bool) -> bool:
                  is_recurrence: bool) -> bool:
         nonlocal matched
         nonlocal matched
+        if trigger:
+            # if trigger is given, only check range_start
+            if isinstance(trigger, timedelta):
+                # trigger is a offset, apply to range_start
+                if start < range_start + trigger and range_start + trigger < end:
+                    matched = True
+                    return True
+                else:
+                    return False
+            elif isinstance(trigger, datetime):
+                # trigger is absolute, use instead of range_start
+                if start < trigger and trigger < end:
+                    matched = True
+                    return True
+                else:
+                    return False
+            else:
+                logger.warning("item/filter/time_range_match/range_fn: unsupported data format of provided trigger=%r", trigger)
+                return True
         if start < range_end and range_start < end:
         if start < range_end and range_start < end:
             matched = True
             matched = True
             return True
             return True

+ 15 - 0
radicale/tests/static/valarm1.ics

@@ -0,0 +1,15 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//python-caldav//caldav//en_DK
+BEGIN:VEVENT
+SUMMARY:This is a test event
+DTSTART:20151010T060000Z
+DTEND:20161010T070000Z
+DTSTAMP:20250515T073149Z
+UID:a9cef952-315e-11f0-a30a-1c1bb5134174
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER:-PT15M
+END:VALARM
+END:VEVENT
+END:VCALENDAR

+ 15 - 0
radicale/tests/static/valarm2.ics

@@ -0,0 +1,15 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//python-caldav//caldav//en_DK
+BEGIN:VEVENT
+SUMMARY:This is a test event
+DTSTART:20151010T060000Z
+DTEND:20161010T070000Z
+DTSTAMP:20250515T073149Z
+UID:a9cef952-315e-11f0-a30a-1c1bb5134175
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER;VALUE=DATE-TIME:20151010T033000Z
+END:VALARM
+END:VEVENT
+END:VCALENDAR

+ 48 - 1
radicale/tests/test_base.py

@@ -21,6 +21,7 @@ Radicale tests with simple requests.
 
 
 """
 """
 
 
+import logging
 import os
 import os
 import posixpath
 import posixpath
 from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
 from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
@@ -745,7 +746,7 @@ permissions: RrWw""")
                      ) -> List[str]:
                      ) -> List[str]:
         filter_template = "<C:filter>%s</C:filter>"
         filter_template = "<C:filter>%s</C:filter>"
         create_collection_fn: Callable[[str], Any]
         create_collection_fn: Callable[[str], Any]
-        if kind in ("event", "journal", "todo"):
+        if kind in ("event", "journal", "todo", "valarm"):
             create_collection_fn = self.mkcalendar
             create_collection_fn = self.mkcalendar
             path = "/calendar.ics/"
             path = "/calendar.ics/"
             filename_template = "%s%d.ics"
             filename_template = "%s%d.ics"
@@ -764,10 +765,13 @@ permissions: RrWw""")
         status, _, = self.delete(path, check=None)
         status, _, = self.delete(path, check=None)
         assert status in (200, 404)
         assert status in (200, 404)
         create_collection_fn(path)
         create_collection_fn(path)
+        logging.warning("Upload items %r", items)
         for i in items:
         for i in items:
+            logging.warning("Upload %d", i)
             filename = filename_template % (kind, i)
             filename = filename_template % (kind, i)
             event = get_file_content(filename)
             event = get_file_content(filename)
             self.put(posixpath.join(path, filename), event)
             self.put(posixpath.join(path, filename), event)
+        logging.warning("Upload items finished")
         filters_text = "".join(filter_template % f for f in filters)
         filters_text = "".join(filter_template % f for f in filters)
         _, responses = self.report(path, """\
         _, responses = self.report(path, """\
 <?xml version="1.0" encoding="utf-8" ?>
 <?xml version="1.0" encoding="utf-8" ?>
@@ -1304,6 +1308,49 @@ permissions: RrWw""")
 </C:comp-filter>"""], "todo", items=range(1, 9))
 </C:comp-filter>"""], "todo", items=range(1, 9))
         assert "/calendar.ics/todo7.ics" in answer
         assert "/calendar.ics/todo7.ics" in answer
 
 
+    def test_time_range_filter_events_valarm(self) -> None:
+        """Report request with time-range filter on events having absolute VALARM."""
+        answer = self._test_filter(["""\
+<C:comp-filter name="VCALENDAR">
+    <C:comp-filter name="VEVENT">
+        <C:comp-filter name="VALARM">
+            <C:time-range start="20151010T030000Z" end="20151010T040000Z"/>
+        </C:comp-filter>
+    </C:comp-filter>
+</C:comp-filter>"""], "valarm", items=[1, 2])
+        assert "/calendar.ics/valarm1.ics" not in answer
+        assert "/calendar.ics/valarm2.ics" in answer  # absolute date
+        answer = self._test_filter(["""\
+<C:comp-filter name="VCALENDAR">
+    <C:comp-filter name="VEVENT">
+        <C:comp-filter name="VALARM">
+            <C:time-range start="20151010T010000Z" end="20151010T020000Z"/>
+        </C:comp-filter>
+    </C:comp-filter>
+</C:comp-filter>"""], "valarm", items=[1, 2])
+        assert "/calendar.ics/valarm1.ics" not in answer
+        assert "/calendar.ics/valarm2.ics" not in answer
+        answer = self._test_filter(["""\
+<C:comp-filter name="VCALENDAR">
+    <C:comp-filter name="VEVENT">
+        <C:comp-filter name="VALARM">
+            <C:time-range start="20151010T080000Z" end="20151010T090000Z"/>
+        </C:comp-filter>
+    </C:comp-filter>
+</C:comp-filter>"""], "valarm", items=[1, 2])
+        assert "/calendar.ics/valarm1.ics" not in answer
+        assert "/calendar.ics/valarm2.ics" not in answer
+        answer = self._test_filter(["""\
+<C:comp-filter name="VCALENDAR">
+    <C:comp-filter name="VEVENT">
+        <C:comp-filter name="VALARM">
+            <C:time-range start="20151010T053000Z" end="20151010T055000Z"/>
+        </C:comp-filter>
+    </C:comp-filter>
+</C:comp-filter>"""], "valarm", items=[1, 2])
+        assert "/calendar.ics/valarm1.ics" in answer  # -15 min offset
+        assert "/calendar.ics/valarm2.ics" not in answer
+
     def test_time_range_filter_todos_completed(self) -> None:
     def test_time_range_filter_todos_completed(self) -> None:
         answer = self._test_filter(["""\
         answer = self._test_filter(["""\
 <C:comp-filter name="VCALENDAR">
 <C:comp-filter name="VCALENDAR">

+ 4 - 4
radicale/utils.py

@@ -102,7 +102,7 @@ def ssl_context_options_by_protocol(protocol: str, ssl_context_options):
     ssl_context_options |= ssl.OP_NO_TLSv1_3
     ssl_context_options |= ssl.OP_NO_TLSv1_3
     logger.debug("SSL cleared SSL context options: '0x%x'", ssl_context_options)
     logger.debug("SSL cleared SSL context options: '0x%x'", ssl_context_options)
     for entry in protocol.split():
     for entry in protocol.split():
-        entry = entry.strip('+') # remove trailing '+'
+        entry = entry.strip('+')  # remove trailing '+'
         if entry == "ALL":
         if entry == "ALL":
             logger.debug("SSL context options, enable ALL (some maybe not supported by underlying OpenSSL, SSLv2 not enabled at all)")
             logger.debug("SSL context options, enable ALL (some maybe not supported by underlying OpenSSL, SSLv2 not enabled at all)")
             ssl_context_options &= ~ssl.OP_NO_SSLv3
             ssl_context_options &= ~ssl.OP_NO_SSLv3
@@ -162,7 +162,7 @@ def ssl_context_options_by_protocol(protocol: str, ssl_context_options):
 
 
 def ssl_context_minimum_version_by_options(ssl_context_options):
 def ssl_context_minimum_version_by_options(ssl_context_options):
     logger.debug("SSL calculate minimum version by context options: '0x%x'", ssl_context_options)
     logger.debug("SSL calculate minimum version by context options: '0x%x'", ssl_context_options)
-    ssl_context_minimum_version = ssl.TLSVersion.SSLv3 # default
+    ssl_context_minimum_version = ssl.TLSVersion.SSLv3  # default
     if ((ssl_context_options & ssl.OP_NO_SSLv3) and (ssl_context_minimum_version == ssl.TLSVersion.SSLv3)):
     if ((ssl_context_options & ssl.OP_NO_SSLv3) and (ssl_context_minimum_version == ssl.TLSVersion.SSLv3)):
         ssl_context_minimum_version = ssl.TLSVersion.TLSv1
         ssl_context_minimum_version = ssl.TLSVersion.TLSv1
     if ((ssl_context_options & ssl.OP_NO_TLSv1) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1)):
     if ((ssl_context_options & ssl.OP_NO_TLSv1) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1)):
@@ -172,7 +172,7 @@ def ssl_context_minimum_version_by_options(ssl_context_options):
     if ((ssl_context_options & ssl.OP_NO_TLSv1_2) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_2)):
     if ((ssl_context_options & ssl.OP_NO_TLSv1_2) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_2)):
         ssl_context_minimum_version = ssl.TLSVersion.TLSv1_3
         ssl_context_minimum_version = ssl.TLSVersion.TLSv1_3
     if ((ssl_context_options & ssl.OP_NO_TLSv1_3) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_3)):
     if ((ssl_context_options & ssl.OP_NO_TLSv1_3) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_3)):
-        ssl_context_minimum_version = 0 # all disabled
+        ssl_context_minimum_version = 0  # all disabled
 
 
     logger.debug("SSL context options: '0x%x' results in minimum version: %s", ssl_context_options, ssl_context_minimum_version)
     logger.debug("SSL context options: '0x%x' results in minimum version: %s", ssl_context_options, ssl_context_minimum_version)
     return ssl_context_minimum_version
     return ssl_context_minimum_version
@@ -180,7 +180,7 @@ def ssl_context_minimum_version_by_options(ssl_context_options):
 
 
 def ssl_context_maximum_version_by_options(ssl_context_options):
 def ssl_context_maximum_version_by_options(ssl_context_options):
     logger.debug("SSL calculate maximum version by context options: '0x%x'", ssl_context_options)
     logger.debug("SSL calculate maximum version by context options: '0x%x'", ssl_context_options)
-    ssl_context_maximum_version = ssl.TLSVersion.TLSv1_3 # default
+    ssl_context_maximum_version = ssl.TLSVersion.TLSv1_3  # default
     if ((ssl_context_options & ssl.OP_NO_TLSv1_3) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_3)):
     if ((ssl_context_options & ssl.OP_NO_TLSv1_3) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_3)):
         ssl_context_maximum_version = ssl.TLSVersion.TLSv1_2
         ssl_context_maximum_version = ssl.TLSVersion.TLSv1_2
     if ((ssl_context_options & ssl.OP_NO_TLSv1_2) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_2)):
     if ((ssl_context_options & ssl.OP_NO_TLSv1_2) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_2)):