Google Calendar Out of Office events - autogenerated shift swap requests (#4104)

# What this PR does

## Which issue(s) this PR closes

Closes https://github.com/grafana/oncall-private/issues/2590

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required) - will be done in
https://github.com/grafana/oncall-private/issues/2591
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
show up in the autogenerated release notes. - will be done in
https://github.com/grafana/oncall-private/issues/2591

---------

Co-authored-by: Dominik <dominik.broj@grafana.com>
Co-authored-by: Maxim Mordasov <maxim.mordasov@grafana.com>
This commit is contained in:
Joey Orlando 2024-04-02 16:10:16 -04:00 committed by GitHub
parent 59f727d4f5
commit 33364b63c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 554 additions and 53 deletions

View file

@ -176,8 +176,8 @@ def test_retrieve_permissions(
assert response.status_code == expected_status
@patch("apps.api.views.shift_swap.write_resource_insight_log")
@patch("apps.api.views.shift_swap.create_shift_swap_request_message")
@patch("apps.schedules.models.shift_swap_request.write_resource_insight_log")
@patch("apps.schedules.tasks.shift_swaps.create_shift_swap_request_message")
@pytest.mark.django_db
def test_create(
mock_create_shift_swap_request_message,

View file

@ -18,7 +18,7 @@ from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.schedules import exceptions
from apps.schedules.models import ShiftSwapRequest
from apps.schedules.tasks.shift_swaps import create_shift_swap_request_message, update_shift_swap_request_message
from apps.schedules.tasks.shift_swaps import update_shift_swap_request_message
from apps.user_management.models import User
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import PublicPrimaryKeyMixin
@ -33,13 +33,6 @@ class BaseShiftSwapViewSet(ModelViewSet):
serializer_class = ShiftSwapRequestSerializer
pagination_class = FiftyPageSizePaginator
def _do_create(self, beneficiary: User, serializer: BaseSerializer[ShiftSwapRequest]) -> None:
shift_swap_request = serializer.save(beneficiary=beneficiary)
write_resource_insight_log(instance=shift_swap_request, author=self.request.user, event=EntityEvent.CREATED)
create_shift_swap_request_message.apply_async((shift_swap_request.pk,))
def _do_take(self, benefactor: User) -> dict:
shift_swap = self.get_object()
prev_state = shift_swap.insight_logs_serialized
@ -83,7 +76,7 @@ class BaseShiftSwapViewSet(ModelViewSet):
def perform_create(self, serializer: BaseSerializer[ShiftSwapRequest]) -> None:
# default to create swap request with logged in user as beneficiary
self._do_create(self.request.user, serializer=serializer)
serializer.save(beneficiary=self.request.user)
def perform_update(self, serializer: BaseSerializer[ShiftSwapRequest]) -> None:
prev_state = serializer.instance.insight_logs_serialized

View file

@ -6,12 +6,23 @@ from django.conf import settings
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from apps.google.types import GoogleCalendarEvent
from apps.google import constants, utils
from apps.google.types import GoogleCalendarEvent as GoogleCalendarEventType
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class GoogleCalendarEvent:
def __init__(self, event: GoogleCalendarEventType):
self.raw_event = event
self._start_time = utils.datetime_strptime(event["start"]["dateTime"])
self._end_time = utils.datetime_strptime(event["end"]["dateTime"])
self.start_time_utc = self._start_time.astimezone(datetime.timezone.utc)
self.end_time_utc = self._end_time.astimezone(datetime.timezone.utc)
class GoogleCalendarAPIClient:
MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH = 250
"""
@ -46,26 +57,23 @@ class GoogleCalendarAPIClient:
"""
https://developers.google.com/calendar/api/v3/reference/events/list
"""
def _format_datetime_arg(dt: datetime.datetime) -> str:
"""
https://stackoverflow.com/a/17159470/3902555
"""
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
now = _format_datetime_arg(datetime.datetime.now(datetime.UTC))
logger.info(
f"GoogleCalendarAPIClient - Getting the upcoming {self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH} "
"out of office events"
)
now = datetime.datetime.now(datetime.UTC)
time_min = utils.datetime_strftime(now)
time_max = utils.datetime_strftime(
now + datetime.timedelta(days=constants.DAYS_IN_FUTURE_TO_CONSIDER_OUT_OF_OFFICE_EVENTS)
)
events_result = (
self.service.events()
.list(
calendarId=self.CALENDAR_ID,
timeMin=now,
# timeMax= TODO: should we only fetch out of office events for next X amount of time?
timeMin=time_min,
timeMax=time_max,
maxResults=self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH,
singleEvents=True,
orderBy="startTime",
@ -73,4 +81,4 @@ class GoogleCalendarAPIClient:
)
.execute()
)
return events_result.get("items", [])
return [GoogleCalendarEvent(event) for event in events_result.get("items", [])]

View file

@ -0,0 +1,6 @@
GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
"""
https://stackoverflow.com/a/17159470/3902555
"""
DAYS_IN_FUTURE_TO_CONSIDER_OUT_OF_OFFICE_EVENTS = 30

View file

@ -1,8 +1,15 @@
import typing
from django.db import models
from mirage import fields as mirage_fields
if typing.TYPE_CHECKING:
from apps.user_management.models import User
class GoogleOAuth2User(models.Model):
user: "User"
id = models.AutoField(primary_key=True)
user = models.OneToOneField(
to="user_management.User", null=False, blank=False, related_name="google_oauth2_user", on_delete=models.CASCADE

View file

@ -4,6 +4,7 @@ from celery.utils.log import get_task_logger
from apps.google.client import GoogleCalendarAPIClient
from apps.google.models import GoogleOAuth2User
from apps.schedules.models import OnCallSchedule, ShiftSwapRequest
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
logger = get_task_logger(__name__)
@ -15,9 +16,69 @@ def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> N
google_oauth2_user = GoogleOAuth2User.objects.get(pk=google_oauth2_user_pk)
google_api_client = GoogleCalendarAPIClient(google_oauth2_user.access_token, google_oauth2_user.refresh_token)
# NOTE: shift swap request generation will be done in https://github.com/grafana/oncall-private/issues/2590
# QUESTION: will we need to persist any information about these calendar events in our database?
google_api_client.fetch_out_of_office_events()
user = google_oauth2_user.user
user_id = user.pk
logger.info(f"Syncing out of office Google Calendar events for user {user_id}")
users_schedules = OnCallSchedule.objects.related_to_user(user)
user_google_calendar_settings = user.google_calendar_settings
oncall_schedules_to_consider_for_shift_swaps = user_google_calendar_settings[
"oncall_schedules_to_consider_for_shift_swaps"
]
if oncall_schedules_to_consider_for_shift_swaps:
users_schedules = users_schedules.filter(public_primary_key__in=oncall_schedules_to_consider_for_shift_swaps)
for out_of_office_event in google_api_client.fetch_out_of_office_events():
event_id = out_of_office_event.raw_event["id"]
start_time_utc = out_of_office_event.start_time_utc
end_time_utc = out_of_office_event.end_time_utc
logger.info(
f"Processing out of office event {event_id} starting at {start_time_utc} and ending at "
f"{end_time_utc} for user {user_id}"
)
for schedule in users_schedules:
_, _, upcoming_shifts = schedule.shifts_for_user(
user,
start_time_utc,
datetime_end=end_time_utc,
)
if upcoming_shifts:
logger.info(
f"Found {len(upcoming_shifts)} upcoming shift(s) for user {user_id} "
f"during the out of office event {event_id}"
)
shift_swap_request_exists = ShiftSwapRequest.objects.filter(
beneficiary=user,
schedule=schedule,
swap_start=start_time_utc,
swap_end=end_time_utc,
).exists()
if not shift_swap_request_exists:
logger.info(
f"Creating shift swap request for user {user_id} schedule {schedule.pk} "
f"due to the out of office event {event_id}"
)
ssr = ShiftSwapRequest.objects.create(
beneficiary=user,
schedule=schedule,
swap_start=start_time_utc,
swap_end=end_time_utc,
description=f"{user.name or user.email} will be out of office during this time according to Google Calendar",
)
logger.info(f"Created shift swap request {ssr.pk}")
else:
logger.info(f"Shift swap request already exists for user {user_id} schedule {schedule.pk}")
else:
logger.info(f"No upcoming shifts found for user {user_id} during the out of office event {event_id}")
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True)

View file

View file

@ -0,0 +1,14 @@
import factory
from apps.google.models import GoogleOAuth2User
from common.utils import UniqueFaker
class GoogleOAuth2UserFactory(factory.DjangoModelFactory):
google_user_id = UniqueFaker("pyint")
access_token = factory.Faker("password")
refresh_token = factory.Faker("password")
oauth_scope = factory.Faker("word")
class Meta:
model = GoogleOAuth2User

View file

@ -0,0 +1,291 @@
import datetime
from unittest.mock import patch
import pytest
from django.utils import timezone
from apps.google import constants, tasks
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb, ShiftSwapRequest
def _create_mock_google_calendar_event(start_time: datetime.datetime, end_time: datetime.datetime):
return {
"colorId": "4",
"created": "2024-03-22T23:06:39.000Z",
"creator": {
"email": "joey.orlando@grafana.com",
"self": True,
},
"end": {
"dateTime": end_time.strftime(constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT),
"timeZone": "America/New_York",
},
"etag": "3422297608598000",
"eventType": "outOfOffice",
"extendedProperties": {
"private": {
"reclaim.event.category": "VACATION",
"reclaim.priority.index": "3",
"reclaim.project.id": "NULL",
"reclaim.touched": "true",
},
},
"htmlLink": "https://www.google.com/calendar/event?eid=NDlyZGVmNHU2aTVkaDR1aWFycGZqYWoya3Qgam9leS5vcmxhbmRvQGdyYWZhbmEuY29t",
"iCalUID": "49rdef4u6i5dh4uiarpfjaj2kt@google.com",
"id": "49rdef4u6i5dh4uiarpfjaj2kt",
"kind": "calendar#event",
"organizer": {
"email": "joey.orlando@grafana.com",
"self": True,
},
"outOfOfficeProperties": {
"autoDeclineMode": "declineNone",
},
"reminders": {
"useDefault": False,
},
"sequence": 0,
"start": {
"dateTime": start_time.strftime(constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT),
"timeZone": "America/New_York",
},
"status": "confirmed",
"summary": "Out of office",
"updated": "2024-03-22T23:06:44.299Z",
"visibility": "public",
}
def _create_event_start_and_end_times(start_days_in_future=5, end_time_minutes_past_start=50):
start_time = (
datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=start_days_in_future)
).replace(second=0, microsecond=0)
end_time = start_time + datetime.timedelta(minutes=end_time_minutes_past_start)
return start_time, end_time
@pytest.fixture
def make_schedule_with_on_call_shift(make_schedule, make_on_call_shift):
def _make_schedule_with_on_call_shift(out_of_office_events, organization, user):
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
channel="channel",
prev_ical_file_overrides=None,
cached_ical_file_overrides=None,
)
dt_format = constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT
if out_of_office_events:
on_call_shift_start = datetime.datetime.strptime(
out_of_office_events[0]["start"]["dateTime"], dt_format
) - datetime.timedelta(days=60)
else:
on_call_shift_start = timezone.now() - datetime.timedelta(days=60)
on_call_shift = make_on_call_shift(
organization=organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
start=on_call_shift_start,
rotation_start=on_call_shift_start,
duration=datetime.timedelta(days=365),
priority_level=1,
frequency=CustomOnCallShift.FREQUENCY_DAILY,
schedule=schedule,
)
on_call_shift.add_rolling_users([[user]])
schedule.refresh_ical_file()
schedule.refresh_ical_final_schedule()
return schedule
return _make_schedule_with_on_call_shift
@pytest.fixture
def test_setup(
make_organization,
make_user_for_organization,
make_google_oauth2_user_for_user,
make_schedule_with_on_call_shift,
):
def _test_setup(out_of_office_events):
organization = make_organization()
user_name = "Bob Smith"
user = make_user_for_organization(
organization,
# normally this 👇 is done via User.finish_google_oauth2_connection_flow.. but since we're creating
# the user via a fixture we need to manually add this
google_calendar_settings={
"oncall_schedules_to_consider_for_shift_swaps": [],
},
name=user_name,
)
google_oauth2_user = make_google_oauth2_user_for_user(user)
schedule = make_schedule_with_on_call_shift(out_of_office_events, organization, user)
return google_oauth2_user, schedule
return _test_setup
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_no_ooo_events(mock_google_api_client_build, test_setup):
out_of_office_events = []
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule = test_setup(out_of_office_events)
user = google_oauth2_user.user
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 0
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_single_ooo_event(mock_google_api_client_build, test_setup):
start_time, end_time = _create_event_start_and_end_times()
out_of_office_events = [
_create_mock_google_calendar_event(start_time, end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule = test_setup(out_of_office_events)
user = google_oauth2_user.user
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
ssrs = ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule)
ssr = ssrs.first()
assert ssrs.count() == 1
assert ssr.swap_start == start_time
assert ssr.swap_end == end_time
assert ssr.description == f"{user.name} will be out of office during this time according to Google Calendar"
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_multiple_ooo_events(mock_google_api_client_build, test_setup):
# partial day out of office event
event1_start_time, event1_end_time = _create_event_start_and_end_times()
# all day out of office event
event2_start_time, event2_end_time = _create_event_start_and_end_times(6, 24 * 60)
out_of_office_events = [
_create_mock_google_calendar_event(event1_start_time, event1_end_time),
_create_mock_google_calendar_event(event2_start_time, event2_end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule = test_setup(out_of_office_events)
user = google_oauth2_user.user
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 2
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_oncall_schedules_to_consider_for_shift_swaps_setting(
mock_google_api_client_build,
test_setup,
make_schedule_with_on_call_shift,
):
start_time, end_time = _create_event_start_and_end_times()
out_of_office_events = [
_create_mock_google_calendar_event(start_time, end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule1 = test_setup(out_of_office_events)
user = google_oauth2_user.user
make_schedule_with_on_call_shift(out_of_office_events, schedule1.organization, user)
user.google_calendar_settings = {
"oncall_schedules_to_consider_for_shift_swaps": [schedule1.public_primary_key],
}
user.save()
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
assert ShiftSwapRequest.objects.filter(beneficiary=user).count() == 1
ssr = ShiftSwapRequest.objects.first()
assert ssr.schedule == schedule1
@patch("apps.google.tasks.OnCallSchedule.shifts_for_user", return_value=([], [], []))
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_no_upcoming_shifts(
mock_google_api_client_build,
_mock_schedule_shifts_for_user,
test_setup,
):
start_time, end_time = _create_event_start_and_end_times()
out_of_office_events = [
_create_mock_google_calendar_event(start_time, end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, _ = test_setup(out_of_office_events)
user = google_oauth2_user.user
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
assert ShiftSwapRequest.objects.filter(beneficiary=user).count() == 0
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_preexisting_shift_swap_request(
mock_google_api_client_build,
test_setup,
make_shift_swap_request,
):
start_time, end_time = _create_event_start_and_end_times()
out_of_office_events = [
_create_mock_google_calendar_event(start_time, end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule = test_setup(out_of_office_events)
user = google_oauth2_user.user
make_shift_swap_request(
schedule,
user,
swap_start=start_time,
swap_end=end_time,
)
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
# should be 1 because we just created a shift swap request above via the fixture
assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 1

View file

@ -2,10 +2,15 @@ import typing
class GoogleCalendarEventDate(typing.TypedDict):
date: typing.NotRequired[str]
"""
The date, in the format "yyyy-mm-dd", if this is an all-day event.
"""
# NOTE: in reality I haven't seen this field returned, even despite creating
# an out of office event with the "All day" checkbox checked. Instead it looks
# like it just returns the start.dateTime and end.dateTime as midnight of the
# respective days
# date: typing.NotRequired[str]
# """
# The date, in the format "yyyy-mm-dd", if this is an all-day event.
# """
dateTime: typing.NotRequired[str]
"""

View file

@ -0,0 +1,11 @@
import datetime
from apps.google import constants
def datetime_strftime(dt: datetime.datetime) -> str:
return dt.strftime(constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT)
def datetime_strptime(dt: str) -> datetime.datetime:
return datetime.datetime.strptime(dt, constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT)

View file

@ -112,8 +112,8 @@ def test_list_filters(
assert_expected(response, (swap4,))
@patch("apps.api.views.shift_swap.write_resource_insight_log")
@patch("apps.api.views.shift_swap.create_shift_swap_request_message")
@patch("apps.schedules.models.shift_swap_request.write_resource_insight_log")
@patch("apps.schedules.tasks.shift_swaps.create_shift_swap_request_message")
@pytest.mark.django_db
def test_create(
mock_create_shift_swap_request_message,
@ -122,7 +122,7 @@ def test_create(
make_user_for_organization,
make_schedule,
):
organization, user, token = make_organization_and_user_with_token()
organization, _, token = make_organization_and_user_with_token()
another_user = make_user_for_organization(organization)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
@ -145,7 +145,9 @@ def test_create(
assert_swap_response(response, data)
ssr = ShiftSwapRequest.objects.get(public_primary_key=response.json()["id"])
mock_write_resource_insight_log.assert_called_once_with(instance=ssr, author=user, event=EntityEvent.CREATED)
mock_write_resource_insight_log.assert_called_once_with(
instance=ssr, author=another_user, event=EntityEvent.CREATED
)
mock_create_shift_swap_request_message.apply_async.assert_called_once_with((ssr.pk,))

View file

@ -85,8 +85,7 @@ class ShiftSwapViewSet(RateLimitHeadersMixin, BaseShiftSwapViewSet):
return user
def perform_create(self, serializer: BaseSerializer[ShiftSwapRequest]) -> None:
beneficiary = self._get_user("beneficiary")
self._do_create(beneficiary=beneficiary, serializer=serializer)
serializer.save(beneficiary=self._get_user("beneficiary"))
@action(methods=["post"], detail=True)
def take(self, request: AuthenticatedRequest, pk: str) -> Response:

View file

@ -542,10 +542,23 @@ class OnCallSchedule(PolymorphicModel):
self.save(update_fields=["cached_ical_final_schedule"])
def shifts_for_user(
self, user: User, datetime_start: datetime.datetime, days: int = 7
self,
user: User,
datetime_start: datetime.datetime,
datetime_end: typing.Optional[datetime.datetime] = None,
days: typing.Optional[int] = None,
) -> typing.Tuple[ScheduleEvents, ScheduleEvents, ScheduleEvents]:
"""
NOTE: must specify at least `datetime_end` or `days`
"""
if not datetime_end and not days:
raise ValueError("Must specify at least `datetime_end` or `days`")
now = timezone.now()
datetime_end = datetime_start + datetime.timedelta(days=days)
if days is not None:
datetime_end = datetime_start + datetime.timedelta(days=days)
passed_shifts: ScheduleEvents = []
current_shifts: ScheduleEvents = []
upcoming_shifts: ScheduleEvents = []

View file

@ -5,10 +5,13 @@ from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import QuerySet
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from apps.schedules import exceptions
from apps.schedules.tasks import refresh_ical_final_schedule
from common.insight_log import EntityEvent, write_resource_insight_log
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
if typing.TYPE_CHECKING:
@ -256,3 +259,14 @@ class ShiftSwapRequest(models.Model):
result["schedule"] = self.schedule.insight_logs_verbal
result["schedule_id"] = self.schedule.public_primary_key
return result
@receiver(post_save, sender=ShiftSwapRequest)
def listen_for_shiftswaprequest_model_save(
sender: ShiftSwapRequest, instance: ShiftSwapRequest, created: bool, *args, **kwargs
) -> None:
from apps.schedules.tasks.shift_swaps import create_shift_swap_request_message
if created:
write_resource_insight_log(instance=instance, author=instance.beneficiary, event=EntityEvent.CREATED)
create_shift_swap_request_message.apply_async((instance.pk,))

View file

@ -2663,7 +2663,7 @@ def test_shifts_for_user(
schedule.refresh_ical_file()
schedule.refresh_ical_final_schedule()
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(admin, now)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(admin, now, days=7)
assert len(passed_shifts) == 0
assert len(current_shifts) == 1
assert len(upcoming_shifts) == 7
@ -2678,7 +2678,7 @@ def test_shifts_for_user(
users = {u["pk"] for u in shift["users"]}
assert admin.public_primary_key in users
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(other_user, now)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(other_user, now, days=7)
assert len(passed_shifts) == 0
assert len(current_shifts) == 0
assert len(upcoming_shifts) == 0
@ -2731,7 +2731,7 @@ def test_shifts_for_user_only_two_users_with_shifts(
schedule.refresh_ical_final_schedule()
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days=days)
assert len(passed_shifts) == 0
assert len(current_shifts) == 0
assert len(upcoming_shifts) == 4
@ -2740,7 +2740,7 @@ def test_shifts_for_user_only_two_users_with_shifts(
assert current_user.public_primary_key in users
assert shift["start"] > now
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(user2, start_date, days)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(user2, start_date, days=days)
assert len(passed_shifts) > 0
assert len(current_shifts) > 0
assert len(upcoming_shifts) > 0
@ -2774,7 +2774,7 @@ def test_shifts_for_user_no_events(
start_date = today - timezone.timedelta(days=2)
days = 7
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days=days)
assert len(passed_shifts) == 0
assert len(current_shifts) == 0
assert len(upcoming_shifts) == 0
@ -2795,7 +2795,7 @@ def test_shifts_for_user_without_final_ical(
start_date = today - timezone.timedelta(days=2)
days = 7
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(user, start_date, days)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(user, start_date, days=days)
assert len(passed_shifts) == 0
assert len(current_shifts) == 0
assert len(upcoming_shifts) == 0

View file

@ -4,6 +4,7 @@ import pytest
from django.utils import timezone
from apps.api.permissions import LegacyAccessControlRole
from apps.google.models import GoogleOAuth2User
from apps.user_management.models import User
@ -108,3 +109,77 @@ def test_is_telegram_connected(make_organization_and_user, make_telegram_user_co
assert user.is_telegram_connected is False
make_telegram_user_connector(user)
assert user.is_telegram_connected is True
@pytest.mark.django_db
def test_has_google_oauth2_connected(make_organization_and_user, make_google_oauth2_user_for_user):
_, user = make_organization_and_user()
assert user.has_google_oauth2_connected is False
make_google_oauth2_user_for_user(user)
assert user.has_google_oauth2_connected is True
@pytest.mark.django_db
def test_finish_google_oauth2_connection_flow(make_organization_and_user):
oauth_response = {
"access_token": "access",
"refresh_token": "refresh",
"sub": "google_user_id",
"scope": "scope",
}
_, user = make_organization_and_user()
assert GoogleOAuth2User.objects.filter(user=user).exists() is False
assert user.google_calendar_settings is None
user.finish_google_oauth2_connection_flow(oauth_response)
user.refresh_from_db()
google_oauth_user = user.google_oauth2_user
assert google_oauth_user.google_user_id == "google_user_id"
assert google_oauth_user.access_token == "access"
assert google_oauth_user.refresh_token == "refresh"
assert google_oauth_user.oauth_scope == "scope"
assert user.google_calendar_settings["oncall_schedules_to_consider_for_shift_swaps"] == []
oauth_response2 = {
"access_token": "access2",
"refresh_token": "refresh2",
"sub": "google_user_id2",
"scope": "scope2",
}
user.finish_google_oauth2_connection_flow(oauth_response2)
user.refresh_from_db()
google_oauth_user = user.google_oauth2_user
assert google_oauth_user.google_user_id == "google_user_id2"
assert google_oauth_user.access_token == "access2"
assert google_oauth_user.refresh_token == "refresh2"
assert google_oauth_user.oauth_scope == "scope2"
@pytest.mark.django_db
def test_finish_google_oauth2_disconnection_flow(make_organization_and_user):
_, user = make_organization_and_user()
user.finish_google_oauth2_connection_flow(
{
"access_token": "access",
"refresh_token": "refresh",
"sub": "google_user_id",
"scope": "scope",
}
)
user.refresh_from_db()
assert user.google_oauth2_user is not None
assert user.google_calendar_settings is not None
user.finish_google_oauth2_disconnection_flow()
user.refresh_from_db()
assert GoogleOAuth2User.objects.filter(user=user).exists() is False
assert user.google_calendar_settings is None

View file

@ -62,6 +62,7 @@ from apps.base.tests.factories import (
UserNotificationPolicyLogRecordFactory,
)
from apps.email.tests.factories import EmailMessageFactory
from apps.google.tests.factories import GoogleOAuth2UserFactory
from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory
from apps.labels.tests.factories import (
AlertGroupAssociatedLabelFactory,
@ -107,8 +108,6 @@ from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID, Tes
register(OrganizationFactory)
register(UserFactory)
register(TeamFactory)
register(AlertReceiveChannelFactory)
register(AlertReceiveChannelConnectionFactory)
register(ChannelFilterFactory)
@ -123,29 +122,24 @@ register(AlertGroupLogRecordFactory)
register(InvitationFactory)
register(CustomActionFactory)
register(SlackUserGroupFactory)
register(SlackUserIdentityFactory)
register(SlackTeamIdentityFactory)
register(SlackMessageFactory)
register(TelegramToUserConnectorFactory)
register(TelegramChannelFactory)
register(TelegramVerificationCodeFactory)
register(TelegramChannelVerificationCodeFactory)
register(TelegramMessageFactory)
register(ResolutionNoteSlackMessageFactory)
register(PhoneCallRecordFactory)
register(SMSRecordFactory)
register(EmailMessageFactory)
register(IntegrationHeartBeatFactory)
register(LiveSettingFactory)
register(LabelKeyFactory)
register(LabelValueFactory)
register(AlertReceiveChannelAssociatedLabelFactory)
register(GoogleOAuth2UserFactory)
IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True"
@ -1067,3 +1061,11 @@ def make_webhook_label_association(make_label_key_and_value):
return WebhookAssociatedLabelFactory(webhook=webhook, organization=organization, key=key, value=value, **kwargs)
return _make_integration_label_association
@pytest.fixture
def make_google_oauth2_user_for_user():
def _make_google_oauth2_user_for_user(user):
return GoogleOAuth2UserFactory(user=user)
return _make_google_oauth2_user_for_user