From fb0bc902813bcaf99641aa42a8483fd57bc6aeb2 Mon Sep 17 00:00:00 2001 From: Shahar Glazner Date: Wed, 27 Nov 2024 12:33:35 +0200 Subject: [PATCH] feat(api): add business hours (#2664) Co-authored-by: Tal Borenstein --- examples/workflows/businesshours.yml | 15 ++ keep-ui/shared/ui/utils/showErrorToast.tsx | 2 +- keep/api/tasks/notification_cache.py | 41 +++ keep/api/tasks/process_event_task.py | 16 +- keep/functions/__init__.py | 78 ++++++ keep/iohandler/iohandler.py | 85 ++++-- .../prometheus_provider/alerts_mock.py | 35 ++- poetry.lock | 19 +- pyproject.toml | 3 +- tests/test_functions.py | 248 ++++++++++++++++++ 10 files changed, 501 insertions(+), 41 deletions(-) create mode 100644 examples/workflows/businesshours.yml create mode 100644 keep/api/tasks/notification_cache.py diff --git a/examples/workflows/businesshours.yml b/examples/workflows/businesshours.yml new file mode 100644 index 000000000..6c9708598 --- /dev/null +++ b/examples/workflows/businesshours.yml @@ -0,0 +1,15 @@ +workflow: + id: businesshours + description: demonstrate how to do smth only when it's business hours + triggers: + - type: alert + - type: manual + actions: + - name: dismiss-alert + if: "keep.is_business_hours(timezone='America/New_York')" + provider: + type: mock + with: + enrich_alert: + - key: buisnesshours + value: "true" diff --git a/keep-ui/shared/ui/utils/showErrorToast.tsx b/keep-ui/shared/ui/utils/showErrorToast.tsx index c1e9af5ba..3d9041694 100644 --- a/keep-ui/shared/ui/utils/showErrorToast.tsx +++ b/keep-ui/shared/ui/utils/showErrorToast.tsx @@ -25,6 +25,6 @@ export function showErrorToast( } else if (error instanceof KeepApiError) { toast.error(customMessage || error.message, options); } else { - toast.error(`${customMessage + ": " || ""}Unknown error}`, options); + toast.error(`${customMessage + ": " || ""}Unknown error`, options); } } diff --git a/keep/api/tasks/notification_cache.py b/keep/api/tasks/notification_cache.py new file mode 100644 index 000000000..8f6896d45 --- /dev/null +++ b/keep/api/tasks/notification_cache.py @@ -0,0 +1,41 @@ +import os +import time +from typing import Dict, Tuple + +# Get polling interval from env +POLLING_INTERVAL = int(os.getenv("PUSHER_POLLING_INTERVAL", "15")) + + +class NotificationCache: + _instance = None + __initialized = False + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not self.__initialized: + self.cache: Dict[Tuple[str, str], float] = {} + self.__initialized = True + + def should_notify(self, tenant_id: str, event_type: str) -> bool: + cache_key = (tenant_id, event_type) + current_time = time.time() + + if cache_key not in self.cache: + self.cache[cache_key] = current_time + return True + + last_time = self.cache[cache_key] + if current_time - last_time >= POLLING_INTERVAL: + self.cache[cache_key] = current_time + return True + + return False + + +# Get singleton instance +def get_notification_cache() -> NotificationCache: + return NotificationCache() diff --git a/keep/api/tasks/process_event_task.py b/keep/api/tasks/process_event_task.py index 3809ea872..9404083c2 100644 --- a/keep/api/tasks/process_event_task.py +++ b/keep/api/tasks/process_event_task.py @@ -28,6 +28,7 @@ from keep.api.core.elastic import ElasticClient from keep.api.models.alert import AlertDto, AlertStatus, IncidentDto from keep.api.models.db.alert import Alert, AlertActionType, AlertAudit, AlertRaw +from keep.api.tasks.notification_cache import get_notification_cache from keep.api.utils.enrichment_helpers import ( calculated_start_firing_time, convert_db_alerts_to_dto_alerts, @@ -410,11 +411,12 @@ def __handle_formatted_events( }, ) - pusher_client = get_pusher_client() if notify_client else None + # Get the notification cache + pusher_cache = get_notification_cache() # Tell the client to poll alerts - if pusher_client: + if pusher_client and pusher_cache.should_notify(tenant_id, "poll-alerts"): try: pusher_client.trigger( f"private-{tenant_id}", @@ -425,8 +427,12 @@ def __handle_formatted_events( except Exception: logger.exception("Failed to tell client to poll alerts") pass - - if incidents and pusher_client: + + if ( + incidents + and pusher_client + and pusher_cache.should_notify(tenant_id, "incident-change") + ): try: pusher_client.trigger( f"private-{tenant_id}", @@ -440,7 +446,7 @@ def __handle_formatted_events( # send with pusher if not pusher_client: return - + try: presets = get_all_presets_dtos(tenant_id) rules_engine = RulesEngine(tenant_id=tenant_id) diff --git a/keep/functions/__init__.py b/keep/functions/__init__.py index b5a8b665d..7d69470eb 100644 --- a/keep/functions/__init__.py +++ b/keep/functions/__init__.py @@ -392,3 +392,81 @@ def is_first_time(fingerprint: str, since: str = None, **kwargs) -> str: return True else: return False + + +def is_business_hours( + time_to_check=None, + start_hour=8, + end_hour=20, + business_days=(0, 1, 2, 3, 4), # Mon = 0, Sun = 6 + timezone="UTC", +): + """ + Check if the given time or current time is between start_hour and end_hour + and falls on a business day + + Args: + time_to_check (str | datetime.datetime, optional): Time to check. + If None, current UTC time will be used. + start_hour (int, optional): Start hour in 24-hour format. Defaults to 8 (8:00 AM) + end_hour (int, optional): End hour in 24-hour format. Defaults to 20 (8:00 PM) + business_days (tuple, optional): Days of week considered as business days. + Monday=0 through Sunday=6. Defaults to Mon-Fri (0,1,2,3,4) + timezone (str, optional): Timezone name (e.g., 'UTC', 'America/New_York', 'Europe/London'). + Defaults to 'UTC'. + + Returns: + bool: True if time is between start_hour and end_hour on a business day + + Raises: + ValueError: If start_hour or end_hour are not between 0 and 23 + ValueError: If business_days contains invalid day numbers + ValueError: If timezone string is invalid + """ + # Validate hour inputs + if not (0 <= start_hour <= 23 and 0 <= end_hour <= 23): + raise ValueError("Hours must be between 0 and 23") + + # Strict validation for business_days + try: + invalid_days = [day for day in business_days if not (0 <= day <= 6)] + if invalid_days: + raise ValueError( + f"Invalid business days: {invalid_days}. Days must be between 0 (Monday) and 6 (Sunday)" + ) + except TypeError: + raise ValueError( + "business_days must be an iterable of integers between 0 and 6" + ) + + # Validate and convert timezone string to pytz timezone + try: + tz = pytz.timezone(timezone) + except pytz.exceptions.UnknownTimeZoneError: + raise ValueError(f"Invalid timezone: {timezone}") + + # If no time provided, use current UTC time + if time_to_check is None: + dt = utcnow() + else: + # Convert string to datetime if needed + dt = to_utc(time_to_check) if isinstance(time_to_check, str) else time_to_check + + if not dt: # Handle case where parsing failed + return False + + # Convert to specified timezone + dt = dt.astimezone(tz) + + # Get weekday (Monday = 0, Sunday = 6) + weekday = dt.weekday() + + # Check if it's a business day + if weekday not in business_days: + return False + + # Get just the hour (in 24-hour format) + hour = dt.hour + + # Check if hour is between start_hour and end_hour + return start_hour <= hour < end_hour diff --git a/keep/iohandler/iohandler.py b/keep/iohandler/iohandler.py index 4bcbb015c..d13af5318 100644 --- a/keep/iohandler/iohandler.py +++ b/keep/iohandler/iohandler.py @@ -240,7 +240,9 @@ def _parse(self, tree): if isinstance(tree, ast.Call): func = tree.func args = tree.args - # if its another function + keywords = tree.keywords # Get keyword arguments + + # Parse positional args _args = [] for arg in args: _arg = None @@ -250,7 +252,6 @@ def _parse(self, tree): _arg = str(arg.s) elif isinstance(arg, ast.Dict): _arg = ast.literal_eval(arg) - # set is basically {{ value }} elif isinstance(arg, ast.Set) or isinstance(arg, ast.List): _arg = astunparse.unparse(arg).strip() if ( @@ -259,10 +260,6 @@ def _parse(self, tree): or (_arg.startswith("(") and _arg.endswith(")")) ): try: - # TODO(shahargl): when Keep gonna be self hosted, this will be a security issue!!! - # because the user can run any python code need to find a way to limit the functions that can be used - - # https://github.com/keephq/keep/issues/138 import datetime from dateutil.tz import tzutc @@ -272,10 +269,8 @@ def _parse(self, tree): for dependency in self.context_manager.dependencies: g[dependency.__name__] = dependency - # TODO: this is a hack to tzutc in the eval, should be more robust g["tzutc"] = tzutc g["datetime"] = datetime - # finally, eval the expression _arg = eval(_arg, g) except ValueError: pass @@ -284,25 +279,75 @@ def _parse(self, tree): # if the value is empty '', we still need to pass it to the function if _arg or _arg == "": _args.append(_arg) - # check if we need to inject tenant_id + + # Parse keyword args + _kwargs = {} + for keyword in keywords: + key = keyword.arg + value = keyword.value + + if isinstance(value, ast.Call): + _kwargs[key] = _parse(self, value) + elif isinstance(value, ast.Str) or isinstance(value, ast.Constant): + _kwargs[key] = str(value.s) + elif isinstance(value, ast.Dict): + _kwargs[key] = ast.literal_eval(value) + elif isinstance(value, ast.Set) or isinstance(value, ast.List): + parsed_value = astunparse.unparse(value).strip() + if ( + ( + parsed_value.startswith("[") + and parsed_value.endswith("]") + ) + or ( + parsed_value.startswith("{") + and parsed_value.endswith("}") + ) + or ( + parsed_value.startswith("(") + and parsed_value.endswith(")") + ) + ): + try: + import datetime + + from dateutil.tz import tzutc + + g = globals() + for dependency in self.context_manager.dependencies: + g[dependency.__name__] = dependency + + g["tzutc"] = tzutc + g["datetime"] = datetime + _kwargs[key] = eval(parsed_value, g) + except ValueError: + pass + else: + _kwargs[key] = value.id + + # Get the function and its signature keep_func = getattr(keep_functions, func.attr) func_signature = inspect.signature(keep_func) - kwargs = {} + # Add tenant_id if needed if "kwargs" in func_signature.parameters: - kwargs["tenant_id"] = self.context_manager.tenant_id + _kwargs["tenant_id"] = self.context_manager.tenant_id try: - val = ( - keep_func(*_args) if not kwargs else keep_func(*_args, **kwargs) - ) - # try again but with replacing \n with \\n - # again - best effort see test_openobserve_rows_bug test + # Call function with both positional and keyword arguments + val = keep_func(*_args, **_kwargs) except ValueError: - _args = [arg.replace("\n", "\\n") for arg in _args] - val = ( - keep_func(*_args) if not kwargs else keep_func(*_args, **kwargs) - ) + # Handle newline escaping if needed + _args = [ + arg.replace("\n", "\\n") if isinstance(arg, str) else arg + for arg in _args + ] + _kwargs = { + k: v.replace("\n", "\\n") if isinstance(v, str) else v + for k, v in _kwargs.items() + } + val = keep_func(*_args, **_kwargs) + return val try: diff --git a/keep/providers/prometheus_provider/alerts_mock.py b/keep/providers/prometheus_provider/alerts_mock.py index fa5f7e922..458e590c6 100644 --- a/keep/providers/prometheus_provider/alerts_mock.py +++ b/keep/providers/prometheus_provider/alerts_mock.py @@ -11,17 +11,21 @@ }, "parameters": { "labels.host": ["host1", "host2", "host3"], - "labels.service": ["calendar-producer-java-otel-api-dd", "kafka", "api", "queue", "db"], + "labels.service": [ + "calendar-producer-java-otel-api-dd", + "kafka", + "api", + "queue", + "db", + "ftp", + ], "labels.instance": ["instance1", "instance2", "instance3"], }, }, "mq_third_full (Message queue is over 33%)": { "payload": { "summary": "Message queue is over 33% capacity", - "labels": { - "severity": "warning", - "customer_id": "acme" - }, + "labels": {"severity": "warning", "customer_id": "acme"}, }, "parameters": { "labels.queue": ["queue1", "queue2", "queue3"], @@ -32,10 +36,7 @@ "mq_full (Message queue is full)": { "payload": { "summary": "Message queue is over 90% capacity", - "labels": { - "severity": "critical", - "customer_id": "acme" - }, + "labels": {"severity": "critical", "customer_id": "acme"}, }, "parameters": { "labels.queue": ["queue4"], @@ -52,7 +53,13 @@ }, "parameters": { "labels.host": ["host1", "host2", "host3"], - "labels.service": ["calendar-producer-java-otel-api-dd", "kafka", "api", "queue", "db"], + "labels.service": [ + "calendar-producer-java-otel-api-dd", + "kafka", + "api", + "queue", + "db", + ], "labels.instance": ["instance1", "instance2", "instance3"], }, }, @@ -65,7 +72,13 @@ }, "parameters": { "labels.host": ["host1", "host2", "host3"], - "labels.service": ["calendar-producer-java-otel-api-dd", "kafka", "api", "queue", "db"], + "labels.service": [ + "calendar-producer-java-otel-api-dd", + "kafka", + "api", + "queue", + "db", + ], "labels.instance": ["instance1", "instance2", "instance3"], }, }, diff --git a/poetry.lock b/poetry.lock index b4be37586..305e87dc4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1326,6 +1326,20 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.11.0,<2.12.0" pyflakes = ">=3.1.0,<3.2.0" +[[package]] +name = "freezegun" +version = "1.5.1" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.7" +files = [ + {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, + {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "frozenlist" version = "1.4.1" @@ -2636,7 +2650,7 @@ name = "ndg-httpsclient" version = "0.5.1" description = "Provides enhanced HTTPS support for httplib and urllib2 using PyOpenSSL" optional = false -python-versions = ">=2.7,<3.0.dev0 || >=3.4.dev0" +python-versions = ">=2.7,<3.0.0 || >=3.4.0" files = [ {file = "ndg_httpsclient-0.5.1-py2-none-any.whl", hash = "sha256:d2c7225f6a1c6cf698af4ebc962da70178a99bcde24ee6d1961c4f3338130d57"}, {file = "ndg_httpsclient-0.5.1-py3-none-any.whl", hash = "sha256:dd174c11d971b6244a891f7be2b32ca9853d3797a72edb34fa5d7b07d8fff7d4"}, @@ -4079,7 +4093,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -5154,4 +5167,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.12" -content-hash = "d87b9a24e331d9b1ce1286aa31bf653c563e667ea713ca9a46468d59b8164b77" +content-hash = "510bd6c33adea01322ef29dd0e709649af1820ea1112692b85520b3e279771ae" diff --git a/pyproject.toml b/pyproject.toml index 93e71975e..1fdafa51e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.30.2" +version = "0.30.3" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] packages = [{include = "keep"}] @@ -101,6 +101,7 @@ ruff = "^0.1.6" pytest-docker = "^2.0.1" playwright = "^1.44.0" +freezegun = "^1.5.1" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/test_functions.py b/tests/test_functions.py index e4f4a385c..bd2271052 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -4,6 +4,7 @@ import pytest import pytz +from freezegun import freeze_time import keep.functions as functions from keep.api.bl.enrichments_bl import EnrichmentsBl @@ -509,3 +510,250 @@ def test_firing_time_with_manual_resolve(create_alert): # It should override the dispoable status, but show only the time since the last firing result = functions.get_firing_time(alert, "m", tenant_id=SINGLE_TENANT_UUID) assert abs(float(result) - 30) < 1 # Allow for small time differences + + +def test_is_business_hours(): + """ + Test the default business hours (8-20) with different times + """ + # Test during business hours + business_time = datetime.datetime( + 2024, 3, 27, 14, 30, tzinfo=datetime.timezone.utc + ) # 14:30 + assert functions.is_business_hours(business_time) == True + + # Test before business hours + early_time = datetime.datetime( + 2024, 3, 27, 7, 30, tzinfo=datetime.timezone.utc + ) # 7:30 + assert functions.is_business_hours(early_time) == False + + # Test after business hours + late_time = datetime.datetime( + 2024, 3, 27, 20, 30, tzinfo=datetime.timezone.utc + ) # 20:30 + assert functions.is_business_hours(late_time) == False + + # Test exactly at start hour + start_time = datetime.datetime( + 2024, 3, 27, 8, 0, tzinfo=datetime.timezone.utc + ) # 8:00 + assert functions.is_business_hours(start_time) == True + + # Test exactly at end hour + end_time = datetime.datetime( + 2024, 3, 27, 20, 0, tzinfo=datetime.timezone.utc + ) # 20:00 + assert functions.is_business_hours(end_time) == False + + +def test_is_business_hours_custom_hours(): + """ + Test custom business hours (9-17) + """ + test_time = datetime.datetime( + 2024, 3, 27, 8, 30, tzinfo=datetime.timezone.utc + ) # 8:30 + assert functions.is_business_hours(test_time, start_hour=9, end_hour=17) == False + + test_time = datetime.datetime( + 2024, 3, 27, 12, 30, tzinfo=datetime.timezone.utc + ) # 12:30 + assert functions.is_business_hours(test_time, start_hour=9, end_hour=17) == True + + +def test_is_business_hours_invalid_hours(): + """ + Test with invalid hour inputs + """ + test_time = datetime.datetime(2024, 3, 27, 12, 30, tzinfo=datetime.timezone.utc) + + with pytest.raises(ValueError): + functions.is_business_hours(test_time, start_hour=24, end_hour=17) + + with pytest.raises(ValueError): + functions.is_business_hours(test_time, start_hour=8, end_hour=-1) + + +def test_is_business_hours_string_input(): + """ + Test with string datetime input + """ + assert functions.is_business_hours("2024-03-27T14:30:00Z") == True + assert functions.is_business_hours("2024-03-27T06:30:00Z") == False + + +def test_is_business_hours_invalid_string(): + """ + Test with invalid string datetime input + """ + assert functions.is_business_hours("invalid datetime") == False + + +def test_is_business_hours_no_params(): + """ + Test with no parameters by mocking the current time + """ + # Test during business hours + with freeze_time("2024-03-27 10:00:00"): + assert functions.is_business_hours() == True + + # Test before business hours + with freeze_time("2024-03-27 06:00:00"): + assert functions.is_business_hours() == False + + # Test after business hours + with freeze_time("2024-03-27 22:00:00"): + assert functions.is_business_hours() == False + + # Test at the boundaries + with freeze_time("2024-03-27 08:00:00"): + assert functions.is_business_hours() == True + + with freeze_time("2024-03-27 19:59:59"): + assert functions.is_business_hours() == True + + +def test_is_business_hours_weekdays(): + """ + Test business days with default Mon-Fri + """ + # Monday 10 AM (should be True) + with freeze_time("2024-03-25 10:00:00"): # Monday + assert functions.is_business_hours() == True + + # Saturday 10 AM (should be False) + with freeze_time("2024-03-23 10:00:00"): # Saturday + assert functions.is_business_hours() == False + + # Sunday 10 AM (should be False) + with freeze_time("2024-03-24 10:00:00"): # Sunday + assert functions.is_business_hours() == False + + +def test_is_business_hours_custom_days(): + """ + Test with custom business days (Tue-Sat) + """ + test_time = datetime.datetime( + 2024, 3, 25, 10, 0, tzinfo=datetime.timezone.utc + ) # Monday + assert ( + functions.is_business_hours(test_time, business_days=(1, 2, 3, 4, 5)) == False + ) + + test_time = datetime.datetime( + 2024, 3, 23, 10, 0, tzinfo=datetime.timezone.utc + ) # Saturday + assert functions.is_business_hours(test_time, business_days=(1, 2, 3, 4, 5)) == True + + +def test_is_business_hours_timezone(): + """ + Test with different timezones + """ + # 10 AM UTC = 6 AM EDT (before business hours in EDT) + est_tz = "America/New_York" + with freeze_time("2024-03-25 10:00:00"): + assert functions.is_business_hours(timezone=est_tz) == False + + # 2 PM UTC = 10 AM EDT (during business hours in EDT) + with freeze_time("2024-03-25 14:00:00"): + assert functions.is_business_hours(timezone=est_tz) == True + + +def test_is_business_hours_invalid_days(): + """ + Test with invalid business days + """ + test_time = datetime.datetime(2024, 3, 25, 10, 0, tzinfo=datetime.timezone.utc) + + # Test with days outside valid range + with pytest.raises(ValueError) as exc_info: + functions.is_business_hours(test_time, business_days=(7, 8, 9)) + assert "Invalid business days" in str(exc_info.value) + + # Test with negative days + with pytest.raises(ValueError) as exc_info: + functions.is_business_hours(test_time, business_days=(-1, 0, 1)) + assert "Invalid business days" in str(exc_info.value) + + # Test with non-iterable + with pytest.raises(ValueError) as exc_info: + functions.is_business_hours(test_time, business_days=42) + assert "business_days must be an iterable" in str(exc_info.value) + + # Test with invalid types in iterable + with pytest.raises(ValueError) as exc_info: + functions.is_business_hours(test_time, business_days=(1, "tuesday", 3)) + assert "business_days must be an iterable of integers" in str(exc_info.value) + + +def test_is_business_hours_all_combinations(): + """ + Test various combinations of parameters + """ + tokyo_tz = "Asia/Tokyo" + test_time = datetime.datetime(2024, 3, 25, 10, 0, tzinfo=datetime.timezone.utc) + + # Custom hours, days, and timezone + assert ( + functions.is_business_hours( + test_time, + start_hour=9, + end_hour=17, + business_days=(0, 1, 2, 3), # Mon-Thu + timezone=tokyo_tz, + ) + == False + ) # 10 UTC = 19 JST (after business hours) + + # Weekend with extended hours + assert ( + functions.is_business_hours( + test_time, + start_hour=0, + end_hour=23, + business_days=(5, 6), # Sat-Sun only + timezone="UTC", + ) + == False + ) # It's a Monday + + +def test_is_business_hours_edge_cases(): + """ + Test edge cases with timezones and day boundaries + """ + ny_tz = "America/New_York" + + # Test exactly at timezone day boundary + edge_time = datetime.datetime( + 2024, 3, 25, 4, 0, tzinfo=datetime.timezone.utc + ) # Midnight EDT + assert functions.is_business_hours(edge_time, timezone=ny_tz) == False + + # Test exactly at business hours start + with freeze_time("2024-03-25 12:00:00"): # 8 AM EDT + assert functions.is_business_hours(timezone=ny_tz) == True + + # Test exactly at business hours end + with freeze_time("2024-03-26 00:00:00"): # 8 PM EDT previous day + assert functions.is_business_hours(timezone=ny_tz) == False + + +def test_is_business_hours_string_input_with_timezone(): + """ + Test string datetime input with timezone handling + """ + paris_tz = "Europe/Paris" + + # 2 PM UTC = 4 PM Paris + assert ( + functions.is_business_hours("2024-03-25T14:00:00Z", timezone=paris_tz) == True + ) + + # 8 PM UTC = 10 PM Paris + assert ( + functions.is_business_hours("2024-03-25T20:00:00Z", timezone=paris_tz) == False + )