oncall-engine/engine/apps/schedules/tests/test_ical_utils.py

691 lines
23 KiB
Python
Raw Permalink Normal View History

2022-09-13 10:30:34 -03:00
import datetime
import textwrap
from unittest.mock import patch
2022-07-06 15:47:21 -03:00
from uuid import uuid4
import icalendar
import pytest
2022-09-13 10:30:34 -03:00
import pytz
from django.core.cache import cache
from django.utils import timezone
from apps.api.permissions import GrafanaAPIPermissions, LegacyAccessControlRole, RBACPermission
2022-09-13 10:30:34 -03:00
from apps.schedules.ical_utils import (
get_cached_oncall_users_for_multiple_schedules,
get_icalendar_tz_or_utc,
get_oncall_users_for_multiple_schedules,
is_icals_equal,
2022-09-13 10:30:34 -03:00
list_of_oncall_shifts_from_ical,
list_users_to_notify_from_ical,
parse_event_uid,
users_in_ical,
)
from apps.schedules.models import (
CustomOnCallShift,
OnCallSchedule,
OnCallScheduleCalendar,
OnCallScheduleICal,
OnCallScheduleWeb,
)
def test_get_icalendar_tz_or_utc():
ical_data = textwrap.dedent(
"""
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-TIMEZONE:Europe/London
BEGIN:VTIMEZONE
TZID:America/Argentina/Buenos_Aires
X-LIC-LOCATION:America/Argentina/Buenos_Aires
BEGIN:STANDARD
TZOFFSETFROM:-0300
TZOFFSETTO:-0300
TZNAME:-03
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE
END:VCALENDAR
"""
)
ical = icalendar.Calendar.from_ical(ical_data)
tz = get_icalendar_tz_or_utc(ical)
assert tz == pytz.timezone("Europe/London")
def test_get_icalendar_tz_or_utc_fallback():
ical_data = textwrap.dedent(
"""
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VTIMEZONE
TZID:America/Argentina/Buenos_Aires
X-LIC-LOCATION:America/Argentina/Buenos_Aires
BEGIN:STANDARD
TZOFFSETFROM:-0300
TZOFFSETTO:-0300
TZNAME:-03
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE
END:VCALENDAR
"""
)
ical = icalendar.Calendar.from_ical(ical_data)
tz = get_icalendar_tz_or_utc(ical)
assert tz == pytz.timezone("UTC")
@pytest.mark.django_db
def test_users_in_ical_email_case_insensitive(make_organization_and_user, make_user_for_organization):
organization, user = make_organization_and_user()
user = make_user_for_organization(organization, username="foo", email="TestingUser@test.com")
usernames = ["testinguser@test.com"]
result = users_in_ical(usernames, organization)
assert set(result) == {user}
@pytest.mark.django_db
@pytest.mark.parametrize(
"role,included",
[
(LegacyAccessControlRole.ADMIN, True),
(LegacyAccessControlRole.EDITOR, True),
(LegacyAccessControlRole.VIEWER, False),
(LegacyAccessControlRole.NONE, False),
],
)
def test_users_in_ical_basic_role(make_organization_and_user, make_user_for_organization, role, included):
organization, user = make_organization_and_user()
organization.is_rbac_permissions_enabled = False
organization.save()
other_user = make_user_for_organization(organization, role=role)
usernames = [user.username, other_user.username]
expected_result = {user}
if included:
expected_result.add(other_user)
result = users_in_ical(usernames, organization)
assert set(result) == expected_result
@pytest.mark.django_db
@pytest.mark.parametrize(
"permission,included",
[
(RBACPermission.Permissions.NOTIFICATIONS_READ, True),
(RBACPermission.Permissions.SCHEDULES_READ, False),
(None, False),
],
)
def test_users_in_ical_rbac(make_organization_and_user, make_user_for_organization, permission, included):
organization, _ = make_organization_and_user()
organization.is_rbac_permissions_enabled = True
organization.save()
viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER)
usernames = [viewer.username]
# viewer doesn't yet have the required permission, they shouldn't be included
assert len(users_in_ical(usernames, organization)) == 0
viewer.permissions = GrafanaAPIPermissions.construct_permissions([permission.value]) if permission else []
viewer.save()
assert users_in_ical(usernames, organization) == ([viewer] if included else [])
@pytest.mark.django_db
def test_list_users_to_notify_from_ical_viewers_exclusion(
make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift
):
organization, user = make_organization_and_user()
Add RBAC Support (#777) * Modify plugin.json to support RBAC role registration * defines 26 new custom roles in plugin.json. The main roles are: - Admin: read/write access to everything in OnCall - Reader: read access to everything in OnCall - OnCaller : read access to everything in OnCall + edit access to Alert Groups and Schedules - <object-type> Editor: read/write access to everything related to <object-type> - <object-type> Reader: read access for <object-type> - User Settings Admin: read/write access to all user's settings, not just own settings. This is in comparison to User Settings Editor which can only read/write own settings * update changelog and documentation (#686) * implement RBAC for OnCall backend This commit refactors backend authorization. It trys to use RBAC authorization if the org's grafana instance supports it, otherwise it falls back to basic role authorization. * update RBAC backend tests * add tests for RBAC changes - run backend tests as matrix where RBAC is enabled/disabled. When RBAC is enabled, the permissions granted are read from the role grants in the frontend's plugin.json file (instead of relying what we specify in RBACPermission.Permissions) - remove --reuse-db --nomigrations flags from engine/tox.ini - minor autoformatting changes to docker-compose-developer.yml * remove --ds=settings.ci-test from pytest CI command DJANGO_SETTINGS_MODULE is already specified as an env var so this is just unecessary duplication * update gitignore * update github action job name for "test" * RBAC frontend changes * refactors the use of basic roles (ex. Viewer, Editor, Admin) use RBAC permissions (when supported), or falling back to basic roles when RBAC is not supported. - updates the UserAction enum in grafana-plugin/src/state/userAction.ts. Previously this was hardcoded to a list of strings that were being returned by the OnCall API. Now the values here correspond to the permissions in plugin.json (plus a fallback role) * changes per Gabriel's comments: - get rid of group attribute in rbac roles - remove displayName role attribute - remove hidden role attribute - add back role to includes section * don't try to update user timezone if they don't have permission
2022-11-29 09:41:56 +01:00
viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER)
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
Fix warnings when running backend tests (#2079) # What this PR does - update `make test` to always use `settings.ci-test`. Right now it will use whatever the value of `DJANGO_SETTINGS_MODULE` is in `./dev/.env.dev`, which causes ~45 tests to fail - Fix several Python warnings that we see when running the tests ```bash RemovedInDjango40Warning: The providing_args argument is deprecated. As it is purely documentational, it has no replacement. If you rely on this argument as documentation, you can move the text to a code comment or docstring. alert_create_signal = django.dispatch.Signal( ``` ```bash PytestCollectionWarning: cannot collect test class 'TestOnlyBackend' because it has a __init__ constructor (from: apps/api/tests/test_alert_receive_channel_template.py) class TestOnlyBackend(BaseMessagingBackend): ``` ```bash DeprecationWarning: The parameter 'use_aliases' in emoji.emojize() is deprecated and will be removed in version 2.0.0. Use language='alias' instead. To hide this warning, pin/downgrade the package to 'emoji~=1.6.3' return emoji.emojize(self.verbal_name, use_aliases=True) ``` ```bash DateTimeField CustomOnCallShift.start received a naive datetime (2023-06-01 12:53:12) while time zone support is active. warnings.warn("DateTimeField %s received a naive datetime (%s)" ``` ```bash apps/twilioapp/tests/test_phone_calls.py::test_resolve_by_phone /etc/app/apps/twilioapp/tests/test_phone_calls.py:173: DeprecationWarning: The 'text' argument to find()-type methods is deprecated. Use 'string' instead. content = BeautifulSoup(content, features="html.parser").findAll(text=True) ``` ```bash apps/twilioapp/tests/test_phone_calls.py::test_resolve_by_phone apps/twilioapp/tests/test_phone_calls.py::test_wrong_pressed_digit /usr/local/lib/python3.11/site-packages/bs4/builder/__init__.py:545: XMLParsedAsHTMLWarning: It looks like you're parsing an XML document using an HTML parser. If this really is an HTML document (maybe it's XHTML?), you can ignore or filter this warning. If it's XML, you should know that using an XML parser will be more reliable. To parse this document as XML, make sure you have the lxml package installed, and pass the keyword argument `features="xml"` into the BeautifulSoup constructor. ``` ```bash apps/twilioapp/tests/test_phone_calls.py::test_forbidden_requests /usr/local/lib/python3.11/site-packages/social_django/urls.py:15: RemovedInDjango40Warning: django.conf.urls.url() is deprecated in favor of django.urls.re_path(). url(r'^login/(?P<backend>[^/]+){0}$'.format(extra), views.auth, ``` ```bash apps/twilioapp/tests/test_phone_calls.py: 66 warnings /usr/local/lib/python3.11/site-packages/debug_toolbar/utils.py:255: DeprecationWarning: currentThread() is deprecated, use current_thread() instead thread = threading.currentThread() ``` ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
2023-06-06 20:38:00 +02:00
date = timezone.now().replace(microsecond=0)
data = {
"priority_level": 1,
"start": date,
"rotation_start": date,
"duration": timezone.timedelta(seconds=10800),
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_SINGLE_EVENT, **data
)
on_call_shift.users.add(user)
on_call_shift.users.add(viewer)
schedule.custom_on_call_shifts.add(on_call_shift)
# get users on-call
date = date + timezone.timedelta(minutes=5)
users_on_call = list_users_to_notify_from_ical(schedule, date)
assert len(users_on_call) == 1
assert set(users_on_call) == {user}
2022-07-06 15:47:21 -03:00
@pytest.mark.django_db
def test_list_users_to_notify_from_ical_ignore_cancelled(make_organization_and_user, make_schedule):
organization, user = make_organization_and_user()
now = timezone.now().replace(second=0, microsecond=0)
end = now + timezone.timedelta(minutes=30)
ical_data = textwrap.dedent(
"""
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
SUMMARY:{}
DTSTART;VALUE=DATE-TIME:{}
DTEND;VALUE=DATE-TIME:{}
DTSTAMP;VALUE=DATE-TIME:20230807T001508Z
UID:some-uid
LOCATION:primary
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR
""".format(
user.username, now.strftime("%Y%m%dT%H%M%SZ"), end.strftime("%Y%m%dT%H%M%SZ")
)
)
schedule = make_schedule(organization, schedule_class=OnCallScheduleICal, cached_ical_file_primary=ical_data)
# get users on-call
users_on_call = list_users_to_notify_from_ical(schedule, now + timezone.timedelta(minutes=5))
assert len(users_on_call) == 0
@pytest.mark.django_db
def test_list_users_to_notify_from_ical_until_terminated_event(
make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift
):
organization, user = make_organization_and_user()
other_user = make_user_for_organization(organization)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
Fix warnings when running backend tests (#2079) # What this PR does - update `make test` to always use `settings.ci-test`. Right now it will use whatever the value of `DJANGO_SETTINGS_MODULE` is in `./dev/.env.dev`, which causes ~45 tests to fail - Fix several Python warnings that we see when running the tests ```bash RemovedInDjango40Warning: The providing_args argument is deprecated. As it is purely documentational, it has no replacement. If you rely on this argument as documentation, you can move the text to a code comment or docstring. alert_create_signal = django.dispatch.Signal( ``` ```bash PytestCollectionWarning: cannot collect test class 'TestOnlyBackend' because it has a __init__ constructor (from: apps/api/tests/test_alert_receive_channel_template.py) class TestOnlyBackend(BaseMessagingBackend): ``` ```bash DeprecationWarning: The parameter 'use_aliases' in emoji.emojize() is deprecated and will be removed in version 2.0.0. Use language='alias' instead. To hide this warning, pin/downgrade the package to 'emoji~=1.6.3' return emoji.emojize(self.verbal_name, use_aliases=True) ``` ```bash DateTimeField CustomOnCallShift.start received a naive datetime (2023-06-01 12:53:12) while time zone support is active. warnings.warn("DateTimeField %s received a naive datetime (%s)" ``` ```bash apps/twilioapp/tests/test_phone_calls.py::test_resolve_by_phone /etc/app/apps/twilioapp/tests/test_phone_calls.py:173: DeprecationWarning: The 'text' argument to find()-type methods is deprecated. Use 'string' instead. content = BeautifulSoup(content, features="html.parser").findAll(text=True) ``` ```bash apps/twilioapp/tests/test_phone_calls.py::test_resolve_by_phone apps/twilioapp/tests/test_phone_calls.py::test_wrong_pressed_digit /usr/local/lib/python3.11/site-packages/bs4/builder/__init__.py:545: XMLParsedAsHTMLWarning: It looks like you're parsing an XML document using an HTML parser. If this really is an HTML document (maybe it's XHTML?), you can ignore or filter this warning. If it's XML, you should know that using an XML parser will be more reliable. To parse this document as XML, make sure you have the lxml package installed, and pass the keyword argument `features="xml"` into the BeautifulSoup constructor. ``` ```bash apps/twilioapp/tests/test_phone_calls.py::test_forbidden_requests /usr/local/lib/python3.11/site-packages/social_django/urls.py:15: RemovedInDjango40Warning: django.conf.urls.url() is deprecated in favor of django.urls.re_path(). url(r'^login/(?P<backend>[^/]+){0}$'.format(extra), views.auth, ``` ```bash apps/twilioapp/tests/test_phone_calls.py: 66 warnings /usr/local/lib/python3.11/site-packages/debug_toolbar/utils.py:255: DeprecationWarning: currentThread() is deprecated, use current_thread() instead thread = threading.currentThread() ``` ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
2023-06-06 20:38:00 +02:00
date = timezone.now().replace(microsecond=0)
data = {
"start": date,
"duration": timezone.timedelta(hours=4),
"rotation_start": date + timezone.timedelta(days=3),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"by_day": ["SU"],
"interval": 1,
"until": date + timezone.timedelta(hours=8),
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user], [other_user]])
# get users on-call
date = date + timezone.timedelta(minutes=5)
# this should not raise despite the shift configuration (until < rotation start)
users_on_call = list_users_to_notify_from_ical(schedule, date)
assert list(users_on_call) == []
@pytest.mark.django_db
def test_list_users_to_notify_from_ical_overlapping_events(
make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift
):
organization, user = make_organization_and_user()
another_user = make_user_for_organization(organization)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
start = timezone.now() - timezone.timedelta(hours=1)
data = {
"start": start,
"rotation_start": start,
"duration": timezone.timedelta(hours=3),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
data = {
"start": start + timezone.timedelta(minutes=30),
"rotation_start": start + timezone.timedelta(minutes=30),
"duration": timezone.timedelta(hours=2),
"priority_level": 2,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[another_user]])
# get users on-call now
users_on_call = list_users_to_notify_from_ical(schedule)
assert len(users_on_call) == 1
assert set(users_on_call) == {another_user}
2022-09-13 10:30:34 -03:00
@pytest.mark.django_db
def test_shifts_dict_all_day_middle_event(make_organization, make_schedule, get_ical):
calendar = get_ical("calendar_with_all_day_event.ics")
organization = make_organization()
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
schedule.cached_ical_file_primary = calendar.to_ical()
day_to_check_iso = "2021-01-27T15:27:14.448059+00:00"
parsed_iso_day_to_check = datetime.datetime.fromisoformat(day_to_check_iso).replace(tzinfo=pytz.UTC)
requested_datetime = parsed_iso_day_to_check - timezone.timedelta(days=1)
datetime_end = requested_datetime + timezone.timedelta(days=2)
shifts = list_of_oncall_shifts_from_ical(schedule, requested_datetime, datetime_end, with_empty_shifts=True)
assert len(shifts) == 5
2022-09-13 10:30:34 -03:00
for s in shifts:
start = (
s["start"]
if isinstance(s["start"], datetime.datetime)
else datetime.datetime.combine(s["start"], datetime.time.min, tzinfo=pytz.UTC)
)
end = (
s["end"]
if isinstance(s["end"], datetime.datetime)
else datetime.datetime.combine(s["start"], datetime.time.max, tzinfo=pytz.UTC)
)
2022-09-13 10:30:34 -03:00
# event started in the given period, or ended in that period, or is happening during the period
assert (
requested_datetime <= start <= requested_datetime + timezone.timedelta(days=2)
or requested_datetime <= end <= requested_datetime + timezone.timedelta(days=2)
or start <= requested_datetime <= end
2022-09-13 10:30:34 -03:00
)
@pytest.mark.django_db
def test_shifts_dict_from_cached_final(
make_organization,
make_user_for_organization,
make_schedule,
make_on_call_shift,
):
organization = make_organization()
u1 = make_user_for_organization(organization)
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
yesterday = today - timezone.timedelta(days=1)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
data = {
"start": yesterday + timezone.timedelta(hours=10),
"rotation_start": yesterday + timezone.timedelta(hours=10),
"duration": timezone.timedelta(hours=2),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[u1]])
override_data = {
"start": yesterday + timezone.timedelta(hours=12),
"rotation_start": yesterday + timezone.timedelta(hours=12),
"duration": timezone.timedelta(hours=1),
"schedule": schedule,
}
override = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data
)
override.add_rolling_users([[u1]])
schedule.refresh_ical_file()
schedule.refresh_ical_final_schedule()
shifts = [
(s["calendar_type"], s["start"], list(s["users"]))
for s in list_of_oncall_shifts_from_ical(schedule, yesterday, today, from_cached_final=True)
]
expected_events = [
(OnCallSchedule.PRIMARY, on_call_shift.start, [u1]),
(OnCallSchedule.OVERRIDES, override.start, [u1]),
]
assert shifts == expected_events
def test_parse_event_uid_from_export():
shift_pk = "OUCE6WAHL35PP"
user_pk = "UHZ38D6AQXXBY"
event_uid = f"{shift_pk}-202309200300-{user_pk}"
pk, source = parse_event_uid(event_uid)
assert pk == shift_pk
assert source is None
2022-07-06 15:47:21 -03:00
def test_parse_event_uid_v1():
uuid = uuid4()
event_uid = f"amixr-{uuid}-U1-E2-S1"
pk, source = parse_event_uid(event_uid)
assert pk is None
assert source == "api"
def test_parse_event_uid_v2():
uuid = uuid4()
pk_value = "OABCDEF12345"
event_uid = f"oncall-{uuid}-PK{pk_value}-U3-E1-S2"
pk, source = parse_event_uid(event_uid)
assert pk == pk_value
assert source == "slack"
def test_parse_event_uid_fallback():
# use ical existing UID for imported events
event_uid = "someid@google.com"
pk, source = parse_event_uid(event_uid)
assert pk == event_uid
assert source is None
def test_parse_recurrent_event_uid_fallback_modified():
# use ical existing UID for imported events
event_uid = "someid@google.com"
pk, source = parse_event_uid(event_uid, sequence="2")
assert pk == f"{event_uid}_2"
assert source is None
pk, source = parse_event_uid(event_uid, recurrence_id="other-id")
assert pk == f"{event_uid}_other-id"
assert source is None
pk, source = parse_event_uid(event_uid, sequence="3", recurrence_id="other-id")
assert pk == f"{event_uid}_3_other-id"
assert source is None
def test_is_icals_equal_compare_events():
with_vtimezone = textwrap.dedent(
"""
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-TIMEZONE:Europe/Amsterdam
BEGIN:VTIMEZONE
TZID:Europe/Amsterdam
X-LIC-LOCATION:Europe/Amsterdam
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;VALUE=DATE:20230515
DTEND;VALUE=DATE:20230522
DTSTAMP:20230503T152557Z
UID:something@google.com
RECURRENCE-ID;VALUE=DATE:20230501
CREATED:20230403T073117Z
LAST-MODIFIED:20230424T123617Z
SEQUENCE:2
STATUS:CONFIRMED
SUMMARY:some@user.com
END:VEVENT
END:VCALENDAR
"""
)
without_vtimezone = textwrap.dedent(
"""
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-TIMEZONE:Europe/Amsterdam
BEGIN:VEVENT
DTSTART;VALUE=DATE:20230515
DTEND;VALUE=DATE:20230522
DTSTAMP:20230503T162103Z
UID:something@google.com
RECURRENCE-ID;VALUE=DATE:20230501
CREATED:20230403T073117Z
LAST-MODIFIED:20230424T123617Z
SEQUENCE:2
STATUS:CONFIRMED
SUMMARY:some@user.com
END:VEVENT
END:VCALENDAR
"""
)
assert is_icals_equal(with_vtimezone, without_vtimezone)
def test_is_icals_equal_compare_events_not_equal():
with_vtimezone = textwrap.dedent(
"""
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-TIMEZONE:Europe/Amsterdam
BEGIN:VTIMEZONE
TZID:Europe/Amsterdam
X-LIC-LOCATION:Europe/Amsterdam
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;VALUE=DATE:20230515
DTEND;VALUE=DATE:20230522
DTSTAMP:20230503T152557Z
UID:something@google.com
RECURRENCE-ID;VALUE=DATE:20230501
CREATED:20230403T073117Z
LAST-MODIFIED:20230424T123617Z
SEQUENCE:2
STATUS:CONFIRMED
SUMMARY:some@user.com
END:VEVENT
END:VCALENDAR
"""
)
without_vtimezone = textwrap.dedent(
"""
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-TIMEZONE:Europe/Amsterdam
BEGIN:VEVENT
DTSTART;VALUE=DATE:20230515
DTEND;VALUE=DATE:20230522
DTSTAMP:20230503T162103Z
UID:something@google.com
RECURRENCE-ID;VALUE=DATE:20230501
CREATED:20230403T073117Z
LAST-MODIFIED:20230424T123617Z
SEQUENCE:3
STATUS:CONFIRMED
SUMMARY:some@user.com
END:VEVENT
END:VCALENDAR
"""
)
assert not is_icals_equal(with_vtimezone, without_vtimezone)
@pytest.mark.django_db
def test_get_cached_oncall_users_for_multiple_schedules(
make_organization,
make_user_for_organization,
make_schedule,
make_on_call_shift,
):
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
def _make_schedule_with_oncall_users(organization, oncall_users):
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
shift_start_time = today - timezone.timedelta(hours=1)
on_call_shift = make_on_call_shift(
organization=organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
start=shift_start_time,
rotation_start=shift_start_time,
duration=timezone.timedelta(seconds=(24 * 60 * 60)),
priority_level=1,
frequency=CustomOnCallShift.FREQUENCY_DAILY,
schedule=schedule,
)
on_call_shift.add_rolling_users([oncall_users])
return schedule
def _test_setup():
organization = make_organization()
users = [make_user_for_organization(organization) for _ in range(6)]
schedule1 = _make_schedule_with_oncall_users(organization, users[:2])
schedule2 = _make_schedule_with_oncall_users(organization, users[2:4])
schedule3 = _make_schedule_with_oncall_users(organization, users[4:])
return users, (schedule1, schedule2, schedule3)
def _generate_cache_key(schedule):
patch redis cluster multi-key operations (#3496) # Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/2363 Addresses this issue that arises when using `cache.get_many`/`cache.set_many` operations with a Redis Cluster: ```python3 File "/usr/local/lib/python3.11/site-packages/redis/cluster.py", line 1006, in determine_slot raise RedisClusterException( redis.exceptions.RedisClusterException: MGET - all keys must map to the same key slot ``` From the Redis Cluster [docs](https://redis.io/docs/reference/cluster-spec/#hash-tags), this can be addressed with this 👇 . Basically this will ensure that keys in multi-key operations will resolve to the same hash slot (read: node): > Hash tags > There is an exception for the computation of the hash slot that is used in order to implement hash tags. Hash tags are a way to ensure that multiple keys are allocated in the same hash slot. This is used in order to implement multi-key operations in Redis Cluster. > > To implement hash tags, the hash slot for a key is computed in a slightly different way in certain conditions. If the key contains a "{...}" pattern only the substring between { and } is hashed in order to obtain the hash slot. However since it is possible that there are multiple occurrences of { or } the algorithm is well specified by the following rules: > > IF the key contains a { character. > AND IF there is a } character to the right of {. > AND IF there are one or more characters between the first occurrence of { and the first occurrence of }. > Then instead of hashing the key, only what is between the first occurrence of { and the following first occurrence of } is hashed. ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
2023-12-04 13:08:57 -05:00
return f"schedule_oncall_users_{schedule.public_primary_key}"
# scenario: nothing is cached, need to recalculate everything and cache it
users, schedules = _test_setup()
schedule1, schedule2, schedule3 = schedules
cache.clear()
results = get_cached_oncall_users_for_multiple_schedules(schedules)
assert results == {
schedule1: [users[0], users[1]],
schedule2: [users[2], users[3]],
schedule3: [users[4], users[5]],
}
cached_data = cache.get_many([_generate_cache_key(s) for s in schedules])
assert cached_data == {
_generate_cache_key(schedule1): [users[0].public_primary_key, users[1].public_primary_key],
_generate_cache_key(schedule2): [users[2].public_primary_key, users[3].public_primary_key],
_generate_cache_key(schedule3): [users[4].public_primary_key, users[5].public_primary_key],
}
# scenario: schedule1 is cached, need to recalculate schedule2 and schedule3 and cache them
users, schedules = _test_setup()
schedule1, schedule2, schedule3 = schedules
cache.clear()
cache.set(_generate_cache_key(schedule1), [users[0].public_primary_key, users[1].public_primary_key])
with patch(
"apps.schedules.ical_utils.get_oncall_users_for_multiple_schedules",
wraps=get_oncall_users_for_multiple_schedules,
) as spy_get_oncall_users_for_multiple_schedules:
results = get_cached_oncall_users_for_multiple_schedules(schedules)
# make sure we're only calling the actual method for the uncached schedules
spy_get_oncall_users_for_multiple_schedules.assert_called_once_with([schedule2, schedule3])
assert results == {
schedule1: [users[0], users[1]],
schedule2: [users[2], users[3]],
schedule3: [users[4], users[5]],
}
cached_data = cache.get_many([_generate_cache_key(s) for s in schedules])
assert cached_data == {
_generate_cache_key(schedule1): [users[0].public_primary_key, users[1].public_primary_key],
_generate_cache_key(schedule2): [users[2].public_primary_key, users[3].public_primary_key],
_generate_cache_key(schedule3): [users[4].public_primary_key, users[5].public_primary_key],
}
# scenario: everything is already cached
users, schedules = _test_setup()
schedule1, schedule2, schedule3 = schedules
cache.clear()
cache.set_many(
{
_generate_cache_key(schedule1): [users[0].public_primary_key, users[1].public_primary_key],
_generate_cache_key(schedule2): [users[2].public_primary_key, users[3].public_primary_key],
_generate_cache_key(schedule3): [users[4].public_primary_key, users[5].public_primary_key],
}
)
with patch(
"apps.schedules.ical_utils.get_oncall_users_for_multiple_schedules",
wraps=get_oncall_users_for_multiple_schedules,
) as spy_get_oncall_users_for_multiple_schedules:
results = get_cached_oncall_users_for_multiple_schedules(schedules)
# make sure we're not recalculating results because everything is already cached
spy_get_oncall_users_for_multiple_schedules.assert_called_once_with([])
assert results == {
schedule1: [users[0], users[1]],
schedule2: [users[2], users[3]],
schedule3: [users[4], users[5]],
}
cached_data = cache.get_many([_generate_cache_key(s) for s in schedules])
assert cached_data == {
_generate_cache_key(schedule1): [users[0].public_primary_key, users[1].public_primary_key],
_generate_cache_key(schedule2): [users[2].public_primary_key, users[3].public_primary_key],
_generate_cache_key(schedule3): [users[4].public_primary_key, users[5].public_primary_key],
}
# scenario: user is deleted but still in cache (all schedules are still in cache)
users[0].delete()
results = get_cached_oncall_users_for_multiple_schedules(schedules)
assert results == {
schedule1: [users[1]],
schedule2: [users[2], users[3]],
schedule3: [users[4], users[5]],
}
# scenario: schedule is deleted but still in cache
schedule1.delete()
results = get_cached_oncall_users_for_multiple_schedules(schedules)
assert results == {
schedule2: [users[2], users[3]],
schedule3: [users[4], users[5]],
}