diff --git a/main.py b/main.py index 36c35a1f5b2fd068ff1d4c6d8405c43d74d93f4f..3fbe62784c2e5be6e6478c52144e77bb3868a844 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,11 @@ import argparse import os +from datetime import date + from notify.config import load_config, build_channels +from notify.timeline.basic import BasicTimelineIterator, FixedDay +from notify.messages.generator import build_notification from notify.calendar.calendar import Calendar -from notify.messages.generator import generate_notification def parse_args(): @@ -19,27 +22,40 @@ def is_real_run(args): or os.environ.get("DO_REAL_RUN", "false") == "true" +def get_date(date_str: str) -> date: + if date_str == "today": + return date.today() + else: + return date.fromisoformat(date_str) + + def main(): args = parse_args() config = load_config(args.config) channels = list(build_channels(config)) - calendar = Calendar(config) - info = calendar.get_daily_standup_info(args.day) - - if not info: - print("No standup today") + timeline = BasicTimelineIterator( + workday_calendar=Calendar(config["calendar"]["rules"]), + fixed_day=FixedDay( + day=config["calendar"]["fixed_point"]["date"], + moderator=config["calendar"]["fixed_point"]["moderator"], + days_left=config["calendar"]["fixed_point"]["days_left"] + ), + moderator_days=config["calendar"]["moderator_days"], + moderators=config["moderators"] + ) + notification = build_notification(timeline, config, get_date(args.day)) + if not notification: + print("No notification to send") else: - message = generate_notification(config, info) - if is_real_run(args): print("Sending messages on channels...") for channel in channels: - channel.send_notification(message) + channel.send_notification(notification) else: print("--- This is a dry-run, messages are not sent ---") - print(message.message) + print(notification.message) if __name__ == "__main__": diff --git a/notify/calendar/__tests__/test_calendar.py b/notify/calendar/__tests__/test_calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..20d803c3bf51052305fa986fde0f44b0fde47d3c --- /dev/null +++ b/notify/calendar/__tests__/test_calendar.py @@ -0,0 +1,42 @@ +from pytest import fixture +from datetime import date + +from notify.calendar.calendar import Calendar + + +@fixture +def calendar(): + return Calendar([ + "exclude_all", + "include_weekdays", + { + "name": "exclude_holidays", + "options": { + "country": "HU" + } + }, + { + "name": "exclude_dow", + "options": { + "day": 2 + } + } + ]) + + +def test_weekday_detection(calendar: Calendar): + assert calendar.match_day(date(2022, 7, 22)) is True + assert calendar.match_day(date(2022, 7, 23)) is False + assert calendar.match_day(date(2022, 7, 25)) is True + + +def test_holiday_detection(calendar: Calendar): + # 12.26 is a monday but it is Christmas so it should not be a workday + assert calendar.match_day(date(2022, 12, 26)) is False + assert calendar.match_day(date(2022, 12, 27)) is True + + +def test_midweek(calendar: Calendar): + assert calendar.match_day(date(2022, 9, 13)) is True + assert calendar.match_day(date(2022, 9, 14)) is False + assert calendar.match_day(date(2022, 9, 15)) is True diff --git a/notify/calendar/__tests__/test_usual_calendar.py b/notify/calendar/__tests__/test_usual_calendar.py deleted file mode 100644 index ed55ab7b4c07977170fedf1880c494185f273785..0000000000000000000000000000000000000000 --- a/notify/calendar/__tests__/test_usual_calendar.py +++ /dev/null @@ -1,41 +0,0 @@ -from pytest import fixture -from datetime import datetime - -from notify.calendar.calendar import Calendar - - -@fixture -def calendar(): - return Calendar({ - "moderators": ["a", "b", "c"], - "calendar": { - "rules": [ - "exclude_all", - "include_weekdays", - { - "name": "exclude_holidays", - "options": { - "country": "HU" - } - } - ], - "moderator_days": 3, - "fixed_point": { - "date": datetime(2022, 7, 20), - "moderator": "b", - "days_left": 2 - } - } - }) - - -def test_weekday_detection(calendar: Calendar): - assert calendar._is_workday(datetime(2022, 7, 22)) is True - assert calendar._is_workday(datetime(2022, 7, 23)) is False - assert calendar._is_workday(datetime(2022, 7, 25)) is True - - -def test_holiday_detection(calendar: Calendar): - # 12.26 is a monday but it is Christmas so it should not be a workday - assert calendar._is_workday(datetime(2022, 12, 26)) is False - assert calendar._is_workday(datetime(2022, 12, 27)) is True diff --git a/notify/calendar/calendar.py b/notify/calendar/calendar.py index 830d2403eda343e113c4cf973d086b47bd7e6246..b352978326d01c06b79414c358d7c7d6a05bd872 100644 --- a/notify/calendar/calendar.py +++ b/notify/calendar/calendar.py @@ -1,41 +1,14 @@ -from dataclasses import dataclass -from typing import Optional -from datetime import date, timedelta +from datetime import date from .rules.rule import CalendarRule from .rules.loader import load_rules -accepted_weekdays = [0, 1, 2, 3, 4] - - -@dataclass(frozen=True) -class CalendarDay: - day: date - moderator: str - - -@dataclass(frozen=True) -class DailyStandupInfo: - moderator_name: str - days_left: int - next_moderator_name: str - class Calendar: - def __init__(self, config): - self._moderators = config["moderators"] - self._moderator_days = config["calendar"]["moderator_days"] + def __init__(self, rules_config: list): + self._rules: list[CalendarRule] = load_rules(rules_config) - self._fixed_date: date = config["calendar"]["fixed_point"]["date"] - self._fixed_moderator: str = config["calendar"]["fixed_point"]["moderator"] - self._fixed_moderator_days: str = config["calendar"]["fixed_point"]["days_left"] - - if self._fixed_moderator not in self._moderators: - raise ValueError("Fixed moderator is not in the list of moderators") - - self._rules: list[CalendarRule] = load_rules(config["calendar"]["rules"]) - - def _is_workday(self, qdate: date) -> bool: + def match_day(self, qdate: date) -> bool: included = False for rule in self._rules: @@ -44,65 +17,3 @@ class Calendar: included = evaled.included return included - - def _get_moderator_offset(self): - return \ - self._moderators.index(self._fixed_moderator) * self._moderator_days + \ - self._moderator_days - self._fixed_moderator_days - 1 - - def _count_days(self, from_date: date, target_date: date) -> int: - from_date = from_date - iterator = from_date - date_counter = 0 - - while iterator < target_date: - iterator += timedelta(days=1) - if self._is_workday(iterator): - date_counter += 1 - - return date_counter - - def _build_calendar(self, from_day: date) -> list[CalendarDay]: - days: list[CalendarDay] = [] - last_cached_day = self._fixed_date - last_cached_day_count = 0 - moderator_offset = self._get_moderator_offset() - - check_day = from_day - while len(days) < (self._moderator_days + 2): - if self._is_workday(check_day): - count = self._count_days(last_cached_day, check_day) + last_cached_day_count - last_cached_day = check_day - last_cached_day_count = count - - moderator_id = (count + moderator_offset) // self._moderator_days - days.append(CalendarDay( - day=check_day, - moderator=self._moderators[moderator_id % len(self._moderators)] - )) - check_day += timedelta(days=1) - - return days - - def get_daily_standup_info(self, day: str) -> Optional[DailyStandupInfo]: - if day == "today": - today = date.today() - else: - today = date.fromisoformat(day) - - if not self._is_workday(today): - return None - days = self._build_calendar(today) - - current_moderator = days[0].moderator - days_left = 0 - i = 0 - while current_moderator == days[i+1].moderator: - days_left += 1 - i += 1 - - return DailyStandupInfo( - moderator_name=current_moderator, - days_left=days_left, - next_moderator_name=days[i+1].moderator - ) diff --git a/notify/calendar/rules/__tests__/test_exclude_dow.py b/notify/calendar/rules/__tests__/test_exclude_dow.py new file mode 100644 index 0000000000000000000000000000000000000000..fcc05d5811ef3a18b02f20a09295163277ddbae1 --- /dev/null +++ b/notify/calendar/rules/__tests__/test_exclude_dow.py @@ -0,0 +1,14 @@ +from pytest import fixture +from datetime import date +from notify.calendar.rules.exclude_dow import ExcludeDayOfWeek, RuleResult + + +@fixture +def rule(): + return ExcludeDayOfWeek(2) + + +def test_days(rule: ExcludeDayOfWeek): + assert rule.apply(date(2022, 9, 13)).applicable is False + assert rule.apply(date(2022, 9, 14)) == RuleResult(applicable=True, included=False) + assert rule.apply(date(2022, 9, 15)).applicable is False diff --git a/notify/calendar/rules/exclude_dow.py b/notify/calendar/rules/exclude_dow.py new file mode 100644 index 0000000000000000000000000000000000000000..ecb576c6495dd005d6926050ba05d372f162ebcf --- /dev/null +++ b/notify/calendar/rules/exclude_dow.py @@ -0,0 +1,15 @@ +from datetime import date +from .rule import CalendarRule, RuleResult + + +class ExcludeDayOfWeek(CalendarRule): + def __init__(self, day: int): + super().__init__() + + self._exclude_day = day + + def apply(self, day: date) -> RuleResult: + if day.weekday() == self._exclude_day: + return RuleResult(applicable=True, included=False) + else: + return RuleResult(applicable=False, included=False) diff --git a/notify/calendar/rules/loader.py b/notify/calendar/rules/loader.py index 4cbe919cfc6937603f3fd05150f48fe2aaa5a591..7014caeeae5573274ea378717f52ae4d635cab17 100644 --- a/notify/calendar/rules/loader.py +++ b/notify/calendar/rules/loader.py @@ -29,5 +29,8 @@ def load_rule(rule_config: dict) -> CalendarRule: elif rule_config["name"] == "include_days": from .include_days import IncludeDays return IncludeDays(**options) + elif rule_config["name"] == "exclude_dow": + from .exclude_dow import ExcludeDayOfWeek + return ExcludeDayOfWeek(**options) else: raise ValueError("Unknown rule: " + rule_config["name"]) diff --git a/notify/channels/vonage_sms.py b/notify/channels/vonage_sms.py index 834d32f9b6bf2cbfb44b7ceb6fedafc815443b5a..b970e3a3cb4bdbf0d3f64174d821a5f95ccf7736 100644 --- a/notify/channels/vonage_sms.py +++ b/notify/channels/vonage_sms.py @@ -18,7 +18,8 @@ class VonageSmsChannel(NotificationChannel): self._client.send_message({ 'from': self._sender, 'to': number, - 'text': message.message + 'text': message.short_message + if message.short_message else message.message }) except Exception as e: print(f"Failed to send SMS: {e}") diff --git a/notify/messages/__tests__/test_generator.py b/notify/messages/__tests__/test_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..3f20db2ea112bee0740ecd78a5f63677511132da --- /dev/null +++ b/notify/messages/__tests__/test_generator.py @@ -0,0 +1,74 @@ +from pytest import fixture +from datetime import date + +from notify.timeline.iterator import TimelineIterator +from notify.timeline.basic import BasicTimelineIterator, FixedDay +from notify.calendar.calendar import Calendar +from notify.messages.generator import build_notification + + +@fixture +def basic_timeline_iterator(): + return BasicTimelineIterator( + workday_calendar=Calendar([ + "exclude_all", + "include_weekdays", + { + "name": "exclude_holidays", + "options": { + "country": "HU" + } + }, + ]), + fixed_day=FixedDay( + day=date(2022, 9, 21), + moderator="John", + days_left=2 + ), + moderator_days=3, + moderators=["John", "Jane", "Jack"] + ) + + +@fixture +def template_config(): + return { + "message": { + "title": "Daily standup", + "normal_template": "daily.j2", + "short_template": "daily-short.j2" + } + } + + +def test_weekend(basic_timeline_iterator: TimelineIterator, template_config): + msg = build_notification(basic_timeline_iterator, template_config, date(2022, 9, 25)) + assert msg is None + + +def test_message_generation(basic_timeline_iterator: TimelineIterator, template_config): + msg = build_notification(basic_timeline_iterator, template_config, date(2022, 9, 21)) + assert msg.title == "Daily standup" + assert msg.message == """Dear Team Tirith! + +Today John will hold the daily meeting! + +The upcoming schedule for the next 10 workdays is as follows: + 2022-09-22 (Thursday): John + 2022-09-23 (Friday): John + 2022-09-26 (Monday): Jane + 2022-09-27 (Tuesday): Jane + 2022-09-28 (Wednesday): Jane + 2022-09-29 (Thursday): Jack + 2022-09-30 (Friday): Jack + 2022-10-03 (Monday): Jack + 2022-10-04 (Tuesday): John + 2022-10-05 (Wednesday): John + +Have a nice day!""" + + assert msg.short_message == """Dear Team Tirith! + +Today John will hold the daily meeting! + +Have a nice day!""" diff --git a/notify/messages/generator.py b/notify/messages/generator.py index 72da79ed2a14bf966c40c4c4af6af2c2c429943b..eae6588c4cf809a2fab5b3eded4722bd1b3752d3 100644 --- a/notify/messages/generator.py +++ b/notify/messages/generator.py @@ -1,10 +1,48 @@ -from ..calendar.calendar import DailyStandupInfo +import jinja2 +from datetime import date + +from ..timeline.iterator import TimelineIterator, CalendarDay from .notification import Notification -def generate_notification(config, info: DailyStandupInfo) -> Notification: - template = config["message"]["message_template"] +def render(tempalte_file, data) -> str: + templateLoader = jinja2.FileSystemLoader(searchpath="./templates") + templateEnv = jinja2.Environment(loader=templateLoader) + template = templateEnv.get_template(tempalte_file) + return template.render(data) + + +def day_to_data(day: CalendarDay) -> dict: + return { + **day.__dict__, + "dow": day.day.strftime('%A') + } + + +def build_notification(iter: TimelineIterator, config, starting_day: date) -> Notification: + iter.set_day(starting_day) + + days: list[CalendarDay] = [] + for day in iter: + days.append(day) + if len(days) >= 11: + break + + if len(days) == 0 or days[0].day != starting_day: + return None + + template_data = { + "today": day_to_data(days[0]) if len(days) >= 1 else None, + "days": [day_to_data(day) for day in days[1:]] + } + + title = config["message"]["title"] + normal_msg_template_file = config["message"]["normal_template"] + short_msg_template_file = config["message"]["short_template"] + return Notification( - title=config["message"]["title"], - message=template.format(**info.__dict__) + title=title, + message=render(normal_msg_template_file, template_data), + short_message=render(short_msg_template_file, template_data) + if short_msg_template_file else None ) diff --git a/notify/messages/notification.py b/notify/messages/notification.py index 3ece9f8927f06371c128be737837e62b70f8d495..802578b8c2ced37ad5b5b02608775b42af09c0b0 100644 --- a/notify/messages/notification.py +++ b/notify/messages/notification.py @@ -1,7 +1,9 @@ from dataclasses import dataclass +from typing import Optional @dataclass(frozen=True) class Notification: title: str message: str + short_message: Optional[str] diff --git a/notify/timeline/__tests__/test_basic.py b/notify/timeline/__tests__/test_basic.py new file mode 100644 index 0000000000000000000000000000000000000000..6299dc6aafc54f11d87d348b254fdefbad6b79af --- /dev/null +++ b/notify/timeline/__tests__/test_basic.py @@ -0,0 +1,72 @@ +from pytest import fixture +from datetime import date + +from notify.timeline.basic import BasicTimelineIterator, FixedDay, CalendarDay +from notify.calendar.calendar import Calendar + + +@fixture +def basic_timeline(): + return BasicTimelineIterator( + workday_calendar=Calendar([ + "exclude_all", + "include_weekdays", + { + "name": "exclude_holidays", + "options": { + "country": "HU" + } + }, + ]), + fixed_day=FixedDay( + day=date(2022, 9, 21), + moderator="a", + days_left=2 + ), + moderator_days=3, + moderators=["a", "b", "c"] + ) + + +def test_fixed_day(basic_timeline: BasicTimelineIterator): + basic_timeline.set_day(date(2022, 9, 21)) + assert basic_timeline.__next__() == CalendarDay(day=date(2022, 9, 21), moderator="a") + + +def test_iteration(basic_timeline: BasicTimelineIterator): + expected_items = [ + CalendarDay(day=date(2022, 9, 21), moderator="a"), + CalendarDay(day=date(2022, 9, 22), moderator="a"), + CalendarDay(day=date(2022, 9, 23), moderator="a"), + CalendarDay(day=date(2022, 9, 26), moderator="b"), + CalendarDay(day=date(2022, 9, 27), moderator="b"), + CalendarDay(day=date(2022, 9, 28), moderator="b"), + CalendarDay(day=date(2022, 9, 29), moderator="c"), + ] + + basic_timeline.set_day(date(2022, 9, 21)) + + for day in basic_timeline: + expected = expected_items.pop(0) + assert day == expected + + if len(expected_items) == 0: + break + + +def test_far_in_future(basic_timeline: BasicTimelineIterator): + expected_items = [ + CalendarDay(day=date(2046, 9, 21), moderator="c"), + CalendarDay(day=date(2046, 9, 24), moderator="c"), + CalendarDay(day=date(2046, 9, 25), moderator="c"), + CalendarDay(day=date(2046, 9, 26), moderator="a"), + ] + + basic_timeline.set_day(date(2046, 9, 21)) + + for day in basic_timeline: + expected = expected_items.pop(0) + assert day == expected + + if len(expected_items) == 0: + break diff --git a/notify/timeline/basic.py b/notify/timeline/basic.py new file mode 100644 index 0000000000000000000000000000000000000000..164b5c650334aa59d3d1c908fc29e8539076186f --- /dev/null +++ b/notify/timeline/basic.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +from datetime import date, timedelta + +from .iterator import TimelineIterator, CalendarDay +from ..calendar.calendar import Calendar + + +@dataclass +class FixedDay(): + day: date + moderator: str + days_left: int + + +class BasicTimelineIterator(TimelineIterator): + def __init__(self, + workday_calendar: Calendar, fixed_day: FixedDay, + moderators: list[str], moderator_days: int) -> None: + self._workday_calendar = workday_calendar + self._fixed_day = fixed_day + self._moderators = moderators + self._moderator_days = moderator_days + + self._offset_days = self._get_initial_offset() + + def _get_initial_offset(self): + return \ + self._moderators.index(self._fixed_day.moderator) * self._moderator_days + \ + self._moderator_days - self._fixed_day.days_left - 1 + + def _count_days(self, from_date: date, target_date: date) -> int: + from_date = from_date + iterator = from_date + date_counter = 0 + + while iterator < target_date: + iterator += timedelta(days=1) + if self._workday_calendar.match_day(iterator): + date_counter += 1 + + return date_counter + + def set_day(self, day: date) -> None: + self._check_day = day - timedelta(days=1) + self._quit_day = day + timedelta(days=365) + + def __next__(self) -> CalendarDay: + while True: + self._check_day += timedelta(days=1) + + if self._check_day > self._quit_day: + raise StopIteration + + if self._workday_calendar.match_day(self._check_day): + count = self._count_days(self._fixed_day.day, self._check_day) + moderator_id = (count + self._offset_days) // self._moderator_days + + return CalendarDay( + day=self._check_day, + moderator=self._moderators[moderator_id % len(self._moderators)] + ) diff --git a/notify/timeline/iterator.py b/notify/timeline/iterator.py new file mode 100644 index 0000000000000000000000000000000000000000..5cdb9f009c308afedf90b04e0c321f25a09fe1b6 --- /dev/null +++ b/notify/timeline/iterator.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from datetime import date + + +@dataclass(frozen=True) +class CalendarDay: + day: date + moderator: str + + +class TimelineIterator(): + def __iter__(self) -> 'TimelineIterator': + return self + + def set_day(self, day: date) -> None: + raise NotImplementedError + + def __next__(self) -> 'CalendarDay': + raise NotImplementedError diff --git a/requirements.txt b/requirements.txt index f10e50914f0a07d8018b10ce9d4dd28d0731968a..eeed271b15cddb5a6ea8a6f61e22f5cd3dddef7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ nexmo redmail holidays deepmerge +jinja2 # test pytest diff --git a/templates/daily-short.j2 b/templates/daily-short.j2 new file mode 100644 index 0000000000000000000000000000000000000000..4ea61d68b1f700289ac9fdc4bbc6000105e0262d --- /dev/null +++ b/templates/daily-short.j2 @@ -0,0 +1,5 @@ +Dear Team Tirith! + +Today {{ today.moderator }} will hold the daily meeting! + +Have a nice day! diff --git a/templates/daily.j2 b/templates/daily.j2 new file mode 100644 index 0000000000000000000000000000000000000000..e47e0ca23f1786089c9ff611ad81ffc97c784179 --- /dev/null +++ b/templates/daily.j2 @@ -0,0 +1,10 @@ +Dear Team Tirith! + +Today {{ today.moderator }} will hold the daily meeting! + +The upcoming schedule for the next {{ days | length }} workdays is as follows: +{%- for item in days %} + {{ item.day }} ({{ item.dow }}): {{ item.moderator }} +{%- endfor %} + +Have a nice day!