Skip to content

Commit

Permalink
Test RFC 9074 alarms
Browse files Browse the repository at this point in the history
  • Loading branch information
niccokunzmann committed Oct 25, 2024
1 parent c3bd555 commit 92dae9c
Show file tree
Hide file tree
Showing 3 changed files with 76 additions and 14 deletions.
42 changes: 30 additions & 12 deletions src/icalendar/alarms.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Compute the times and states of alarms.
This takes different calendar software into account and RFC 9074 (Alarm Extension).
- Outlook does not export VALARM information
- Google Calendar uses the DTSTAMP to acknowledge the alarms
- Thunderbird snoozes the alarms with a X-MOZ-SNOOZE-TIME attribute in the event
- Thunderbird acknowledges the alarms with a X-MOZ-LASTACK attribute in the event
- Etar deletes alarms that are not active any more
This takes different calendar software into account and the RFC 9074 (Alarm Extension).
- RFC 9074 defines an ACKNOWLEDGED property in the VALARM.
- Outlook does not export VALARM information.
- Google Calendar uses the DTSTAMP to acknowledge the alarms.
- Thunderbird snoozes the alarms with a X-MOZ-SNOOZE-TIME attribute in the event.
- Thunderbird acknowledges the alarms with a X-MOZ-LASTACK attribute in the event.
- Etar deletes alarms that are acknowledged.
- Nextcloud's Webinterface does not do anything with the alarms when the time passes.
"""

from __future__ import annotations
Expand Down Expand Up @@ -85,6 +87,19 @@ def __init__(
self._last_ack = acknowledged_until
self._snooze_until = snoozed_until

@property
def acknowledged(self) -> Optional[datetime]:
"""The time in UTC at which this alarm was last acknowledged.
If the alarm was not acknowledged (dismissed), then this is None.
"""
ack = self.alarm.ACKNOWLEDGED
if ack is None:
return self._last_ack
if self._last_ack is None:
return ack
return max(ack, self._last_ack)

@property
def alarm(self) -> Alarm:
"""The alarm component."""
Expand All @@ -108,16 +123,19 @@ def is_active(self) -> bool:
To calculate if the alarm really happened, we need it to be in a timezone.
If a timezone is required but not given, we throw an IncompleteAlarmInformation.
"""
if not self._last_ack:
acknowledged = self.acknowledged
if not acknowledged:
# if nothing is acknowledged, this alarm counts
return True
if self._snooze_until is not None and self._snooze_until > self._last_ack:
if self._snooze_until is not None and self._snooze_until > acknowledged:
return True
trigger = self.trigger
if trigger.tzinfo is None:
raise LocalTimezoneMissing("A local timezone is required to check if the alarm is still active. Use Alarms.set_local_timezone().")
# print(f"trigger == {trigger} > {self._last_ack} == last ack")
return trigger > self._last_ack
raise LocalTimezoneMissing(
"A local timezone is required to check if the alarm is still active. "
"Use Alarms.set_local_timezone()."
)
return trigger > acknowledged

@property
def trigger(self) -> date:
Expand Down
29 changes: 29 additions & 0 deletions src/icalendar/cal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,35 @@ def REPEAT(self, value: int) -> None:
self["REPEAT"] = int(value)

DURATION = Event.DURATION # TODO: adjust once https://github.com/collective/icalendar/pull/733 is merged
ACKNOWLEDGED = create_utc_property("ACKNOWLEDGED",
"""This is defined in RFC 9074:
Purpose: This property specifies the UTC date and time at which the
corresponding alarm was last sent or acknowledged.
This property is used to specify when an alarm was last sent or acknowledged.
This allows clients to determine when a pending alarm has been acknowledged
by a calendar user so that any alerts can be dismissed across multiple devices.
It also allows clients to track repeating alarms or alarms on recurring events or
to-dos to ensure that the right number of missed alarms can be tracked.
Clients SHOULD set this property to the current date-time value in UTC
when a calendar user acknowledges a pending alarm. Certain kinds of alarms,
such as email-based alerts, might not provide feedback as to when the calendar user
sees them. For those kinds of alarms, the client SHOULD set this property
when the alarm is triggered and the action is successfully carried out.
When an alarm is triggered on a client, clients can check to see if an"ACKNOWLEDGED"
property is present. If it is, and the value of that property is greater than or
equal to the computed trigger time for the alarm, then the client SHOULD NOT trigger
the alarm. Similarly, if an alarm has been triggered and
an "alert" has been presented to a calendar user, clients can monitor
the iCalendar data to determine whether an "ACKNOWLEDGED" property is added or
changed in the alarm component. If the value of any "ACKNOWLEDGED" property
in the alarm changes and is greater than or equal to the trigger time of the alarm,
then clients SHOULD dismiss or cancel any "alert" presented to the calendar user.
""")


class Calendar(Component):
"""This is the base object for an iCalendar file.
Expand Down
19 changes: 17 additions & 2 deletions src/icalendar/tests/test_issue_716_alarm_time_computation.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,22 @@ def test_snoozed_alarm_has_trigger_at_snooze_time(tzp, snooze):
@pytest.mark.parametrize(
("event_index", "alarm_times"),
[
# Assume that we have the following event with an alarm set to trigger 15 minutes before the meeting:
(1, ("20210302T101500",)),
# When the alarm is triggered, the user decides to "snooze" it for 5 minutes.
# The client acknowledges the original alarm and creates a new "snooze"
# alarm as a sibling of, and relates it to, the original alarm (note that
# both occurrences of "VALARM" reside within the same "parent" VEVENT):
(2, ("20210302T102000",)),
# When the "snooze" alarm is triggered, the user decides to "snooze" it
# again for an additional 5 minutes. The client once again acknowledges
# the original alarm, removes the triggered "snooze" alarm, and creates another
# new "snooze" alarm as a sibling of, and relates it to, the original alarm
# (note the different UID for the new "snooze" alarm):
(3, ("20210302T102500",)),
# When the second "snooze" alarm is triggered, the user decides to dismiss it.
# The client acknowledges both the original alarm and the second "snooze" alarm:
(4, ()),
]
)
def test_rfc_9074_alarm_times(events, event_index, alarm_times):
Expand All @@ -297,9 +312,9 @@ def test_rfc_9074_alarm_times(events, event_index, alarm_times):
Add times use America/New_York as timezone.
"""
a = Alarms(events[f"rfc_9074_example_{event_index}"])
assert len(a.times) == len(alarm_times)
assert len(a.active) == len(alarm_times)
expected_alarm_times = {vDatetime.from_ical(t, "America/New_York") for t in alarm_times}
computed_alarm_times = {alarm.trigger for alarm in a.times}
computed_alarm_times = {alarm.trigger for alarm in a.active}
assert expected_alarm_times == computed_alarm_times


Expand Down

0 comments on commit 92dae9c

Please sign in to comment.