Skip to content

Commit

Permalink
Test the snoozing of alarms
Browse files Browse the repository at this point in the history
  • Loading branch information
niccokunzmann committed Oct 25, 2024
1 parent 41244ba commit c3bd555
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 31 deletions.
107 changes: 81 additions & 26 deletions src/icalendar/alarms.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@
class IncompleteAlarmInformation(ValueError):
"""The alarms cannot be calculated yet because information is missing."""

class ComponentStartMissing(IncompleteAlarmInformation):
"""We are missing the start of a component that the alarm is for.
Use Alarms.set_start().
"""

class ComponentEndMissing(IncompleteAlarmInformation):
"""We are missing the end of a component that the alarm is for.
Use Alarms.set_end().
"""

class LocalTimezoneMissing(IncompleteAlarmInformation):
"""We are missing the local timezone to compute the value.
Use Alarms.set_local_timezone().
"""


class AlarmTime:
"""An alarm time with all the information."""
Expand All @@ -37,17 +55,29 @@ def __init__(
trigger : datetime,
acknowledged_until:Optional[datetime]=None,
snoozed_until:Optional[datetime]=None,
parent: Optional[Parent]=None
parent: Optional[Parent]=None,
):
"""Create a new AlarmTime.
alarm - the Alarm component
trigger - a date or datetime at which to trigger the alarm
acknowledged_until - an optional datetime in UTC until when all alarms
alarm
the Alarm component
trigger
a date or datetime at which to trigger the alarm
acknowledged_until
an optional datetime in UTC until when all alarms
have been acknowledged
snoozed_until - an optional datetime in UTC until which all alarms of
snoozed_until
an optional datetime in UTC until which all alarms of
the same parent are snoozed
parent - the optional parent component the alarm refers to
parent
the optional parent component the alarm refers to
local_tzinfo
the local timezone that events without tzinfo should have
"""
self._alarm = alarm
self._parent = parent
Expand All @@ -68,7 +98,7 @@ def parent(self) -> Optional[Parent]:
"""
return self._parent

def is_active_in(self, timezone:Optional[tzinfo]=None) -> bool:
def is_active(self) -> bool:
"""Whether this alarm is active (True) or acknowledged (False).
E.g. in some calendar software, this is True until the user had a look
Expand All @@ -81,11 +111,11 @@ def is_active_in(self, timezone:Optional[tzinfo]=None) -> bool:
if not self._last_ack:
# if nothing is acknowledged, this alarm counts
return True
trigger = self.trigger if timezone is None else tzp.localize(self.trigger, timezone)
if trigger.tzinfo is None:
raise IncompleteAlarmInformation("A timezone is required to check if the alarm is still active.")
if self._snooze_until is not None and self._snooze_until > self._last_ack:
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

Expand All @@ -94,8 +124,9 @@ def trigger(self) -> date:
"""This is the time to trigger the alarm.
If the alarm has been snoozed, this can differ from the TRIGGER property.
Use is_active_in() to avoid timezone issues.
"""
if self._snooze_until is not None and self._snooze_until > self._trigger:
return self._snooze_until
return self._trigger


Expand All @@ -118,6 +149,7 @@ def __init__(self, component:Optional[Alarm|Event|Todo]=None):
self._parent : Optional[Parent] = None
self._last_ack : Optional[datetime] = None
self._snooze_until : Optional[datetime] = None
self._local_tzinfo : Optional[tzinfo] = None

if component is not None:
self.add_component(component)
Expand Down Expand Up @@ -163,15 +195,15 @@ def add_alarm(self, alarm: Alarm) -> None:
else:
self._end_alarms.append(alarm)

def set_start(self, dt: date):
def set_start(self, dt: Optional[date]):
"""Set the start of the component.
If you have only absolute alarms, this is not required.
If you have alarms relative to the start of a compoment, set the start here.
"""
self._start = dt

def set_end(self, dt: date):
def set_end(self, dt: Optional[date]):
"""Set the end of the component.
If you have only absolute alarms, this is not required.
Expand All @@ -188,29 +220,43 @@ def _add(self, dt: date, td:timedelta):
return normalize_pytz(dt + td)

def acknowledge_until(self, dt: Optional[date]) -> None:
"""This is the time when all the alarms of this component were acknowledged.
"""This is the time in UTC when all the alarms of this component were acknowledged.
You can set several times like this. Only the last one counts.
Only the last call counts.
Since RFC 9074 (Alarm Extension) was created later,
calendar implementations differ in how they acknowledge alarms.
E.g. Thunderbird and Google Calendar store the last time
an event has been acknowledged because of an alarm.
All alarms that happen before this time will be ackknowledged at this dt.
All alarms that happen before this time count as ackknowledged.
"""
if dt is not None:
self._last_ack = tzp.localize_utc(dt)

def snooze_until(self, dt: Optional[date]) -> None:
"""This is the time when all the alarms of this component were snoozed.
"""This is the time in UTC when all the alarms of this component were snoozed.
Only the last call counts.
You can set several times like this. Only the last one counts.
The alarms are supposed to turn up again at dt when they are not acknowledged
but snoozed.
"""
if dt is not None:
self._snooze_until = tzp.localize_utc(dt)

def set_local_timezone(self, tzinfo:Optional[tzinfo|str]):
"""Set the local timezone.
Events are sometimes in local time.
In order to compute the exact time of the alarm, some
alarms without timezone are considered local.
Some computations work without setting this, others don't.
If they need this information, expect a LocalTimezoneMissing exception
somewhere down the line.
"""
self._local_tzinfo = tzp.timezone(tzinfo) if isinstance(tzinfo, str) else tzinfo

@property
def times(self) -> list[AlarmTime]:
"""Compute and return the times of the alarms given.
Expand Down Expand Up @@ -238,6 +284,8 @@ def _repeat(self, first: datetime, alarm: Alarm) -> Generator[datetime]:

def _alarm_time(self, alarm: Alarm, trigger:date):
"""Create an alarm time with the additional attributes."""
if getattr(trigger, "tzinfo", None) is None and self._local_tzinfo is not None:
trigger = normalize_pytz(trigger.replace(tzinfo=self._local_tzinfo))
return AlarmTime(alarm, trigger, self._last_ack, self._snooze_until, self._parent)

def _get_absolute_alarm_times(self) -> list[AlarmTime]:
Expand All @@ -251,7 +299,7 @@ def _get_absolute_alarm_times(self) -> list[AlarmTime]:
def _get_start_alarm_times(self) -> list[AlarmTime]:
"""Return a list of alarm times relative to the start of the component."""
if self._start is None and self._start_alarms:
raise IncompleteAlarmInformation("Use Alarms.set_start because at least one alarm is relative to the start of a component.")
raise ComponentStartMissing("Use Alarms.set_start because at least one alarm is relative to the start of a component.")
return [
self._alarm_time(alarm , trigger)
for alarm in self._start_alarms
Expand All @@ -261,14 +309,15 @@ def _get_start_alarm_times(self) -> list[AlarmTime]:
def _get_end_alarm_times(self) -> list[AlarmTime]:
"""Return a list of alarm times relative to the start of the component."""
if self._end is None and self._end_alarms:
raise IncompleteAlarmInformation("Use Alarms.set_end because at least one alarm is relative to the end of a component.")
raise ComponentEndMissing("Use Alarms.set_end because at least one alarm is relative to the end of a component.")
return [
self._alarm_time(alarm , trigger)
for alarm in self._end_alarms
for trigger in self._repeat(self._add(self._end, alarm["TRIGGER"].dt), alarm)
]

def active_in(self, timezone:Optional[tzinfo|str]=None) -> list[AlarmTime]:
@property
def active(self) -> list[AlarmTime]:
"""The alarm times that are still active and not acknowledged.
This considers snoozed alarms.
Expand All @@ -277,7 +326,13 @@ def active_in(self, timezone:Optional[tzinfo|str]=None) -> list[AlarmTime]:
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.
"""
timezone = tzp.timezone(timezone) if isinstance(timezone, str) else timezone
return [alarm_time for alarm_time in self.times if alarm_time.is_active_in(timezone)]

__all__ = ["Alarms", "AlarmTime", "IncompleteAlarmInformation"]
return [alarm_time for alarm_time in self.times if alarm_time.is_active()]

__all__ = [
"Alarms",
"AlarmTime",
"IncompleteAlarmInformation",
"ComponentEndMissing",
"ComponentStartMissing",
"LocalTimezoneMissing"
]
58 changes: 53 additions & 5 deletions src/icalendar/tests/test_issue_716_alarm_time_computation.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def test_cannot_compute_relative_alarm_without_end(alarms):
(datetime(2024, 10, 29, 13, 20), None, datetime(2024, 10, 29, 13, 20)),
]
)
def test_can_complete_relative_calculation_if_a_start_is_given(alarms, dtend, timezone, trigger, tzp):
def test_can_complete_relative_calculation(alarms, dtend, timezone, trigger, tzp):
"""The start is given and required."""
start = (dtend if timezone is None else tzp.localize(dtend, timezone))
alarms = Alarms(alarms.rfc_5545_end)
Expand Down Expand Up @@ -201,7 +201,7 @@ def test_number_of_active_alarms_from_calendar_software(calendars, calendar, ind
"""Check that we extract calculate the correct amount of active alarms."""
event = calendars[calendar].subcomponents[index]
a = Alarms(event)
active_alarms = a.active_in() # We do not need to pass a timezone because the events have a timezone
active_alarms = a.active # We do not need to pass a timezone because the events have a timezone
assert len(active_alarms) == count, f"{message} - I expect {count} alarms active but got {len(active_alarms)}."


Expand All @@ -226,8 +226,9 @@ def test_number_of_active_alarms_with_moving_time(start, acknowledged, count, tz
a = Alarms()
a.add_alarm(three_alarms)
a.set_start(start)
a.set_local_timezone(timezone)
a.acknowledge_until(tzp.localize_utc(acknowledged))
active = a.active_in(timezone)
active = a.active
assert len(active) == count


Expand All @@ -238,8 +239,8 @@ def test_incomplete_alarm_information_for_active_state(tzp):
a.set_start(date(2017, 12, 1))
a.acknowledge_until(tzp.localize_utc(datetime(2012, 10, 10, 12)))
with pytest.raises(IncompleteAlarmInformation) as e:
a.active_in()
assert e.value.args[0] == "A timezone is required to check if the alarm is still active."
a.active # noqa: B018
assert e.value.args[0] == f"A local timezone is required to check if the alarm is still active. Use Alarms.{Alarms.set_local_timezone.__name__}()."


@pytest.mark.parametrize(
Expand All @@ -258,3 +259,50 @@ def test_thunderbird_recognition(calendars, calendar_name):
event = calendar.subcomponents[-1]
assert isinstance(event, Event)
assert event.is_thunderbird() == ("thunderbird" in calendar_name)


@pytest.mark.parametrize(
"snooze",
[
datetime(2012, 10, 10, 11, 1), # before everything
datetime(2017, 12, 1, 10, 1),
datetime(2017, 12, 1, 11, 1),
datetime(2017, 12, 1, 12, 1),
datetime(2017, 12, 1, 13, 1), # snooze until after the start of the event
]
)
def test_snoozed_alarm_has_trigger_at_snooze_time(tzp, snooze):
"""When an alarm is snoozed, it pops up after the snooze time."""
a = Alarms()
a.add_alarm(three_alarms)
a.set_start(datetime(2017, 12, 1, 13))
a.set_local_timezone("UTC")
snooze_utc = tzp.localize_utc(snooze)
a.snooze_until(snooze_utc)
active = a.active
assert len(active) == 3
for alarm in active:
assert alarm.trigger >= snooze_utc


@pytest.mark.parametrize(
("event_index", "alarm_times"),
[
(1, ("20210302T101500",)),
]
)
def test_rfc_9074_alarm_times(events, event_index, alarm_times):
"""Test the examples from the RFC and their timing.
Add times use America/New_York as timezone.
"""
a = Alarms(events[f"rfc_9074_example_{event_index}"])
assert len(a.times) == 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}
assert expected_alarm_times == computed_alarm_times


def test_set_to_None():
"""acknowledge_until, snooze_until, set_local_timezone."""
pytest.skip("TODO")

0 comments on commit c3bd555

Please sign in to comment.