-
Notifications
You must be signed in to change notification settings - Fork 7
Recurring time window filter #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d9eca62
5339273
eabad84
1a79afb
6fcd070
651eb82
6ca4ca4
e1d4a3a
c5e6e84
7f816a9
4759ec8
ab57af1
8233ee5
3686d7a
de6912c
040a1d6
fbd1709
5f0dc78
dc1d006
c55375b
10ccf12
14692e6
856c4c6
c48141f
1d50962
9657883
3b39585
f429fc5
706588a
c76178a
07e9ea4
d156682
d536139
50d270d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
# ------------------------------------------------------------------------ | ||
# Copyright (c) Microsoft Corporation. All rights reserved. | ||
# Licensed under the MIT License. See License.txt in the project root for | ||
# license information. | ||
# ------------------------------------------------------------------------- | ||
from ._recurrence_evaluator import is_match | ||
from ._models import Recurrence, TimeWindowFilterSettings | ||
|
||
__all__ = ["is_match", "Recurrence", "TimeWindowFilterSettings"] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
# ------------------------------------------------------------------------ | ||
# Copyright (c) Microsoft Corporation. All rights reserved. | ||
# Licensed under the MIT License. See License.txt in the project root for | ||
# license information. | ||
# ------------------------------------------------------------------------- | ||
from enum import Enum | ||
from typing import Dict, Any, Optional, List | ||
from datetime import datetime | ||
from dataclasses import dataclass | ||
from email.utils import parsedate_to_datetime | ||
|
||
|
||
class RecurrencePatternType(str, Enum): | ||
""" | ||
The recurrence pattern type. | ||
""" | ||
|
||
DAILY = "Daily" | ||
WEEKLY = "Weekly" | ||
|
||
@staticmethod | ||
def from_str(value: str) -> "RecurrencePatternType": | ||
""" | ||
Get the RecurrencePatternType from the string value. | ||
|
||
:param value: The string value. | ||
:type value: str | ||
:return: The RecurrencePatternType. | ||
:rtype: RecurrencePatternType | ||
""" | ||
if value == "Daily": | ||
return RecurrencePatternType.DAILY | ||
if value == "Weekly": | ||
return RecurrencePatternType.WEEKLY | ||
raise ValueError(f"Invalid value: {value}") | ||
|
||
|
||
class RecurrenceRangeType(str, Enum): | ||
""" | ||
The recurrence range type. | ||
""" | ||
|
||
NO_END = "NoEnd" | ||
END_DATE = "EndDate" | ||
NUMBERED = "Numbered" | ||
|
||
@staticmethod | ||
def from_str(value: str) -> "RecurrenceRangeType": | ||
""" | ||
Get the RecurrenceRangeType from the string value. | ||
|
||
:param value: The string value. | ||
:type value: str | ||
:return: The RecurrenceRangeType. | ||
:rtype: RecurrenceRangeType | ||
""" | ||
if value == "NoEnd": | ||
return RecurrenceRangeType.NO_END | ||
if value == "EndDate": | ||
return RecurrenceRangeType.END_DATE | ||
if value == "Numbered": | ||
return RecurrenceRangeType.NUMBERED | ||
raise ValueError(f"Invalid value: {value}") | ||
|
||
|
||
class RecurrencePattern: # pylint: disable=too-few-public-methods | ||
""" | ||
The recurrence pattern settings. | ||
""" | ||
|
||
days: List[str] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. .net has Sunday as 0. Your comment below also makes it sound like Sunday should be the first one here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this has changed a few times. The Python default is Monday = 0, but I think it is easier now to follow the app config default of Sunday = 0. Updating the comment and one other location. |
||
|
||
def __init__(self, pattern_data: Dict[str, Any]): | ||
self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) | ||
self.interval = pattern_data.get("Interval", 1) | ||
mrm9084 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if self.interval <= 0: | ||
raise ValueError("The interval must be greater than 0.") | ||
# Days of the week are represented as a list of strings of their names. | ||
days_of_week_str = pattern_data.get("DaysOfWeek", []) | ||
|
||
# Days of the week are represented as a list of integers from 0 to 6. | ||
self.days_of_week: List[int] = [] | ||
for day in days_of_week_str: | ||
if day not in self.days: | ||
raise ValueError(f"Invalid value for DaysOfWeek: {day}") | ||
if self.days.index(day) in self.days_of_week: | ||
raise ValueError(f"Duplicate day of the week found: {day}") | ||
self.days_of_week.append(self.days.index(day)) | ||
mrm9084 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if pattern_data.get("FirstDayOfWeek") and pattern_data.get("FirstDayOfWeek") not in self.days: | ||
raise ValueError(f"Invalid value for FirstDayOfWeek: {pattern_data.get('FirstDayOfWeek')}") | ||
self.first_day_of_week = self.days.index(pattern_data.get("FirstDayOfWeek", "Sunday")) | ||
|
||
|
||
class RecurrenceRange: # pylint: disable=too-few-public-methods | ||
""" | ||
The recurrence range settings. | ||
""" | ||
|
||
type: RecurrenceRangeType | ||
end_date: Optional[datetime] = None | ||
|
||
def __init__(self, range_data: Dict[str, Any]): | ||
self.type = RecurrenceRangeType.from_str(range_data.get("Type", "NoEnd")) | ||
if range_data.get("EndDate") and isinstance(range_data.get("EndDate"), str): | ||
end_date_str = range_data.get("EndDate", "") | ||
try: | ||
self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None | ||
except ValueError as e: | ||
raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e | ||
except TypeError as e: | ||
# Python 3.9 and earlier throw TypeError if the string is not in RFC 2822 format. | ||
raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e | ||
self.num_of_occurrences = range_data.get("NumberOfOccurrences", 2**63 - 1) | ||
if self.num_of_occurrences <= 0: | ||
raise ValueError("The number of occurrences must be greater than 0.") | ||
|
||
|
||
class Recurrence: # pylint: disable=too-few-public-methods | ||
""" | ||
The recurrence settings. | ||
""" | ||
|
||
pattern: RecurrencePattern | ||
range: RecurrenceRange | ||
|
||
def __init__(self, recurrence_data: Dict[str, Any]): | ||
self.pattern = RecurrencePattern(recurrence_data.get("Pattern", {})) | ||
self.range = RecurrenceRange(recurrence_data.get("Range", {})) | ||
|
||
|
||
@dataclass | ||
class TimeWindowFilterSettings: | ||
""" | ||
The settings for the time window filter. | ||
""" | ||
|
||
start: Optional[datetime] | ||
end: Optional[datetime] | ||
recurrence: Optional[Recurrence] | ||
|
||
|
||
@dataclass | ||
class OccurrenceInfo: | ||
""" | ||
The information of the previous occurrence. | ||
""" | ||
|
||
previous_occurrence: datetime | ||
num_of_occurrences: int |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
# ------------------------------------------------------------------------ | ||
# Copyright (c) Microsoft Corporation. All rights reserved. | ||
# Licensed under the MIT License. See License.txt in the project root for | ||
# license information. | ||
# ------------------------------------------------------------------------- | ||
from datetime import datetime, timedelta | ||
from typing import Optional | ||
from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo, Recurrence | ||
from ._recurrence_validator import validate_settings, _get_passed_week_days, _sort_days_of_week | ||
|
||
DAYS_PER_WEEK = 7 | ||
REQUIRED_PARAMETER = "Required parameter: %s" | ||
|
||
|
||
def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool: | ||
""" | ||
Check if the current time is within the time window filter settings. | ||
|
||
:param TimeWindowFilterSettings settings: The settings for the time window filter. | ||
:param datetime now: The current time. | ||
:return: True if the current time is within the time window filter settings, otherwise False. | ||
:rtype: bool | ||
""" | ||
recurrence = settings.recurrence | ||
if recurrence is None: | ||
raise ValueError(REQUIRED_PARAMETER % "Recurrence") | ||
|
||
start = settings.start | ||
end = settings.end | ||
if start is None or end is None: | ||
raise ValueError(REQUIRED_PARAMETER % "Start or End") | ||
|
||
validate_settings(recurrence, start, end) | ||
|
||
previous_occurrence = _get_previous_occurrence(recurrence, start, now) | ||
if previous_occurrence is None: | ||
return False | ||
|
||
occurrence_end_date = previous_occurrence + (end - start) | ||
return now < occurrence_end_date | ||
|
||
|
||
def _get_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> Optional[datetime]: | ||
if now < start: | ||
return None | ||
|
||
pattern_type = recurrence.pattern.type | ||
if pattern_type == RecurrencePatternType.DAILY: | ||
occurrence_info = _get_daily_previous_occurrence(recurrence, start, now) | ||
elif pattern_type == RecurrencePatternType.WEEKLY: | ||
occurrence_info = _get_weekly_previous_occurrence(recurrence, start, now) | ||
else: | ||
mrm9084 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
raise ValueError(f"Unsupported recurrence pattern type: {pattern_type}") | ||
|
||
recurrence_range = recurrence.range | ||
range_type = recurrence_range.type | ||
previous_occurrence = occurrence_info.previous_occurrence | ||
end_date = recurrence_range.end_date | ||
zhiyuanliang-ms marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if ( | ||
range_type == RecurrenceRangeType.END_DATE | ||
and previous_occurrence is not None | ||
and end_date is not None | ||
and previous_occurrence > end_date | ||
): | ||
return None | ||
if ( | ||
range_type == RecurrenceRangeType.NUMBERED | ||
and recurrence_range.num_of_occurrences is not None | ||
and occurrence_info.num_of_occurrences > recurrence_range.num_of_occurrences | ||
): | ||
return None | ||
|
||
return occurrence_info.previous_occurrence | ||
|
||
|
||
def _get_daily_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo: | ||
interval = recurrence.pattern.interval | ||
num_of_occurrences = (now - start).days // interval | ||
previous_occurrence = start + timedelta(days=num_of_occurrences * interval) | ||
return OccurrenceInfo(previous_occurrence, num_of_occurrences + 1) | ||
|
||
|
||
def _get_weekly_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo: | ||
pattern = recurrence.pattern | ||
interval = pattern.interval | ||
first_day_of_first_week = start - timedelta(days=_get_passed_week_days(start.weekday(), pattern.first_day_of_week)) | ||
|
||
number_of_interval = (now - first_day_of_first_week).days // (interval * DAYS_PER_WEEK) | ||
first_day_of_most_recent_occurring_week = first_day_of_first_week + timedelta( | ||
days=number_of_interval * (interval * DAYS_PER_WEEK) | ||
) | ||
sorted_days_of_week = _sort_days_of_week(pattern.days_of_week, pattern.first_day_of_week) | ||
max_day_offset = _get_passed_week_days(sorted_days_of_week[-1], pattern.first_day_of_week) | ||
min_day_offset = _get_passed_week_days(sorted_days_of_week[0], pattern.first_day_of_week) | ||
num_of_occurrences = number_of_interval * len(sorted_days_of_week) - sorted_days_of_week.index(start.weekday()) | ||
mrm9084 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if now > first_day_of_most_recent_occurring_week + timedelta(days=DAYS_PER_WEEK): | ||
num_of_occurrences += len(sorted_days_of_week) | ||
most_recent_occurrence = first_day_of_most_recent_occurring_week + timedelta(days=max_day_offset) | ||
return OccurrenceInfo(most_recent_occurrence, num_of_occurrences) | ||
|
||
day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(days=min_day_offset) | ||
if start > day_with_min_offset: | ||
num_of_occurrences = 0 | ||
day_with_min_offset = start | ||
if now < day_with_min_offset: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't guard that |
||
most_recent_occurrence = ( | ||
first_day_of_most_recent_occurring_week | ||
- timedelta(days=interval * DAYS_PER_WEEK) | ||
+ timedelta(days=max_day_offset) | ||
) | ||
else: | ||
most_recent_occurrence = day_with_min_offset | ||
num_of_occurrences += 1 | ||
|
||
for day in sorted_days_of_week[sorted_days_of_week.index(day_with_min_offset.weekday()) + 1 :]: | ||
day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta( | ||
days=_get_passed_week_days(day, pattern.first_day_of_week) | ||
) | ||
if now < day_with_min_offset: | ||
break | ||
most_recent_occurrence = day_with_min_offset | ||
num_of_occurrences += 1 | ||
|
||
return OccurrenceInfo(most_recent_occurrence, num_of_occurrences) |
Uh oh!
There was an error while loading. Please reload this page.