Skip to content

Commit d9eca62

Browse files
committed
added recurring time window filter
1 parent 2982253 commit d9eca62

File tree

6 files changed

+414
-5
lines changed

6 files changed

+414
-5
lines changed

featuremanagement/_defaultfilters.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from email.utils import parsedate_to_datetime
1010
from typing import cast, List, Mapping, Optional, Dict, Any
1111
from ._featurefilters import FeatureFilter
12+
from ._time_window_filter import Recurrence, is_match, TimeWindowFilterSettings
1213

1314
FEATURE_FLAG_NAME_KEY = "feature_name"
1415
ROLLOUT_PERCENTAGE_KEY = "RolloutPercentage"
@@ -18,6 +19,15 @@
1819
# Time Window Constants
1920
START_KEY = "Start"
2021
END_KEY = "End"
22+
TIME_WINDOW_FILTER_SETTING_RECURRENCE = "Recurrence"
23+
24+
# Time Window Exceptions
25+
TIME_WINDOW_FILTER_INVALID = (
26+
"%s: The %s feature filter is not valid for feature %s. It must specify either %s, $s, or both."
27+
)
28+
TIME_WINDOW_FILTER_INVALID_RECURRENCE = (
29+
"%s: The %s feature filter is not valid for feature %s. It must specify both %s and $s when Recurrence is not None."
30+
)
2131

2232
# Targeting kwargs
2333
TARGETED_USER_KEY = "user"
@@ -31,6 +41,8 @@
3141
FEATURE_FILTER_NAME_KEY = "Name"
3242
IGNORE_CASE_KEY = "ignore_case"
3343

44+
logger = logging.getLogger(__name__)
45+
3446

3547
class TargetingException(Exception):
3648
"""
@@ -52,17 +64,45 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool:
5264
:return: True if the current time is within the time window.
5365
:rtype: bool
5466
"""
55-
start = context.get(PARAMETERS_KEY, {}).get(START_KEY)
56-
end = context.get(PARAMETERS_KEY, {}).get(END_KEY)
67+
start = context.get(PARAMETERS_KEY, {}).get(START_KEY, None)
68+
end = context.get(PARAMETERS_KEY, {}).get(END_KEY, None)
69+
recurrence_data = context.get(PARAMETERS_KEY, {}).get(TIME_WINDOW_FILTER_SETTING_RECURRENCE, None)
70+
recurrence = None
5771

5872
current_time = datetime.now(timezone.utc)
5973

74+
if recurrence_data:
75+
recurrence = Recurrence(**cast(Dict[str, Any], recurrence_data))
76+
6077
if not start and not end:
61-
logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__)
78+
logger.warning(
79+
TIME_WINDOW_FILTER_INVALID,
80+
TimeWindowFilter.__name__,
81+
context.get(FEATURE_FLAG_NAME_KEY),
82+
START_KEY,
83+
END_KEY,
84+
)
6285
return False
6386

64-
start_time = parsedate_to_datetime(start) if start else None
65-
end_time = parsedate_to_datetime(end) if end else None
87+
start_time: Optional[datetime] = parsedate_to_datetime(start) if start else None
88+
end_time: Optional[datetime] = parsedate_to_datetime(end) if end else None
89+
90+
if recurrence:
91+
if start_time and end_time:
92+
settings = TimeWindowFilterSettings(start_time, end_time, recurrence)
93+
return is_match(settings, current_time)
94+
logger.warning(
95+
TIME_WINDOW_FILTER_INVALID_RECURRENCE,
96+
TimeWindowFilter.__name__,
97+
context.get(FEATURE_FLAG_NAME_KEY),
98+
START_KEY,
99+
END_KEY,
100+
)
101+
return False
102+
103+
if not start and not end:
104+
logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__)
105+
return False
66106

67107
return (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time)
68108

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from ._recurrence_evaluator import is_match
7+
from ._models import Recurrence, TimeWindowFilterSettings
8+
9+
__all__ = ["is_match", "Recurrence", "TimeWindowFilterSettings"]
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from enum import Enum
7+
from datetime import datetime
8+
from typing import List
9+
from dataclasses import dataclass
10+
11+
12+
class RecurrencePatternType(str, Enum):
13+
"""
14+
The recurrence pattern type.
15+
"""
16+
17+
DAILY = "Daily"
18+
WEEKLY = "Weekly"
19+
20+
21+
class RecurrenceRangeType(str, Enum):
22+
"""
23+
The recurrence range type.
24+
"""
25+
26+
NO_END = "NoEnd"
27+
END_DATE = "EndDate"
28+
NUMBERED = "Numbered"
29+
30+
31+
@dataclass
32+
class RecurrencePattern:
33+
"""
34+
The recurrence pattern settings.
35+
"""
36+
37+
days_of_week: List[int]
38+
interval: int = 1
39+
first_day_of_week: int = 7
40+
type: RecurrencePatternType = RecurrencePatternType.DAILY
41+
42+
43+
@dataclass
44+
class RecurrenceRange:
45+
"""
46+
The recurrence range settings.
47+
"""
48+
49+
end_date: datetime
50+
num_of_occurrences: int
51+
type: RecurrenceRangeType = RecurrenceRangeType.NO_END
52+
53+
54+
@dataclass
55+
class Recurrence:
56+
"""
57+
The recurrence settings.
58+
"""
59+
60+
pattern: RecurrencePattern
61+
range: RecurrenceRange
62+
63+
64+
@dataclass
65+
class TimeWindowFilterSettings:
66+
"""
67+
The settings for the time window filter.
68+
"""
69+
70+
start: datetime
71+
end: datetime
72+
recurrence: Recurrence
73+
74+
75+
@dataclass
76+
class OccurrenceInfo:
77+
"""
78+
The information of the previous occurrence.
79+
"""
80+
81+
previous_occurrence: datetime
82+
num_of_occurrences: int
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from datetime import datetime, timedelta
7+
from typing import List, Optional
8+
from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo
9+
from ._recurrence_validator import validate_settings
10+
11+
12+
def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool:
13+
"""
14+
Check if the current time is within the time window filter settings.
15+
16+
:param TimeWindowFilterSettings settings: The settings for the time window filter.
17+
:param datetime now: The current time.
18+
:return: True if the current time is within the time window filter settings, otherwise False.
19+
:rtype: bool
20+
"""
21+
validate_settings(settings)
22+
23+
previous_occurrence = _get_previous_occurrence(settings, now)
24+
if previous_occurrence is None:
25+
return False
26+
27+
occurrence_end_date = previous_occurrence + (settings.end - settings.start)
28+
return now < occurrence_end_date
29+
30+
31+
def _get_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> Optional[datetime]:
32+
start = settings.start
33+
if now < start:
34+
return None
35+
36+
pattern_type = settings.recurrence.pattern.type
37+
if pattern_type == RecurrencePatternType.DAILY:
38+
occurrence_info = _get_daily_previous_occurrence(settings, now)
39+
else:
40+
occurrence_info = _get_weekly_previous_occurrence(settings, now)
41+
42+
recurrence_range = settings.recurrence.range
43+
range_type = recurrence_range.type
44+
if (
45+
range_type == RecurrenceRangeType.END_DATE
46+
and occurrence_info.previous_occurrence
47+
and occurrence_info.previous_occurrence > recurrence_range.end_date
48+
):
49+
return None
50+
if (
51+
range_type == RecurrenceRangeType.NUMBERED
52+
and occurrence_info.num_of_occurrences > recurrence_range.num_of_occurrences
53+
):
54+
return None
55+
56+
return occurrence_info.previous_occurrence
57+
58+
59+
def _get_daily_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> OccurrenceInfo:
60+
start = settings.start
61+
interval = settings.recurrence.pattern.interval
62+
num_of_occurrences = (now - start).days // interval
63+
previous_occurrence = start + timedelta(days=num_of_occurrences * interval)
64+
return OccurrenceInfo(previous_occurrence, num_of_occurrences + 1)
65+
66+
67+
def _get_weekly_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> OccurrenceInfo:
68+
pattern = settings.recurrence.pattern
69+
interval = pattern.interval
70+
start = settings.start
71+
first_day_of_first_week = start - timedelta(days=_get_passed_week_days(start.weekday(), pattern.first_day_of_week))
72+
73+
number_of_interval = (now - first_day_of_first_week).days // (interval * 7)
74+
first_day_of_most_recent_occurring_week = first_day_of_first_week + timedelta(
75+
days=number_of_interval * (interval * 7)
76+
)
77+
sorted_days_of_week = _sort_days_of_week(pattern.days_of_week, pattern.first_day_of_week)
78+
max_day_offset = _get_passed_week_days(sorted_days_of_week[-1], pattern.first_day_of_week)
79+
min_day_offset = _get_passed_week_days(sorted_days_of_week[0], pattern.first_day_of_week)
80+
num_of_occurrences = number_of_interval * len(sorted_days_of_week) - sorted_days_of_week.index(start.weekday())
81+
82+
if now > first_day_of_most_recent_occurring_week + timedelta(days=7):
83+
num_of_occurrences += len(sorted_days_of_week)
84+
most_recent_occurrence = first_day_of_most_recent_occurring_week + timedelta(days=max_day_offset)
85+
return OccurrenceInfo(most_recent_occurrence, num_of_occurrences)
86+
87+
day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(days=min_day_offset)
88+
if start > day_with_min_offset:
89+
num_of_occurrences = 0
90+
day_with_min_offset = start
91+
if now < day_with_min_offset:
92+
most_recent_occurrence = (
93+
first_day_of_most_recent_occurring_week - timedelta(days=interval * 7) + timedelta(days=max_day_offset)
94+
)
95+
else:
96+
most_recent_occurrence = day_with_min_offset
97+
num_of_occurrences += 1
98+
99+
for day in sorted_days_of_week[sorted_days_of_week.index(day_with_min_offset.weekday()) + 1 :]:
100+
day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(
101+
days=_get_passed_week_days(day, pattern.first_day_of_week)
102+
)
103+
if now < day_with_min_offset:
104+
break
105+
most_recent_occurrence = day_with_min_offset
106+
num_of_occurrences += 1
107+
108+
return OccurrenceInfo(most_recent_occurrence, num_of_occurrences)
109+
110+
111+
def _get_passed_week_days(current_day: int, first_day_of_week: int) -> int:
112+
return (current_day - first_day_of_week + 7) % 7
113+
114+
115+
def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]:
116+
sorted_days = sorted(days_of_week)
117+
return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)]

0 commit comments

Comments
 (0)