1
0
Fork 0
mirror of https://github.com/Kozea/Radicale.git synced 2025-06-26 16:45:52 +00:00

Merge pull request #1783 from pbiering/issue-1782

Implement timerange filter for VALARM
This commit is contained in:
Peter Bieringer 2025-05-16 07:42:58 +02:00 committed by GitHub
commit c0fd66eda6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 129 additions and 17 deletions

View file

@ -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

View file

@ -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,6 +51,9 @@ class Auth(auth.BaseAuth):
def _login(self, login, password) -> str: def _login(self, login, password) -> str:
try: try:
if sys.version_info < (3, 10):
connection: Union[imaplib.IMAP4, imaplib.IMAP4_SSL]
else:
connection: imaplib.IMAP4 | imaplib.IMAP4_SSL connection: imaplib.IMAP4 | imaplib.IMAP4_SSL
if self._security == "tls": if self._security == "tls":
connection = imaplib.IMAP4_SSL( connection = imaplib.IMAP4_SSL(

View file

@ -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 # TODO: Filtering VFREEBUSY is not implemented
# so far only filtering based on existence of such component is 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

View file

@ -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

View file

@ -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

View file

@ -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">