filter.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2015 Guillaume Ayoub
  5. # Copyright © 2017-2018 Unrud<unrud@outlook.com>
  6. #
  7. # This library is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This library is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  19. import math
  20. from datetime import date, datetime, timedelta, timezone
  21. from itertools import chain
  22. from radicale import xmlutils
  23. from radicale.log import logger
  24. DAY = timedelta(days=1)
  25. SECOND = timedelta(seconds=1)
  26. DATETIME_MIN = datetime.min.replace(tzinfo=timezone.utc)
  27. DATETIME_MAX = datetime.max.replace(tzinfo=timezone.utc)
  28. TIMESTAMP_MIN = math.floor(DATETIME_MIN.timestamp())
  29. TIMESTAMP_MAX = math.ceil(DATETIME_MAX.timestamp())
  30. def date_to_datetime(date_):
  31. """Transform a date to a UTC datetime.
  32. If date_ is a datetime without timezone, return as UTC datetime. If date_
  33. is already a datetime with timezone, return as is.
  34. """
  35. if not isinstance(date_, datetime):
  36. date_ = datetime.combine(date_, datetime.min.time())
  37. if not date_.tzinfo:
  38. date_ = date_.replace(tzinfo=timezone.utc)
  39. return date_
  40. def comp_match(item, filter_, level=0):
  41. """Check whether the ``item`` matches the comp ``filter_``.
  42. If ``level`` is ``0``, the filter is applied on the
  43. item's collection. Otherwise, it's applied on the item.
  44. See rfc4791-9.7.1.
  45. """
  46. # TODO: Filtering VALARM and VFREEBUSY is not implemented
  47. # HACK: the filters are tested separately against all components
  48. if level == 0:
  49. tag = item.name
  50. elif level == 1:
  51. tag = item.component_name
  52. else:
  53. logger.warning(
  54. "Filters with three levels of comp-filter are not supported")
  55. return True
  56. if not tag:
  57. return False
  58. name = filter_.get("name").upper()
  59. if len(filter_) == 0:
  60. # Point #1 of rfc4791-9.7.1
  61. return name == tag
  62. if len(filter_) == 1:
  63. if filter_[0].tag == xmlutils.make_tag("C", "is-not-defined"):
  64. # Point #2 of rfc4791-9.7.1
  65. return name != tag
  66. if name != tag:
  67. return False
  68. if (level == 0 and name != "VCALENDAR" or
  69. level == 1 and name not in ("VTODO", "VEVENT", "VJOURNAL")):
  70. logger.warning("Filtering %s is not supported" % name)
  71. return True
  72. # Point #3 and #4 of rfc4791-9.7.1
  73. components = ([item.vobject_item] if level == 0
  74. else list(getattr(item.vobject_item,
  75. "%s_list" % tag.lower())))
  76. for child in filter_:
  77. if child.tag == xmlutils.make_tag("C", "prop-filter"):
  78. if not any(prop_match(comp, child, "C")
  79. for comp in components):
  80. return False
  81. elif child.tag == xmlutils.make_tag("C", "time-range"):
  82. if not time_range_match(item.vobject_item, filter_[0], tag):
  83. return False
  84. elif child.tag == xmlutils.make_tag("C", "comp-filter"):
  85. if not comp_match(item, child, level=level + 1):
  86. return False
  87. else:
  88. raise ValueError("Unexpected %r in comp-filter" % child.tag)
  89. return True
  90. def prop_match(vobject_item, filter_, ns):
  91. """Check whether the ``item`` matches the prop ``filter_``.
  92. See rfc4791-9.7.2 and rfc6352-10.5.1.
  93. """
  94. name = filter_.get("name").lower()
  95. if len(filter_) == 0:
  96. # Point #1 of rfc4791-9.7.2
  97. return name in vobject_item.contents
  98. if len(filter_) == 1:
  99. if filter_[0].tag == xmlutils.make_tag("C", "is-not-defined"):
  100. # Point #2 of rfc4791-9.7.2
  101. return name not in vobject_item.contents
  102. if name not in vobject_item.contents:
  103. return False
  104. # Point #3 and #4 of rfc4791-9.7.2
  105. for child in filter_:
  106. if ns == "C" and child.tag == xmlutils.make_tag("C", "time-range"):
  107. if not time_range_match(vobject_item, child, name):
  108. return False
  109. elif child.tag == xmlutils.make_tag(ns, "text-match"):
  110. if not text_match(vobject_item, child, name, ns):
  111. return False
  112. elif child.tag == xmlutils.make_tag(ns, "param-filter"):
  113. if not param_filter_match(vobject_item, child, name, ns):
  114. return False
  115. else:
  116. raise ValueError("Unexpected %r in prop-filter" % child.tag)
  117. return True
  118. def time_range_match(vobject_item, filter_, child_name):
  119. """Check whether the component/property ``child_name`` of
  120. ``vobject_item`` matches the time-range ``filter_``."""
  121. start = filter_.get("start")
  122. end = filter_.get("end")
  123. if not start and not end:
  124. return False
  125. if start:
  126. start = datetime.strptime(start, "%Y%m%dT%H%M%SZ")
  127. else:
  128. start = datetime.min
  129. if end:
  130. end = datetime.strptime(end, "%Y%m%dT%H%M%SZ")
  131. else:
  132. end = datetime.max
  133. start = start.replace(tzinfo=timezone.utc)
  134. end = end.replace(tzinfo=timezone.utc)
  135. matched = False
  136. def range_fn(range_start, range_end, is_recurrence):
  137. nonlocal matched
  138. if start < range_end and range_start < end:
  139. matched = True
  140. return True
  141. if end < range_start and not is_recurrence:
  142. return True
  143. return False
  144. def infinity_fn(start):
  145. return False
  146. visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
  147. return matched
  148. def visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn):
  149. """Visit all time ranges in the component/property ``child_name`` of
  150. `vobject_item`` with visitors ``range_fn`` and ``infinity_fn``.
  151. ``range_fn`` gets called for every time_range with ``start`` and ``end``
  152. datetimes and ``is_recurrence`` as arguments. If the function returns True,
  153. the operation is cancelled.
  154. ``infinity_fn`` gets called when an infiite recurrence rule is detected
  155. with ``start`` datetime as argument. If the function returns True, the
  156. operation is cancelled.
  157. See rfc4791-9.9.
  158. """
  159. # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled
  160. # with Recurrence ID affects the recurrence itself and all following
  161. # recurrences too. This is not respected and client don't seem to bother
  162. # either.
  163. def getrruleset(child, ignore=()):
  164. if (hasattr(child, "rrule") and
  165. ";UNTIL=" not in child.rrule.value.upper() and
  166. ";COUNT=" not in child.rrule.value.upper()):
  167. for dtstart in child.getrruleset(addRDate=True):
  168. if dtstart in ignore:
  169. continue
  170. if infinity_fn(date_to_datetime(dtstart)):
  171. return (), True
  172. break
  173. return filter(lambda dtstart: dtstart not in ignore,
  174. child.getrruleset(addRDate=True)), False
  175. def get_children(components):
  176. main = None
  177. recurrences = []
  178. for comp in components:
  179. if hasattr(comp, "recurrence_id") and comp.recurrence_id.value:
  180. recurrences.append(comp.recurrence_id.value)
  181. if comp.rruleset:
  182. # Prevent possible infinite loop
  183. raise ValueError("Overwritten recurrence with RRULESET")
  184. yield comp, True, ()
  185. else:
  186. if main is not None:
  187. raise ValueError("Multiple main components")
  188. main = comp
  189. if main is None:
  190. raise ValueError("Main component missing")
  191. yield main, False, recurrences
  192. # Comments give the lines in the tables of the specification
  193. if child_name == "VEVENT":
  194. for child, is_recurrence, recurrences in get_children(
  195. vobject_item.vevent_list):
  196. # TODO: check if there's a timezone
  197. dtstart = child.dtstart.value
  198. if child.rruleset:
  199. dtstarts, infinity = getrruleset(child, recurrences)
  200. if infinity:
  201. return
  202. else:
  203. dtstarts = (dtstart,)
  204. dtend = getattr(child, "dtend", None)
  205. if dtend is not None:
  206. dtend = dtend.value
  207. original_duration = (dtend - dtstart).total_seconds()
  208. dtend = date_to_datetime(dtend)
  209. duration = getattr(child, "duration", None)
  210. if duration is not None:
  211. original_duration = duration = duration.value
  212. for dtstart in dtstarts:
  213. dtstart_is_datetime = isinstance(dtstart, datetime)
  214. dtstart = date_to_datetime(dtstart)
  215. if dtend is not None:
  216. # Line 1
  217. dtend = dtstart + timedelta(seconds=original_duration)
  218. if range_fn(dtstart, dtend, is_recurrence):
  219. return
  220. elif duration is not None:
  221. if original_duration is None:
  222. original_duration = duration.seconds
  223. if duration.seconds > 0:
  224. # Line 2
  225. if range_fn(dtstart, dtstart + duration,
  226. is_recurrence):
  227. return
  228. else:
  229. # Line 3
  230. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  231. return
  232. elif dtstart_is_datetime:
  233. # Line 4
  234. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  235. return
  236. else:
  237. # Line 5
  238. if range_fn(dtstart, dtstart + DAY, is_recurrence):
  239. return
  240. elif child_name == "VTODO":
  241. for child, is_recurrence, recurrences in get_children(
  242. vobject_item.vtodo_list):
  243. dtstart = getattr(child, "dtstart", None)
  244. duration = getattr(child, "duration", None)
  245. due = getattr(child, "due", None)
  246. completed = getattr(child, "completed", None)
  247. created = getattr(child, "created", None)
  248. if dtstart is not None:
  249. dtstart = date_to_datetime(dtstart.value)
  250. if duration is not None:
  251. duration = duration.value
  252. if due is not None:
  253. due = date_to_datetime(due.value)
  254. if dtstart is not None:
  255. original_duration = (due - dtstart).total_seconds()
  256. if completed is not None:
  257. completed = date_to_datetime(completed.value)
  258. if created is not None:
  259. created = date_to_datetime(created.value)
  260. original_duration = (completed - created).total_seconds()
  261. elif created is not None:
  262. created = date_to_datetime(created.value)
  263. if child.rruleset:
  264. reference_dates, infinity = getrruleset(child, recurrences)
  265. if infinity:
  266. return
  267. else:
  268. if dtstart is not None:
  269. reference_dates = (dtstart,)
  270. elif due is not None:
  271. reference_dates = (due,)
  272. elif completed is not None:
  273. reference_dates = (completed,)
  274. elif created is not None:
  275. reference_dates = (created,)
  276. else:
  277. # Line 8
  278. if range_fn(DATETIME_MIN, DATETIME_MAX, is_recurrence):
  279. return
  280. reference_dates = ()
  281. for reference_date in reference_dates:
  282. reference_date = date_to_datetime(reference_date)
  283. if dtstart is not None and duration is not None:
  284. # Line 1
  285. if range_fn(reference_date,
  286. reference_date + duration + SECOND,
  287. is_recurrence):
  288. return
  289. if range_fn(reference_date + duration - SECOND,
  290. reference_date + duration + SECOND,
  291. is_recurrence):
  292. return
  293. elif dtstart is not None and due is not None:
  294. # Line 2
  295. due = reference_date + timedelta(seconds=original_duration)
  296. if (range_fn(reference_date, due, is_recurrence) or
  297. range_fn(reference_date,
  298. reference_date + SECOND, is_recurrence) or
  299. range_fn(due - SECOND, due, is_recurrence) or
  300. range_fn(due - SECOND, reference_date + SECOND,
  301. is_recurrence)):
  302. return
  303. elif dtstart is not None:
  304. if range_fn(reference_date, reference_date + SECOND,
  305. is_recurrence):
  306. return
  307. elif due is not None:
  308. # Line 4
  309. if range_fn(reference_date - SECOND, reference_date,
  310. is_recurrence):
  311. return
  312. elif completed is not None and created is not None:
  313. # Line 5
  314. completed = reference_date + timedelta(
  315. seconds=original_duration)
  316. if (range_fn(reference_date - SECOND,
  317. reference_date + SECOND,
  318. is_recurrence) or
  319. range_fn(completed - SECOND, completed + SECOND,
  320. is_recurrence) or
  321. range_fn(reference_date - SECOND,
  322. reference_date + SECOND, is_recurrence) or
  323. range_fn(completed - SECOND, completed + SECOND,
  324. is_recurrence)):
  325. return
  326. elif completed is not None:
  327. # Line 6
  328. if range_fn(reference_date - SECOND,
  329. reference_date + SECOND, is_recurrence):
  330. return
  331. elif created is not None:
  332. # Line 7
  333. if range_fn(reference_date, DATETIME_MAX, is_recurrence):
  334. return
  335. elif child_name == "VJOURNAL":
  336. for child, is_recurrence, recurrences in get_children(
  337. vobject_item.vjournal_list):
  338. dtstart = getattr(child, "dtstart", None)
  339. if dtstart is not None:
  340. dtstart = dtstart.value
  341. if child.rruleset:
  342. dtstarts, infinity = getrruleset(child, recurrences)
  343. if infinity:
  344. return
  345. else:
  346. dtstarts = (dtstart,)
  347. for dtstart in dtstarts:
  348. dtstart_is_datetime = isinstance(dtstart, datetime)
  349. dtstart = date_to_datetime(dtstart)
  350. if dtstart_is_datetime:
  351. # Line 1
  352. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  353. return
  354. else:
  355. # Line 2
  356. if range_fn(dtstart, dtstart + DAY, is_recurrence):
  357. return
  358. else:
  359. # Match a property
  360. child = getattr(vobject_item, child_name.lower())
  361. if isinstance(child, date):
  362. range_fn(child, child + DAY, False)
  363. elif isinstance(child, datetime):
  364. range_fn(child, child + SECOND, False)
  365. def text_match(vobject_item, filter_, child_name, ns, attrib_name=None):
  366. """Check whether the ``item`` matches the text-match ``filter_``.
  367. See rfc4791-9.7.5.
  368. """
  369. # TODO: collations are not supported, but the default ones needed
  370. # for DAV servers are actually pretty useless. Texts are lowered to
  371. # be case-insensitive, almost as the "i;ascii-casemap" value.
  372. text = next(filter_.itertext()).lower()
  373. match_type = "contains"
  374. if ns == "CR":
  375. match_type = filter_.get("match-type", match_type)
  376. def match(value):
  377. value = value.lower()
  378. if match_type == "equals":
  379. return value == text
  380. if match_type == "contains":
  381. return text in value
  382. if match_type == "starts-with":
  383. return value.startswith(text)
  384. if match_type == "ends-with":
  385. return value.endswith(text)
  386. raise ValueError("Unexpected text-match match-type: %r" % match_type)
  387. children = getattr(vobject_item, "%s_list" % child_name, [])
  388. if attrib_name:
  389. condition = any(
  390. match(attrib) for child in children
  391. for attrib in child.params.get(attrib_name, []))
  392. else:
  393. condition = any(match(child.value) for child in children)
  394. if filter_.get("negate-condition") == "yes":
  395. return not condition
  396. else:
  397. return condition
  398. def param_filter_match(vobject_item, filter_, parent_name, ns):
  399. """Check whether the ``item`` matches the param-filter ``filter_``.
  400. See rfc4791-9.7.3.
  401. """
  402. name = filter_.get("name").upper()
  403. children = getattr(vobject_item, "%s_list" % parent_name, [])
  404. condition = any(name in child.params for child in children)
  405. if len(filter_):
  406. if filter_[0].tag == xmlutils.make_tag(ns, "text-match"):
  407. return condition and text_match(
  408. vobject_item, filter_[0], parent_name, ns, name)
  409. elif filter_[0].tag == xmlutils.make_tag(ns, "is-not-defined"):
  410. return not condition
  411. else:
  412. return condition
  413. def simplify_prefilters(filters, collection_tag="VCALENDAR"):
  414. """Creates a simplified condition from ``filters``.
  415. Returns a tuple (``tag``, ``start``, ``end``, ``simple``) where ``tag`` is
  416. a string or None (match all) and ``start`` and ``end`` are POSIX
  417. timestamps (as int). ``simple`` is a bool that indicates that ``filters``
  418. and the simplified condition are identical.
  419. """
  420. flat_filters = tuple(chain.from_iterable(filters))
  421. simple = len(flat_filters) <= 1
  422. for col_filter in flat_filters:
  423. if collection_tag != "VCALENDAR":
  424. simple = False
  425. break
  426. if (col_filter.tag != xmlutils.make_tag("C", "comp-filter") or
  427. col_filter.get("name").upper() != "VCALENDAR"):
  428. simple = False
  429. continue
  430. simple &= len(col_filter) <= 1
  431. for comp_filter in col_filter:
  432. if comp_filter.tag != xmlutils.make_tag("C", "comp-filter"):
  433. simple = False
  434. continue
  435. tag = comp_filter.get("name").upper()
  436. if comp_filter.find(
  437. xmlutils.make_tag("C", "is-not-defined")) is not None:
  438. simple = False
  439. continue
  440. simple &= len(comp_filter) <= 1
  441. for time_filter in comp_filter:
  442. if tag not in ("VTODO", "VEVENT", "VJOURNAL"):
  443. simple = False
  444. break
  445. if time_filter.tag != xmlutils.make_tag("C", "time-range"):
  446. simple = False
  447. continue
  448. start = time_filter.get("start")
  449. end = time_filter.get("end")
  450. if start:
  451. start = math.floor(datetime.strptime(
  452. start, "%Y%m%dT%H%M%SZ").replace(
  453. tzinfo=timezone.utc).timestamp())
  454. else:
  455. start = TIMESTAMP_MIN
  456. if end:
  457. end = math.ceil(datetime.strptime(
  458. end, "%Y%m%dT%H%M%SZ").replace(
  459. tzinfo=timezone.utc).timestamp())
  460. else:
  461. end = TIMESTAMP_MAX
  462. return tag, start, end, simple
  463. return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
  464. return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple