diff --git a/caldav/davclient.py b/caldav/davclient.py index e9bc8c2f..38d91b2b 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -310,7 +310,12 @@ def _expand_simple_prop( values = [] if proptag in props_found: prop_xml = props_found[proptag] - error.assert_(not prop_xml.items()) + if prop_xml.items(): + from caldav.lib.debug import xmlstring + + log.error( + f"If you see this, please add a report at https://github.com/python-caldav/caldav/issues/209 - in _expand_simple_prop, dealing with {proptag}, extra items found: {xmlstring(prop_xml)}." + ) if not xpath and len(prop_xml) == 0: if prop_xml.text: values.append(prop_xml.text) diff --git a/caldav/lib/vcal.py b/caldav/lib/vcal.py index 3962abdf..09946c77 100644 --- a/caldav/lib/vcal.py +++ b/caldav/lib/vcal.py @@ -150,8 +150,12 @@ def __call__(self, line): ## sorry for being english-language-euro-centric ... fits rather perfectly as default language for me :-) def create_ical(ical_fragment=None, objtype=None, language="en_DK", **props): - """ - I somehow feel this fits more into the icalendar library than here + """Creates some icalendar based on properties given as parameters. + It basically creates an icalendar object with all the boilerplate, + some sensible defaults, the properties given and returns it as a + string. + + TODO: timezones not supported so far """ ical_fragment = to_normal_str(ical_fragment) if "class_" in props: @@ -193,6 +197,7 @@ def create_ical(ical_fragment=None, objtype=None, language="en_DK", **props): if not component.get("uid") and not props.get("uid"): component.add("uid", uuid.uuid1()) + alarm = {} for prop in props: if props[prop] is not None: if isinstance(props[prop], datetime.datetime) and not props[prop].tzinfo: @@ -206,8 +211,12 @@ def create_ical(ical_fragment=None, objtype=None, language="en_DK", **props): parameters={"reltype": prop.upper()}, encode=True, ) + elif prop.startswith("alarm_"): + alarm[prop[6:]] = props[prop] else: component.add(prop, props[prop]) + if alarm: + add_alarm(my_instance, alarm) ret = to_normal_str(my_instance.to_ical()) if ical_fragment and ical_fragment.strip(): ret = re.sub( @@ -218,3 +227,11 @@ def create_ical(ical_fragment=None, objtype=None, language="en_DK", **props): count=1, ) return ret + + +def add_alarm(ical, alarm): + ia = icalendar.Alarm() + for prop in alarm: + ia.add(prop, alarm[prop]) + ical.subcomponents[0].add_component(ia) + return ical diff --git a/caldav/objects.py b/caldav/objects.py index eee96222..598d9c0c 100755 --- a/caldav/objects.py +++ b/caldav/objects.py @@ -864,7 +864,8 @@ def save_object( * ical - ical object (text) * no_overwrite - existing calendar objects should not be overwritten * no_create - don't create a new object, existing calendar objects should be updated - * ical_data - passed to lib.vcal.create_ical + * dt_start, dt_end, summary, etc - properties to be inserted into the icalendar object + * alarm_trigger, alarm_action, alarm_attach, etc - when given, one alarm will be added """ o = objclass( self.client, @@ -1345,6 +1346,8 @@ def build_search_xml_query( start=None, end=None, props=None, + alarm_start=None, + alarm_end=None, **kwargs, ): """This method will produce a caldav search query as an etree object. @@ -1405,6 +1408,11 @@ def build_search_xml_query( if start or end: filters.append(cdav.TimeRange(start, end)) + if alarm_start or alarm_end: + filters.append( + cdav.CompFilter("VALARM") + cdav.TimeRange(alarm_start, alarm_end) + ) + if todo is not None: if not todo: raise NotImplementedError() @@ -2426,7 +2434,7 @@ def copy(self, keep_uid: bool = False, new_parent: Optional[Any] = None) -> Self id=self.id if keep_uid else str(uuid.uuid1()), ) if new_parent or not keep_uid: - obj.url = obj.generate_url() + obj.url = obj._generate_url() else: obj.url = self.url return obj @@ -2516,7 +2524,7 @@ def _find_id_path(self, id=None, path=None) -> None: error.assert_(x.get("UID", None) == self.id) if path is None: - path = self.generate_url() + path = self._generate_url() else: path = self.parent.url.join(path) @@ -2545,7 +2553,7 @@ def _create(self, id=None, path=None, retry_on_failure=True) -> None: self._find_id_path(id=id, path=path) self._put() - def generate_url(self): + def _generate_url(self): ## See https://github.com/python-caldav/caldav/issues/143 for the rationale behind double-quoting slashes ## TODO: should try to wrap my head around issues that arises when id contains weird characters. maybe it's ## better to generate a new uuid here, particularly if id is in some unexpected format. diff --git a/tests/compatibility_issues.py b/tests/compatibility_issues.py index e38a9ea8..86b74768 100644 --- a/tests/compatibility_issues.py +++ b/tests/compatibility_issues.py @@ -44,6 +44,9 @@ """date searches covering recurrances may yield no results, """ """and events/todos may not be expanded with recurrances""", + 'no_alarmsearch': + """Searching for alarms may yield too few or too many or even a 500 internal server error""", + 'no_recurring_todo': """Recurring events are supported, but not recurring todos""", @@ -271,6 +274,9 @@ ## rather than date, just to have the test exercised ... but we ## should report this upstream #'broken_expand_on_exceptions', + + ## No alarm search (500 internal server error) + "no_alarmsearch", ] ## This can soon be removed (relevant for running tests under python 3.7 and python 3.8) @@ -286,11 +292,14 @@ except Exception: pass +## TODO - there has been quite some development in radicale recently, so this list +## should probably be gone through radicale = [ ## calendar listings and calendar creation works a bit ## "weird" on radicale "broken_expand", "no_default_calendar", + "no_alarmsearch", ## This is fixed and will be released soon ## freebusy is not supported yet, but on the long-term road map #"no_freebusy_rfc4791", diff --git a/tests/test_caldav.py b/tests/test_caldav.py index b3f11c50..3b421dbf 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -983,6 +983,46 @@ def testCreateEvent(self): assert len(events) == len(existing_events) + 2 ev2.delete() + def testAlarm(self): + ## Ref https://github.com/python-caldav/caldav/issues/132 + c = self._fixCalendar() + ev = c.save_event( + dtstart=datetime(2015, 10, 10, 8, 0, 0), + summary="This is a test event", + dtend=datetime(2016, 10, 10, 9, 0, 0), + alarm_trigger=timedelta(minutes=-15), + alarm_action="AUDIO", + ) + + self.skip_on_compatibility_flag("no_alarmsearch") + + ## So we have an alarm that goes off 07:45 for an event starting 08:00 + + ## Search for alarms after 8 should find nothing + ## (search for an alarm 07:55 - 08:05 should most likely find nothing). + assert ( + len( + c.search( + event=True, + alarm_start=datetime(2015, 10, 10, 8, 1), + alarm_end=datetime(2015, 10, 10, 8, 7), + ) + ) + == 0 + ) + + ## Search for alarms from 07:40 to 07:55 should definitively find the alarm. + assert ( + len( + c.search( + event=True, + alarm_start=datetime(2015, 10, 10, 7, 40), + alarm_end=datetime(2015, 10, 10, 7, 55), + ) + ) + == 1 + ) + def testCalendarByFullURL(self): """ ref private email, passing a full URL as cal_id works in 0.5.0 but