diff --git a/app/integrations/google_workspace/google_calendar.py b/app/integrations/google_workspace/google_calendar.py index 550b0a5c..a9c4f721 100644 --- a/app/integrations/google_workspace/google_calendar.py +++ b/app/integrations/google_workspace/google_calendar.py @@ -1,6 +1,6 @@ import os from datetime import datetime, timedelta - +import requests import pytz from integrations.google_workspace.google_service import ( @@ -14,6 +14,7 @@ SRE_BOT_EMAIL = os.environ.get("SRE_BOT_EMAIL") + @handle_google_api_errors def get_freebusy(time_min, time_max, items, **kwargs): """Returns free/busy information for a set of calendars. @@ -125,6 +126,9 @@ def find_first_available_slot( busy_times.append((start, end)) busy_times.sort(key=lambda x: x[0]) + # get the list of Canandian federal holidays + federal_holidays = get_federal_holidays() + for day_offset in range(days_in_future, days_in_future + search_days_limit): # Calculate the start and end times of the search window for the current day search_date = datetime.utcnow() + timedelta(days=day_offset) @@ -140,6 +144,10 @@ def find_first_available_slot( hour=19, minute=0, second=0, microsecond=0 ) # 3 PM EST, times are in UTC + # if the day is a federal holiday, skip it + if search_date.date().strftime("%Y-%m-%d") in federal_holidays: + continue + # Attempt to find an available slot within this day's search window for current_time in ( search_start + timedelta(minutes=i) for i in range(0, 121, duration_minutes) @@ -153,3 +161,20 @@ def find_first_available_slot( return current_time.astimezone(est), slot_end.astimezone(est) return None, None # No available slot found after searching the limit + +def get_federal_holidays(): + # Get the public holidays for the current year + # Uses Paul Craig's Public holidays api to retrieve the federal holidays + + # get today's year + year = datetime.now().year + + # call the api to get the public holidays + url = f"https://canada-holidays.ca/api/v1/holidays?federal=true&year={year}" + response = requests.get(url) + + # Store the observed dates of the holidays and return the list + holidays = [] + for holiday in response.json()["holidays"]: + holidays.append(holiday["observedDate"]) + return holidays \ No newline at end of file diff --git a/app/requirements_dev.txt b/app/requirements_dev.txt index 3c10efba..4a1d108d 100644 --- a/app/requirements_dev.txt +++ b/app/requirements_dev.txt @@ -5,3 +5,4 @@ freezegun==1.4.0 pytest==7.4.4 pytest-asyncio==0.23.6 pytest-env==0.8.2 +requests-mock==1.12.1 diff --git a/app/tests/integrations/google_workspace/test_google_calendar.py b/app/tests/integrations/google_workspace/test_google_calendar.py index e39414f0..6cae9ce4 100644 --- a/app/tests/integrations/google_workspace/test_google_calendar.py +++ b/app/tests/integrations/google_workspace/test_google_calendar.py @@ -44,7 +44,6 @@ def fixed_utc_now(): # Return a fixed UTC datetime return datetime(2023, 4, 10, 12, 0) # This is a Monday - # Fixture to mock the datetime.now() function @pytest.fixture def mock_datetime_now(est_timezone): @@ -69,6 +68,10 @@ def mock_datetime_now(est_timezone): def items(): return [{"id": "calendar1"}, {"id": "calendar2"}] +# Fixture to mock the year +@pytest.fixture +def mock_year(): + return 2024 @patch( "integrations.google_workspace.google_calendar.DEFAULT_DELEGATED_ADMIN_EMAIL", @@ -322,11 +325,12 @@ def test_insert_event_api_call_error( assert mock_os_environ_get.called assert not mock_handle_errors.called - +@patch("integrations.google_workspace.google_calendar.get_federal_holidays") @patch("integrations.google_workspace.google_calendar.datetime") -def test_available_slot_on_first_weekday(mock_datetime, fixed_utc_now, est_timezone): +def test_available_slot_on_first_weekday(mock_datetime, mock_federal_holidays ,fixed_utc_now, mock_year, est_timezone): # Mock datetime to control the flow of time in the test mock_datetime.utcnow.return_value = fixed_utc_now + mock_datetime.return_value.year = 2024 mock_datetime.fromisoformat.side_effect = lambda d: datetime.fromisoformat(d[:-1]) mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) @@ -343,7 +347,12 @@ def test_available_slot_on_first_weekday(mock_datetime, fixed_utc_now, est_timez } } } - + mock_federal_holidays.return_value = {"holidays": [ + {"observedDate": "2024-01-01"}, + {"observedDate": "2024-07-01"} + ] + } + # Expected search date is three days in the future (which should be Thursday) # Busy period is from 1 PM to 1:30 PM EST on the first day being checked (April 13th) # The function should find an available slot after the busy period @@ -363,10 +372,12 @@ def test_available_slot_on_first_weekday(mock_datetime, fixed_utc_now, est_timez # Test out the find_first_available_slot function when multiple busy days +@patch("integrations.google_workspace.google_calendar.get_federal_holidays") @patch("integrations.google_workspace.google_calendar.datetime") -def test_opening_exists_after_busy_days(mock_datetime, fixed_utc_now, est_timezone): +def test_opening_exists_after_busy_days(mock_datetime, mock_federal_holidays, fixed_utc_now, est_timezone): # Mock datetime to control the flow of time in the test mock_datetime.utcnow.return_value = fixed_utc_now + mock_datetime.return_value.year = 2024 mock_datetime.fromisoformat.side_effect = lambda d: datetime.fromisoformat(d[:-1]) mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) freebusy_response = { @@ -382,6 +393,13 @@ def test_opening_exists_after_busy_days(mock_datetime, fixed_utc_now, est_timezo } } + mock_federal_holidays.return_value = {"holidays": [ + {"observedDate": "2024-01-01"}, + {"observedDate": "2024-07-01"} + ] + } + + start, end = google_calendar.find_first_available_slot( freebusy_response, days_in_future=3, duration_minutes=30, search_days_limit=60 ) @@ -396,8 +414,9 @@ def test_opening_exists_after_busy_days(mock_datetime, fixed_utc_now, est_timezo # Test that weekends are skipped when searching for available slots +@patch("integrations.google_workspace.google_calendar.get_federal_holidays") @patch("integrations.google_workspace.google_calendar.datetime") -def test_skipping_weekends(mock_datetime, fixed_utc_now, est_timezone): +def test_skipping_weekends(mock_datetime, mock_federal_holidays, fixed_utc_now, est_timezone): mock_datetime.utcnow.return_value = fixed_utc_now mock_datetime.fromisoformat.side_effect = lambda d: datetime.fromisoformat(d[:-1]) mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) @@ -411,6 +430,12 @@ def test_skipping_weekends(mock_datetime, fixed_utc_now, est_timezone): } } + mock_federal_holidays.return_value = {"holidays": [ + {"observedDate": "2024-01-01"}, + {"observedDate": "2024-07-01"} + ] + } + # For this test, ensure the mocked 'now' falls before a weekend, and verify that the function skips to the next weekday start, end = google_calendar.find_first_available_slot( freebusy_response, duration_minutes=30, days_in_future=1, search_days_limit=60 @@ -428,9 +453,10 @@ def test_skipping_weekends(mock_datetime, fixed_utc_now, est_timezone): # Test that no available slots are found within the search limit +@patch("integrations.google_workspace.google_calendar.get_federal_holidays") @patch("integrations.google_workspace.google_calendar.datetime") def test_no_available_slots_within_search_limit( - mock_datetime, fixed_utc_now, est_timezone + mock_datetime, mock_federal_holidays, fixed_utc_now, est_timezone ): mock_datetime.utcnow.return_value = fixed_utc_now mock_datetime.fromisoformat.side_effect = lambda d: datetime.fromisoformat(d[:-1]) @@ -447,6 +473,12 @@ def test_no_available_slots_within_search_limit( } } + mock_federal_holidays.return_value = {"holidays": [ + {"observedDate": "2024-01-01"}, + {"observedDate": "2024-07-01"} + ] + } + start, end = google_calendar.find_first_available_slot( freebusy_response, duration_minutes=30, days_in_future=3, search_days_limit=60 ) @@ -454,3 +486,65 @@ def test_no_available_slots_within_search_limit( assert ( start is None and end is None ), "Expected no available slots within the search limit" + + +# test that the federal holidays are correctly parsed +def test_get_federal_holidays(requests_mock): + # Mock the API response + mocked_response = { + "holidays": [ + {"observedDate": "2024-01-01"}, + {"observedDate": "2024-07-01"}, + {"observedDate": "2024-12-25"} + ] + } + requests_mock.get("https://canada-holidays.ca/api/v1/holidays?federal=true&year=2024", json=mocked_response) + + # Call the function + holidays = google_calendar.get_federal_holidays() + + # Assert that the holidays are correctly parsed + assert holidays == ["2024-01-01", "2024-07-01", "2024-12-25"] + +# test that holidays are correctly fetched for a different year +def test_get_federal_holidays_with_different_year(requests_mock): + # Mock the API response for a different year + requests_mock.get("https://canada-holidays.ca/api/v1/holidays?federal=true&year=2025", json={"holidays": []}) + + # Patch datetime to control the current year + with patch('integrations.google_workspace.google_calendar.datetime') as mock_datetime: + mock_datetime.now.return_value = datetime(2025, 1, 1) + + # Call the function + holidays = google_calendar.get_federal_holidays() + + # Assert that no holidays are returned for the mocked year + assert holidays == [] + + +# Test that an empty list is returned when there are no holidays +def test_api_returns_empty_list(requests_mock): + # Mock no holidays + requests_mock.get("https://canada-holidays.ca/api/v1/holidays?federal=true&year=2024", json={"holidays": []}) + + # Execute + holidays = google_calendar.get_federal_holidays() + + # Verify that an empty list is correctly handled + assert holidays == [], "Expected an empty list when there are no holidays" + + +# Test that a leap year is correctly handled +def test_leap_year_handling(requests_mock): + # Mock response for a leap year with an extra day + requests_mock.get("https://canada-holidays.ca/api/v1/holidays?federal=true&year=2024", json={ + "holidays": [ + {"observedDate": "2024-02-29"} # Assuming this is a special leap year holiday + ] + }) + + # Execute + holidays = google_calendar.get_federal_holidays() + + # Verify leap year is considered + assert "2024-02-29" in holidays, "Leap year date should be included in the holidays"