Rework schedule related users, add endpoint (#1828)
Related to https://github.com/grafana/oncall/issues/1820.
This commit is contained in:
parent
52ff041066
commit
06b6c856d7
6 changed files with 113 additions and 24 deletions
|
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Add 2, 3 and 6 hours silence options
|
- Add 2, 3 and 6 hours silence options
|
||||||
|
- Add schedule related users endpoint to plugin API
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,27 @@ class UserHiddenFieldsSerializer(UserSerializer):
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleUserSerializer(UserSerializer):
|
||||||
|
fields_to_keep = [
|
||||||
|
"pk",
|
||||||
|
"organization",
|
||||||
|
"email",
|
||||||
|
"username",
|
||||||
|
"name",
|
||||||
|
"avatar",
|
||||||
|
"avatar_full",
|
||||||
|
"timezone",
|
||||||
|
"working_hours",
|
||||||
|
"slack_user_identity",
|
||||||
|
"telegram_configuration",
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
serialized = super(UserSerializer, self).to_representation(instance)
|
||||||
|
ret = {field: value for field, value in serialized.items() if field in self.fields_to_keep}
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
class FastUserSerializer(serializers.ModelSerializer):
|
class FastUserSerializer(serializers.ModelSerializer):
|
||||||
pk = serializers.CharField(source="public_primary_key")
|
pk = serializers.CharField(source="public_primary_key")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.alerts.models import EscalationPolicy
|
from apps.alerts.models import EscalationPolicy
|
||||||
from apps.api.permissions import LegacyAccessControlRole
|
from apps.api.permissions import LegacyAccessControlRole
|
||||||
|
from apps.api.serializers.user import ScheduleUserSerializer
|
||||||
from apps.schedules.ical_utils import memoized_users_in_ical
|
from apps.schedules.ical_utils import memoized_users_in_ical
|
||||||
from apps.schedules.models import (
|
from apps.schedules.models import (
|
||||||
CustomOnCallShift,
|
CustomOnCallShift,
|
||||||
|
|
@ -1274,6 +1275,68 @@ def test_next_shifts_per_user(
|
||||||
assert returned_data == expected
|
assert returned_data == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_related_users(
|
||||||
|
make_organization_and_user_with_plugin_token,
|
||||||
|
make_user_for_organization,
|
||||||
|
make_user_auth_headers,
|
||||||
|
make_schedule,
|
||||||
|
make_on_call_shift,
|
||||||
|
):
|
||||||
|
organization, admin, token = make_organization_and_user_with_plugin_token()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
schedule = make_schedule(
|
||||||
|
organization,
|
||||||
|
schedule_class=OnCallScheduleWeb,
|
||||||
|
name="test_web_schedule",
|
||||||
|
)
|
||||||
|
|
||||||
|
tomorrow = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + timezone.timedelta(days=1)
|
||||||
|
user_a, user_b, user_c, _ = (make_user_for_organization(organization, username=i) for i in "ABCD")
|
||||||
|
# clear users pks <-> organization cache (persisting between tests)
|
||||||
|
memoized_users_in_ical.cache_clear()
|
||||||
|
|
||||||
|
shifts = (
|
||||||
|
# user, priority, start time (h), duration (hs)
|
||||||
|
(user_a, 1, 8, 2), # r1-1: 8-10 / A
|
||||||
|
(user_b, 2, 16, 2), # r2-2: 16-18 / B
|
||||||
|
)
|
||||||
|
for user, priority, start_h, duration in shifts:
|
||||||
|
data = {
|
||||||
|
"start": tomorrow + timezone.timedelta(hours=start_h),
|
||||||
|
"rotation_start": tomorrow + timezone.timedelta(hours=start_h),
|
||||||
|
"duration": timezone.timedelta(hours=duration),
|
||||||
|
"priority_level": priority,
|
||||||
|
"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]])
|
||||||
|
|
||||||
|
# override: 17-18 / C
|
||||||
|
override_data = {
|
||||||
|
"start": tomorrow + timezone.timedelta(hours=17),
|
||||||
|
"rotation_start": tomorrow + timezone.timedelta(hours=17),
|
||||||
|
"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([[user_c]])
|
||||||
|
schedule.refresh_ical_file()
|
||||||
|
|
||||||
|
url = reverse("api-internal:schedule-related-users", kwargs={"pk": schedule.public_primary_key})
|
||||||
|
response = client.get(url, format="json", **make_user_auth_headers(admin, token))
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
|
||||||
|
expected = [ScheduleUserSerializer(u).data for u in (user_a, user_b, user_c)]
|
||||||
|
assert sorted(response.data["users"], key=lambda u: u["username"]) == sorted(expected, key=lambda u: u["username"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_related_escalation_chains(
|
def test_related_escalation_chains(
|
||||||
make_organization_and_user_with_plugin_token,
|
make_organization_and_user_with_plugin_token,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from apps.api.serializers.schedule_polymorphic import (
|
||||||
PolymorphicScheduleSerializer,
|
PolymorphicScheduleSerializer,
|
||||||
PolymorphicScheduleUpdateSerializer,
|
PolymorphicScheduleUpdateSerializer,
|
||||||
)
|
)
|
||||||
|
from apps.api.serializers.user import ScheduleUserSerializer
|
||||||
from apps.auth_token.auth import PluginAuthentication
|
from apps.auth_token.auth import PluginAuthentication
|
||||||
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
|
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
|
||||||
from apps.auth_token.models import ScheduleExportAuthToken
|
from apps.auth_token.models import ScheduleExportAuthToken
|
||||||
|
|
@ -80,6 +81,7 @@ class ScheduleView(
|
||||||
"events": [RBACPermission.Permissions.SCHEDULES_READ],
|
"events": [RBACPermission.Permissions.SCHEDULES_READ],
|
||||||
"filter_events": [RBACPermission.Permissions.SCHEDULES_READ],
|
"filter_events": [RBACPermission.Permissions.SCHEDULES_READ],
|
||||||
"next_shifts_per_user": [RBACPermission.Permissions.SCHEDULES_READ],
|
"next_shifts_per_user": [RBACPermission.Permissions.SCHEDULES_READ],
|
||||||
|
"related_users": [RBACPermission.Permissions.SCHEDULES_READ],
|
||||||
"quality": [RBACPermission.Permissions.SCHEDULES_READ],
|
"quality": [RBACPermission.Permissions.SCHEDULES_READ],
|
||||||
"notify_empty_oncall_options": [RBACPermission.Permissions.SCHEDULES_READ],
|
"notify_empty_oncall_options": [RBACPermission.Permissions.SCHEDULES_READ],
|
||||||
"notify_oncall_shift_freq_options": [RBACPermission.Permissions.SCHEDULES_READ],
|
"notify_oncall_shift_freq_options": [RBACPermission.Permissions.SCHEDULES_READ],
|
||||||
|
|
@ -336,7 +338,7 @@ class ScheduleView(
|
||||||
schedule = self.get_object()
|
schedule = self.get_object()
|
||||||
events = schedule.final_events(user_tz, starting_date, days=30)
|
events = schedule.final_events(user_tz, starting_date, days=30)
|
||||||
|
|
||||||
users = {u: None for u in schedule.related_users()}
|
users = {u.public_primary_key: None for u in schedule.related_users()}
|
||||||
for e in events:
|
for e in events:
|
||||||
user = e["users"][0]["pk"] if e["users"] else None
|
user = e["users"][0]["pk"] if e["users"] else None
|
||||||
if user is not None and users.get(user) is None and e["end"] > now:
|
if user is not None and users.get(user) is None and e["end"] > now:
|
||||||
|
|
@ -345,6 +347,13 @@ class ScheduleView(
|
||||||
result = {"users": users}
|
result = {"users": users}
|
||||||
return Response(result, status=status.HTTP_200_OK)
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def related_users(self, request, pk):
|
||||||
|
schedule = self.get_object()
|
||||||
|
serializer = ScheduleUserSerializer(schedule.related_users(), many=True)
|
||||||
|
result = {"users": serializer.data}
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
@action(detail=True, methods=["get"])
|
||||||
def related_escalation_chains(self, request, pk):
|
def related_escalation_chains(self, request, pk):
|
||||||
"""Return escalation chains associated to schedule."""
|
"""Return escalation chains associated to schedule."""
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
|
||||||
import itertools
|
import itertools
|
||||||
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Iterable, Optional, TypedDict
|
from typing import Iterable, Optional, TypedDict
|
||||||
|
|
@ -45,6 +45,9 @@ from apps.user_management.models import User
|
||||||
from common.database import NON_POLYMORPHIC_CASCADE, NON_POLYMORPHIC_SET_NULL
|
from common.database import NON_POLYMORPHIC_CASCADE, NON_POLYMORPHIC_SET_NULL
|
||||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||||
|
|
||||||
|
RE_ICAL_SEARCH_USERNAME = r"SUMMARY:(\[L[0-9]+\] )?{}"
|
||||||
|
RE_ICAL_FETCH_USERNAME = re.compile(r"SUMMARY:(?:\[L[0-9]+\] )?(\w+)")
|
||||||
|
|
||||||
|
|
||||||
# Utility classes for schedule quality report
|
# Utility classes for schedule quality report
|
||||||
class QualityReportCommentType(str, Enum):
|
class QualityReportCommentType(str, Enum):
|
||||||
|
|
@ -88,7 +91,7 @@ class OnCallScheduleQuerySet(PolymorphicQuerySet):
|
||||||
return get_oncall_users_for_multiple_schedules(self, events_datetime)
|
return get_oncall_users_for_multiple_schedules(self, events_datetime)
|
||||||
|
|
||||||
def related_to_user(self, user):
|
def related_to_user(self, user):
|
||||||
username_regex = r"SUMMARY:(\[L[0-9]+\] )?{}".format(user.username)
|
username_regex = RE_ICAL_SEARCH_USERNAME.format(user.username)
|
||||||
return self.filter(
|
return self.filter(
|
||||||
Q(cached_ical_file_primary__regex=username_regex)
|
Q(cached_ical_file_primary__regex=username_regex)
|
||||||
| Q(cached_ical_file_primary__contains=user.email)
|
| Q(cached_ical_file_primary__contains=user.email)
|
||||||
|
|
@ -244,8 +247,13 @@ class OnCallSchedule(PolymorphicModel):
|
||||||
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
|
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
|
||||||
|
|
||||||
def related_users(self):
|
def related_users(self):
|
||||||
"""Return public primary keys for all users referenced in the schedule."""
|
"""Return users referenced in the schedule."""
|
||||||
return set()
|
usernames = []
|
||||||
|
if self.cached_ical_file_primary:
|
||||||
|
usernames += RE_ICAL_FETCH_USERNAME.findall(self.cached_ical_file_primary)
|
||||||
|
if self.cached_ical_file_overrides:
|
||||||
|
usernames += RE_ICAL_FETCH_USERNAME.findall(self.cached_ical_file_overrides)
|
||||||
|
return self.organization.users.filter(username__in=usernames)
|
||||||
|
|
||||||
def filter_events(
|
def filter_events(
|
||||||
self,
|
self,
|
||||||
|
|
@ -981,22 +989,6 @@ class OnCallScheduleWeb(OnCallSchedule):
|
||||||
self.cached_ical_file_overrides = self._generate_ical_file_overrides()
|
self.cached_ical_file_overrides = self._generate_ical_file_overrides()
|
||||||
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
|
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
|
||||||
|
|
||||||
def related_users(self):
|
|
||||||
"""Return public primary keys for all users referenced in the schedule."""
|
|
||||||
rolling_users = self.custom_shifts.values_list("rolling_users", flat=True)
|
|
||||||
users = functools.reduce(
|
|
||||||
set.union,
|
|
||||||
(
|
|
||||||
set(g.values())
|
|
||||||
for rolling_groups in rolling_users
|
|
||||||
if rolling_groups is not None
|
|
||||||
for g in rolling_groups
|
|
||||||
if g is not None
|
|
||||||
),
|
|
||||||
set(),
|
|
||||||
)
|
|
||||||
return users
|
|
||||||
|
|
||||||
# Insight logs
|
# Insight logs
|
||||||
@property
|
@property
|
||||||
def insight_logs_type_verbal(self):
|
def insight_logs_type_verbal(self):
|
||||||
|
|
|
||||||
|
|
@ -894,9 +894,10 @@ def test_schedule_related_users_empty_schedule(make_organization, make_schedule)
|
||||||
schedule_class=OnCallScheduleWeb,
|
schedule_class=OnCallScheduleWeb,
|
||||||
name="test_web_schedule",
|
name="test_web_schedule",
|
||||||
)
|
)
|
||||||
|
schedule.refresh_ical_file()
|
||||||
|
|
||||||
users = schedule.related_users()
|
users = schedule.related_users()
|
||||||
assert users == set()
|
assert set(users) == set()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|
@ -930,7 +931,7 @@ def test_schedule_related_users(make_organization, make_user_for_organization, m
|
||||||
"schedule": schedule,
|
"schedule": schedule,
|
||||||
}
|
}
|
||||||
on_call_shift = make_on_call_shift(
|
on_call_shift = make_on_call_shift(
|
||||||
organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data
|
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||||
)
|
)
|
||||||
on_call_shift.add_rolling_users([[user]])
|
on_call_shift.add_rolling_users([[user]])
|
||||||
|
|
||||||
|
|
@ -946,9 +947,11 @@ def test_schedule_related_users(make_organization, make_user_for_organization, m
|
||||||
)
|
)
|
||||||
override.add_rolling_users([[user_e]])
|
override.add_rolling_users([[user_e]])
|
||||||
|
|
||||||
|
schedule.refresh_ical_file()
|
||||||
schedule.refresh_from_db()
|
schedule.refresh_from_db()
|
||||||
|
|
||||||
users = schedule.related_users()
|
users = schedule.related_users()
|
||||||
assert users == set(u.public_primary_key for u in [user_a, user_d, user_e])
|
assert set(users) == set([user_a, user_d, user_e])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db(transaction=True)
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue