1
0
Fork 0
mirror of https://github.com/Kozea/Radicale.git synced 2025-08-01 18:18:31 +00:00

Merge branch 'master' into email

This commit is contained in:
Nate Harris 2025-07-19 23:39:16 -06:00 committed by GitHub
commit 0cc5d42947
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 803 additions and 65 deletions

2
.gitignore vendored
View file

@ -23,3 +23,5 @@ coverage.xml
.vscode
.sass-cache
Gemfile.lock
pytestdebug.log

View file

@ -7,6 +7,8 @@
* Improve: display owner+permissions on directories on startup, extend error message in case of missing permissions
* Feature: add hook for server-side e-mail notification
* Fix: logging ignores not retrievable get_native_id if not supported by OS
* Fix: report with enabled expand honors now provided filter proper
* Improve: add options [logging] trace_on_debug and trace_filter for supporting trace logging
## 3.5.4
* Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute)

View file

@ -1420,6 +1420,24 @@ Available levels: **debug**, **info**, **warning**, **error**, **critical**
Default: `warning` _(< 3.2.0)_ `info` _(>= 3.2.0)_
##### trace_on_debug
_(> 3.5.4)_
Do not filter debug messages starting with 'TRACE'
Default: `False`
##### trace_filter
_(> 3.5.4)_
Filter debug messages starting with 'TRACE/<TOKEN>'
Precondition: `trace_on_debug = True`
Default: (empty)
##### mask_passwords
Don't include passwords in logs.

6
config
View file

@ -272,6 +272,12 @@
# Value: debug | info | warning | error | critical
#level = info
# do not filter debug messages starting with 'TRACE'
#trace_on_debug = False
# filter debug messages starting with 'TRACE/<TOKEN>'
#trace_filter = ""
# Don't include passwords in logs
#mask_passwords = True

View file

@ -52,7 +52,10 @@ def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream
configuration = config.load(config.parse_compound_paths(
config.DEFAULT_CONFIG_PATH,
config_path))
log.set_level(cast(str, configuration.get("logging", "level")), configuration.get("logging", "backtrace_on_debug"))
log.set_level(cast(str, configuration.get("logging", "level")),
configuration.get("logging", "backtrace_on_debug"),
configuration.get("logging", "trace_on_debug"),
configuration.get("logging", "trace_filter"))
# Log configuration after logger is configured
default_config_active = True
for source, miss in configuration.sources():

View file

@ -165,7 +165,10 @@ def run() -> None:
sys.exit(1)
# Configure logging
log.set_level(cast(str, configuration.get("logging", "level")), configuration.get("logging", "backtrace_on_debug"))
log.set_level(cast(str, configuration.get("logging", "level")),
configuration.get("logging", "backtrace_on_debug"),
configuration.get("logging", "trace_on_debug"),
configuration.get("logging", "trace_filter"))
# Log configuration after logger is configured
default_config_active = True

View file

@ -5,8 +5,9 @@
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
# Copyright © 2024-2024 Ray <ray@react0r.com>
# Copyright © 2024-2024 Georgiy <metallerok@gmail.com>
# Copyright © 2024-2025 Georgiy <metallerok@gmail.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
# Copyright © 2025-2025 David Greaves <david@dgreaves.com>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -144,7 +145,8 @@ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Eleme
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
collection: storage.BaseCollection, encoding: str,
unlock_storage_fn: Callable[[], None]
unlock_storage_fn: Callable[[], None],
max_occurrence: int = 0,
) -> Tuple[int, ET.Element]:
"""Read and answer REPORT requests that return XML.
@ -223,14 +225,34 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
filters = (
root.findall(xmlutils.make_clark("C:filter")) +
root.findall(xmlutils.make_clark("CR:filter")))
expand = root.find(".//" + xmlutils.make_clark("C:expand"))
# if we have expand prop we use "filter (except time range) -> expand -> filter (only time range)" approach
time_range_element = None
main_filters = []
for filter_ in filters:
# extract time-range filter for processing after main filters
# for expand request
filter_copy = copy.deepcopy(filter_)
if expand is not None:
for comp_filter in filter_copy.findall(".//" + xmlutils.make_clark("C:comp-filter")):
if comp_filter.get("name", "").upper() == "VCALENDAR":
continue
time_range_element = comp_filter.find(xmlutils.make_clark("C:time-range"))
if time_range_element is not None:
comp_filter.remove(time_range_element)
main_filters.append(filter_copy)
# Retrieve everything required for finishing the request.
retrieved_items = list(retrieve_items(
base_prefix, path, collection, hreferences, filters, multistatus))
base_prefix, path, collection, hreferences, main_filters, multistatus))
collection_tag = collection.tag
# !!! Don't access storage after this !!!
unlock_storage_fn()
n_vevents = 0
while retrieved_items:
# ``item.vobject_item`` might be accessed during filtering.
# Don't keep reference to ``item``, because VObject requires a lot of
@ -239,7 +261,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
if filters and not filters_matched:
try:
if not all(test_filter(collection_tag, item, filter_)
for filter_ in filters):
for filter_ in main_filters):
continue
except ValueError as e:
raise ValueError("Failed to filter item %r from %r: %s" %
@ -264,36 +286,59 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
xmlutils.make_clark("CR:address-data")):
element.text = item.serialize()
expand = prop.find(xmlutils.make_clark("C:expand"))
if expand is not None and item.component_name == 'VEVENT':
start = expand.get('start')
end = expand.get('end')
if (expand is not None) and item.component_name == 'VEVENT':
starts = expand.get('start')
ends = expand.get('end')
if (start is None) or (end is None):
if (starts is None) or (ends is None):
return client.FORBIDDEN, \
xmlutils.webdav_error("C:expand")
start = datetime.datetime.strptime(
start, '%Y%m%dT%H%M%SZ'
starts, '%Y%m%dT%H%M%SZ'
).replace(tzinfo=datetime.timezone.utc)
end = datetime.datetime.strptime(
end, '%Y%m%dT%H%M%SZ'
ends, '%Y%m%dT%H%M%SZ'
).replace(tzinfo=datetime.timezone.utc)
expanded_element = _expand(
element, copy.copy(item), start, end)
time_range_start = None
time_range_end = None
if time_range_element is not None:
time_range_start, time_range_end = radicale_filter.parse_time_range(time_range_element)
(expanded_element, n_vev) = _expand(
element=element, item=copy.copy(item),
start=start, end=end,
time_range_start=time_range_start, time_range_end=time_range_end,
max_occurrence=max_occurrence,
)
if n_vev == 0:
logger.debug("No VEVENTs found after expansion for %r, skipping", item.href)
continue
n_vevents += n_vev
found_props.append(expanded_element)
else:
found_props.append(element)
if hasattr(item.vobject_item, "vevent_list"):
n_vevents += len(item.vobject_item.vevent_list)
# Avoid DoS with too many events
if max_occurrence and n_vevents > max_occurrence:
raise ValueError("REPORT occurrences limit of {} hit"
.format(max_occurrence))
else:
not_found_props.append(element)
assert item.href
uri = pathutils.unstrip_path(
posixpath.join(collection.path, item.href))
multistatus.append(xml_item_response(
base_prefix, uri, found_props=found_props,
not_found_props=not_found_props, found_item=True))
if found_props or not_found_props:
multistatus.append(xml_item_response(
base_prefix, uri, found_props=found_props,
not_found_props=not_found_props, found_item=True))
return client.MULTI_STATUS, multistatus
@ -303,8 +348,14 @@ def _expand(
item: radicale_item.Item,
start: datetime.datetime,
end: datetime.datetime,
) -> ET.Element:
time_range_start: Optional[datetime.datetime] = None,
time_range_end: Optional[datetime.datetime] = None,
max_occurrence: int = 0,
) -> Tuple[ET.Element, int]:
vevent_component: vobject.base.Component = copy.copy(item.vobject_item)
logger.info("Expanding event %s", item.href)
logger.debug(f"Expand range: {start} to {end}")
logger.debug(f"Time range: {time_range_start} to {time_range_end}")
# Split the vevents included in the component into one that contains the
# recurrence information and others that contain a recurrence id to
@ -323,6 +374,9 @@ def _expand(
# rruleset.between computes with datetimes without timezone information
start = start.replace(tzinfo=None)
end = end.replace(tzinfo=None)
if time_range_start is not None and time_range_end is not None:
time_range_start = time_range_start.replace(tzinfo=None)
time_range_end = time_range_end.replace(tzinfo=None)
for vevent in vevents_overridden:
_strip_single_event(vevent, dt_format)
@ -330,32 +384,92 @@ def _expand(
duration = None
if hasattr(vevent_recurrence, "dtend"):
duration = vevent_recurrence.dtend.value - vevent_recurrence.dtstart.value
elif hasattr(vevent_recurrence, "duration"):
try:
duration = vevent_recurrence.duration.value
if duration.total_seconds() <= 0:
logger.warning("Invalid DURATION: %s", duration)
duration = None
except (AttributeError, TypeError) as e:
logger.warning("Failed to parse DURATION: %s", e)
duration = None
# Generate EXDATE to remove from expansion range
exdates_set: set[datetime.datetime] = set()
if hasattr(vevent_recurrence, 'exdate'):
exdates = vevent_recurrence.exdate.value
if not isinstance(exdates, list):
exdates = [exdates]
exdates_set = {
exdate.astimezone(datetime.timezone.utc) if isinstance(exdate, datetime.datetime)
else datetime.datetime.fromordinal(exdate.toordinal()).replace(tzinfo=None)
for exdate in exdates
}
logger.debug("EXDATE values: %s", exdates_set)
rruleset = None
if hasattr(vevent_recurrence, 'rrule'):
rruleset = vevent_recurrence.getrruleset()
filtered_vevents = []
if rruleset:
# This function uses datetimes internally without timezone info for dates
recurrences = rruleset.between(start, end, inc=True)
# A vobject rruleset is for the event dtstart.
# Expanded over a given time range this will not include
# events which started before the time range but are still
# ongoing at the start of the range
# To accomodate this, reduce the start time by the duration of
# the event. If this introduces an extra reccurence point then
# that event should be included as it is still ongoing. If no
# extra point is generated then it was a no-op.
rstart = start - duration if duration and duration.total_seconds() > 0 else start
recurrences = rruleset.between(rstart, end, inc=True, count=max_occurrence)
if max_occurrence and len(recurrences) >= max_occurrence:
# this shouldn't be > and if it's == then assume a limit
# was hit and ignore that maybe some would be filtered out
# by EXDATE etc. This is anti-DoS, not precise limits
raise ValueError("REPORT occurrences limit of {} hit"
.format(max_occurrence))
_strip_component(vevent_component)
_strip_single_event(vevent_recurrence, dt_format)
is_component_filled: bool = False
i_overridden = 0
for recurrence_dt in recurrences:
recurrence_utc = recurrence_dt.astimezone(datetime.timezone.utc)
recurrence_utc = recurrence_dt if all_day_event else recurrence_dt.astimezone(datetime.timezone.utc)
logger.debug("Processing recurrence: %s (all_day_event: %s)", recurrence_utc, all_day_event)
# Apply time-range filter
if time_range_start is not None and time_range_end is not None:
dtstart = recurrence_utc
dtend = dtstart + duration if duration else dtstart
# Start includes the time, end does not
if not (dtstart <= time_range_end and dtend > time_range_start):
logger.debug("Recurrence %s filtered out by time-range", recurrence_utc)
continue
# Check exdate
if recurrence_utc in exdates_set:
logger.debug("Recurrence %s excluded by EXDATE", recurrence_utc)
continue
# Check for overridden instances
i_overridden, vevent = _find_overridden(i_overridden, vevents_overridden, recurrence_utc, dt_format)
if not vevent:
# We did not find an overridden instance, so create a new one
# Create new instance from recurrence
vevent = copy.deepcopy(vevent_recurrence)
# For all day events, the system timezone may influence the
# results, so use recurrence_dt
recurrence_id = recurrence_dt if all_day_event else recurrence_utc
logger.debug("Creating new VEVENT with RECURRENCE-ID: %s", recurrence_id)
vevent.recurrence_id = ContentLine(
name='RECURRENCE-ID',
value=recurrence_id, params={}
@ -365,21 +479,67 @@ def _expand(
name='DTSTART',
value=recurrence_id.strftime(dt_format), params={}
)
if duration:
# if there is a DTEND, override it. Duration does not need changing
if hasattr(vevent, "dtend"):
vevent.dtend = ContentLine(
name='DTEND',
value=(recurrence_id + duration).strftime(dt_format), params={}
)
if not is_component_filled:
vevent_component.vevent = vevent
is_component_filled = True
else:
vevent_component.add(vevent)
filtered_vevents.append(vevent)
# Filter overridden and recurrence base events
if time_range_start is not None and time_range_end is not None:
for vevent in vevents_overridden:
dtstart = vevent.dtstart.value
# Handle string values for DTSTART/DTEND
if isinstance(dtstart, str):
try:
dtstart = datetime.datetime.strptime(dtstart, dt_format)
if all_day_event:
dtstart = dtstart.date()
except ValueError as e:
logger.warning("Invalid DTSTART format: %s, error: %s", dtstart, e)
continue
dtend = dtstart + duration if duration else dtstart
logger.debug(
"Filtering VEVENT with DTSTART: %s (type: %s), DTEND: %s (type: %s)",
dtstart, type(dtstart), dtend, type(dtend))
# Convert to datetime for comparison
if all_day_event and isinstance(dtstart, datetime.date) and not isinstance(dtstart, datetime.datetime):
dtstart = datetime.datetime.fromordinal(dtstart.toordinal()).replace(tzinfo=None)
dtend = datetime.datetime.fromordinal(dtend.toordinal()).replace(tzinfo=None)
elif not all_day_event and isinstance(dtstart, datetime.datetime) \
and isinstance(dtend, datetime.datetime):
dtstart = dtstart.replace(tzinfo=datetime.timezone.utc)
dtend = dtend.replace(tzinfo=datetime.timezone.utc)
else:
logger.warning("Unexpected DTSTART/DTEND type: dtstart=%s, dtend=%s", type(dtstart), type(dtend))
continue
if dtstart < time_range_end and dtend > time_range_start:
if vevent not in filtered_vevents: # Avoid duplicates
logger.debug("VEVENT passed time-range filter: %s", dtstart)
filtered_vevents.append(vevent)
else:
logger.debug("VEVENT filtered out: %s", dtstart)
# Rebuild component
if not filtered_vevents:
element.text = ""
return element, 0
else:
vevent_component.vevent_list = filtered_vevents
logger.debug("lbt: vevent_component %s", vevent_component)
element.text = vevent_component.serialize()
return element
return element, len(filtered_vevents)
def _convert_timezone(vevent: vobject.icalendar.RecurringComponent,
@ -616,9 +776,9 @@ class ApplicationPartReport(ApplicationBase):
assert item.collection is not None
collection = item.collection
max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence")
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,
@ -633,7 +793,7 @@ class ApplicationPartReport(ApplicationBase):
try:
status, xml_answer = xml_report(
base_prefix, path, xml_content, collection, self._encoding,
lock_stack.close)
lock_stack.close, max_occurrence)
except ValueError as e:
logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)

View file

@ -520,6 +520,14 @@ This is an automated message. Please do not reply.""",
"value": "info",
"help": "threshold for the logger",
"type": logging_level}),
("trace_on_debug", {
"value": "False",
"help": "do not filter debug messages starting with 'TRACE'",
"type": bool}),
("trace_filter", {
"value": "",
"help": "filter debug messages starting with 'TRACE/<TOKEN>'",
"type": str}),
("bad_put_request_content", {
"value": "False",
"help": "log bad PUT request content",

View file

@ -57,8 +57,35 @@ class RemoveTracebackFilter(logging.Filter):
return True
class RemoveTRACEFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
if record.msg.startswith("TRACE"):
return False
else:
return True
class PassTRACETOKENFilter(logging.Filter):
def __init__(self, trace_filter: str):
super().__init__()
self.trace_filter = trace_filter
self.prefix = "TRACE/" + self.trace_filter
def filter(self, record: logging.LogRecord) -> bool:
if record.msg.startswith("TRACE"):
if record.msg.startswith(self.prefix):
return True
else:
return False
else:
return True
REMOVE_TRACEBACK_FILTER: logging.Filter = RemoveTracebackFilter()
REMOVE_TRACE_FILTER: logging.Filter = RemoveTRACEFilter()
class IdentLogRecordFactory:
"""LogRecordFactory that adds ``ident`` attribute."""
@ -231,7 +258,7 @@ logger_display_backtrace_disabled: bool = False
logger_display_backtrace_enabled: bool = False
def set_level(level: Union[int, str], backtrace_on_debug: bool) -> None:
def set_level(level: Union[int, str], backtrace_on_debug: bool, trace_on_debug: bool = False, trace_filter: str = "") -> None:
"""Set logging level for global logger."""
global logger_display_backtrace_disabled
global logger_display_backtrace_enabled
@ -255,3 +282,14 @@ def set_level(level: Union[int, str], backtrace_on_debug: bool) -> None:
logger.debug("Logging of backtrace is enabled by option in this loglevel")
logger_display_backtrace_enabled = True
logger.removeFilter(REMOVE_TRACEBACK_FILTER)
if trace_on_debug:
if trace_filter != "":
logger.debug("Logging messages starting with 'TRACE/%s' enabled", trace_filter)
logger.addFilter(PassTRACETOKENFilter(trace_filter))
logger.removeFilter(REMOVE_TRACE_FILTER)
else:
logger.debug("Logging messages starting with 'TRACE' enabled")
logger.removeFilter(REMOVE_TRACE_FILTER)
else:
logger.debug("Logging messages starting with 'TRACE' disabled")
logger.addFilter(REMOVE_TRACE_FILTER)

View file

@ -340,6 +340,8 @@ def serve(configuration: config.Configuration,
select_timeout = 1.0
max_connections: int = configuration.get("server", "max_connections")
logger.info("Radicale server ready")
logger.debug("TRACE: Radicale server ready ('logging/trace_on_debug' is active)")
logger.debug("TRACE/SERVER: Radicale server ready ('logging/trace_on_debug' is active - either with 'SERVER' or empty filter)")
while True:
rlist: List[socket.socket] = []
# Wait for finished clients

View file

@ -0,0 +1,28 @@
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY
SUMMARY:Recurring event
UID:event_daily_rrule_forever
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,129 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5/4.4.6-beta.1-ose ical4j/3.2.19
BEGIN:VTIMEZONE
TZID:Europe/London
BEGIN:STANDARD
DTSTART:19961027T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
TZNAME:GMT
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19810329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
TZNAME:BST
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:event_issue1812
DTSTART;TZID=Europe/London:20230101T180000
DTEND;TZID=Europe/London:20230101T233000
CREATED:20230130T181142Z
DTSTAMP:20250515T182647Z
EXDATE;TZID=Europe/London:20231222T180000,20240112T180000,20240126T180000,2
0240329T180000,20241018T180000,20241129T180000,20241206T180000,20241213T18
0000
EXDATE;TZID=Europe/London:20250521T180000
EXDATE;TZID=Europe/London:20250515T180000
RELATED-TO;RELTYPE=X-CALENDARSERVER-RECURRENCE-SET:3EF0E463-40EB-47FF-B825-
D474CE894708
RRULE:FREQ=DAILY
SEQUENCE:11
SUMMARY:TV Room
X-MOZ-GENERATION:23
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20240113T180000
DTSTART;TZID=Europe/London:20240113T183000
DTEND;TZID=Europe/London:20240113T230000
DTSTAMP:20250515T182647Z
SEQUENCE:5
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20231227T180000
DTSTART;TZID=Europe/London:20231227T203000
DTEND;TZID=Europe/London:20231227T233000
DTSTAMP:20250515T182647Z
SEQUENCE:3
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20231126T180000
DTSTART;TZID=Europe/London:20231126T180000
DTEND;TZID=Europe/London:20231126T223000
DTSTAMP:20250515T182647Z
SEQUENCE:3
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20231225T180000
DTSTART;TZID=Europe/London:20231225T211500
DTEND;TZID=Europe/London:20231225T233000
DTSTAMP:20250515T182647Z
SEQUENCE:3
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20231129T180000
DTSTART;TZID=Europe/London:20231129T173000
DTEND;TZID=Europe/London:20231129T233000
DTSTAMP:20250515T182647Z
SEQUENCE:2
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20240220T180000
DTSTART;TZID=Europe/London:20240220T173000
DTEND;TZID=Europe/London:20240220T233000
DTSTAMP:20250515T182647Z
SEQUENCE:5
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20240310T180000
DTSTART;TZID=Europe/London:20240310T174500
DTEND;TZID=Europe/London:20240310T233000
DTSTAMP:20250515T182647Z
SEQUENCE:5
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20240324T180000
DTSTART;TZID=Europe/London:20240324T183000
DTEND;TZID=Europe/London:20240324T233000
DTSTAMP:20250515T182648Z
SEQUENCE:6
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20241027T180000
DTSTART;TZID=Europe/London:20241027T173000
DTEND;TZID=Europe/London:20241027T233000
DTSTAMP:20250515T182648Z
SEQUENCE:7
SUMMARY:TV Room
END:VEVENT
BEGIN:VEVENT
UID:event_issue1812
RECURRENCE-ID;TZID=Europe/London:20241226T180000
DTSTART;TZID=Europe/London:20241226T193000
DTEND;TZID=Europe/London:20241227T003000
DTSTAMP:20250515T182648Z
SEQUENCE:10
SUMMARY:TV Room
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,100 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:event_multiple_too_many
SUMMARY:Event
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many1
SUMMARY:Event1
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many2
SUMMARY:Event2
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many3
SUMMARY:Event3
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many4
SUMMARY:Event4
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many5
SUMMARY:Event5
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many6
SUMMARY:Event6
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many7
SUMMARY:Event7
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many8
SUMMARY:Event8
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many9
SUMMARY:Event9
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many10
SUMMARY:Event10
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:event_multiple_too_many11
SUMMARY:Event11
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VTODO
UID:todo
DTSTART;TZID=Europe/Paris:20130901T220000
DURATION:PT1H
SUMMARY:Todo
END:VTODO
END:VCALENDAR

View file

@ -1,6 +1,8 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
# Copyright © 2025 David Greaves <david@dgreaves.com>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -21,8 +23,10 @@ Radicale tests with expand requests.
"""
import os
from typing import ClassVar, List
from typing import ClassVar, List, Optional
from xml.etree import ElementTree
from radicale.log import logger
from radicale.tests import BaseTest
from radicale.tests.helpers import get_file_content
@ -68,17 +72,13 @@ permissions: RrWw""")
self.configure({"rights": {"file": rights_file_path,
"type": "from_file"}})
def _test_expand(self,
expected_uid: str,
start: str,
end: str,
expected_recurrence_ids: List[str],
expected_start_times: List[str],
expected_end_times: List[str],
only_dates: bool,
nr_uids: int) -> None:
def _req_without_expand(self,
expected_uid: str,
start: str,
end: str,
) -> str:
self.put("/calendar.ics/", get_file_content(f"{expected_uid}.ics"))
req_body_without_expand = \
return \
f"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
@ -94,9 +94,43 @@ permissions: RrWw""")
</C:filter>
</C:calendar-query>
"""
_, responses = self.report("/calendar.ics/", req_body_without_expand)
assert len(responses) == 1
def _req_with_expand(self,
expected_uid: str,
start: str,
end: str,
) -> str:
self.put("/calendar.ics/", get_file_content(f"{expected_uid}.ics"))
return \
f"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="{start}" end="{end}"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
def _test_expand(self,
expected_uid: str,
start: str,
end: str,
expected_recurrence_ids: List[str],
expected_start_times: List[str],
expected_end_times: List[str],
only_dates: bool,
nr_uids: int) -> None:
_, responses = self.report("/calendar.ics/",
self._req_without_expand(expected_uid, start, end))
assert len(responses) == 1
response_without_expand = responses[f'/calendar.ics/{expected_uid}.ics']
assert not isinstance(response_without_expand, int)
status, element = response_without_expand["C:calendar-data"]
@ -118,25 +152,8 @@ permissions: RrWw""")
assert len(uids) == nr_uids
req_body_with_expand = \
f"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="{start}" end="{end}"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
_, responses = self.report("/calendar.ics/", req_body_with_expand)
_, responses = self.report("/calendar.ics/",
self._req_with_expand(expected_uid, start, end))
assert len(responses) == 1
@ -144,6 +161,8 @@ permissions: RrWw""")
assert not isinstance(response_with_expand, int)
status, element = response_with_expand["C:calendar-data"]
logger.debug("lbt: element is %s",
ElementTree.tostring(element, encoding='unicode'))
assert status == 200 and element.text
assert "RRULE" not in element.text
assert "BEGIN:VTIMEZONE" not in element.text
@ -168,6 +187,29 @@ permissions: RrWw""")
assert len(uids) == len(expected_recurrence_ids)
assert len(set(recurrence_ids)) == len(expected_recurrence_ids)
def _test_expand_max(self,
expected_uid: str,
start: str,
end: str,
check: Optional[int] = None) -> None:
_, responses = self.report("/calendar.ics/",
self._req_without_expand(expected_uid, start, end))
assert len(responses) == 1
response_without_expand = responses[f'/calendar.ics/{expected_uid}.ics']
assert not isinstance(response_without_expand, int)
status, element = response_without_expand["C:calendar-data"]
assert status == 200 and element.text
assert "RRULE" in element.text
status, _, _ = self.request(
"REPORT", "/calendar.ics/",
self._req_with_expand(expected_uid, start, end),
check=check)
assert status == 400
def test_report_with_expand_property(self) -> None:
"""Test report with expand property"""
self._test_expand(
@ -181,6 +223,58 @@ permissions: RrWw""")
1
)
def test_report_with_expand_property_start_inside(self) -> None:
"""Test report with expand property start inside"""
self._test_expand(
"event_daily_rrule",
"20060103T171500Z",
"20060105T000000Z",
["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"],
["DTSTART:20060103T170000Z", "DTSTART:20060104T170000Z"],
[],
CONTAINS_TIMES,
1
)
def test_report_with_expand_property_just_inside(self) -> None:
"""Test report with expand property start and end inside event"""
self._test_expand(
"event_daily_rrule",
"20060103T171500Z",
"20060103T171501Z",
["RECURRENCE-ID:20060103T170000Z"],
["DTSTART:20060103T170000Z"],
[],
CONTAINS_TIMES,
1
)
def test_report_with_expand_property_issue1812(self) -> None:
"""Test report with expand property for issue 1812"""
self._test_expand(
"event_issue1812",
"20250127T183000Z",
"20250127T183001Z",
["RECURRENCE-ID:20250127T180000Z"],
["DTSTART:20250127T180000Z"],
["DTEND:20250127T233000Z"],
CONTAINS_TIMES,
11
)
def test_report_with_expand_property_issue1812_DS(self) -> None:
"""Test report with expand property for issue 1812 - DS active"""
self._test_expand(
"event_issue1812",
"20250627T183000Z",
"20250627T183001Z",
["RECURRENCE-ID:20250627T170000Z"],
["DTSTART:20250627T170000Z"],
["DTEND:20250627T223000Z"],
CONTAINS_TIMES,
11
)
def test_report_with_expand_property_all_day_event(self) -> None:
"""Test report with expand property for all day events"""
self._test_expand(
@ -228,3 +322,148 @@ permissions: RrWw""")
CONTAINS_TIMES,
1
)
def test_report_with_expand_property_max_occur(self) -> None:
"""Test report with expand property too many vevents"""
self.configure({"reporting": {"max_freebusy_occurrence": 100}})
self._test_expand_max(
"event_daily_rrule_forever",
"20060103T000000Z",
"20060501T000000Z",
check=400
)
def test_report_with_max_occur(self) -> None:
"""Test report with too many vevents"""
self.configure({"reporting": {"max_freebusy_occurrence": 10}})
uid = "event_multiple_too_many"
start = "20130901T000000Z"
end = "20130902T000000Z"
check = 400
status, responses = self.report("/calendar.ics/",
self._req_without_expand(uid, start, end),
check=check)
assert len(responses) == 0
assert status == check
def test_report_vcalendar_all_components(self) -> None:
"""Test calendar-query with comp-filter VCALENDAR, returns all components."""
self.mkcalendar("/test/")
self.put("/test/calendar.ics", get_file_content("event_daily_rrule.ics"))
self.put("/test/todo.ics", get_file_content("todo1.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR"/>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 2
assert "/test/calendar.ics" in responses
assert "/test/todo.ics" in responses
def test_report_vevent_only(self) -> None:
"""Test calendar-query with comp-filter VEVENT, returns only VEVENT."""
self.mkcalendar("/test/")
self.put("/test/calendar.ics", get_file_content("event_daily_rrule.ics"))
self.put("/test/todo.ics", get_file_content("todo1.ics"))
start = "20060101T000000Z"
end = "20060104T000000Z"
request = f"""
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="{start}" end="{end}"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 1
assert "/test/calendar.ics" in responses
vevent_response = responses["/test/calendar.ics"]
assert type(vevent_response) is dict
status, element = vevent_response["C:calendar-data"]
assert status == 200 and element.text
assert "BEGIN:VEVENT" in element.text
assert "UID:" in element.text
assert "BEGIN:VTODO" not in element.text
def test_report_time_range_no_vevent(self) -> None:
"""Test calendar-query with time-range filter, no matching VEVENT."""
self.mkcalendar("/test/")
self.put("/test/calendar.ics/", get_file_content("event_daily_rrule.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="20000101T000000Z" end="20000105T000000Z"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20000101T000000Z" end="20000105T000000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 0
def test_report_time_range_one_vevent(self) -> None:
"""Test calendar-query with time-range filter, matches one VEVENT."""
self.mkcalendar("/test/")
self.put("/test/calendar1.ics/", get_file_content("event_daily_rrule.ics"))
self.put("/test/calendar2.ics/", get_file_content("event1.ics"))
start = "20060101T000000Z"
end = "20060104T000000Z"
request = f"""
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="{start}" end="{end}"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 1
response = responses["/test/calendar1.ics"]
assert type(response) is dict
status, element = response["C:calendar-data"]
assert status == 200 and element.text
assert "BEGIN:VEVENT" in element.text
assert "RECURRENCE-ID:20060103T170000Z" in element.text
assert "DTSTART:20060103T170000Z" in element.text