Improve schedule quality feature (#1602)
# What this PR does Before: <img width="281" alt="Screenshot 2023-03-23 at 16 56 42" src="https://user-images.githubusercontent.com/20116910/227279464-c883ec05-a964-4360-bda2-3443409ca90a.png"> After: <img width="338" alt="Screenshot 2023-03-23 at 16 57 41" src="https://user-images.githubusercontent.com/20116910/227279476-468bffba-922a-45ea-b400-5f34d6bf0534.png"> - Add scores for overloaded users, e.g. `(+25% avg)` which means the user is scheduled to be on-call 25% more than average for given schedule. - Add score for gaps, e.g. `Schedule has gaps (29% not covered)` which means 29% of time no one is scheduled to be on-call. - Make things easier to understand when there are gaps in the schedule, add `(see overloaded users)` text. - Consider events for next 52 weeks (~1 year) instead of 90 days (~3 months), so the quality report is more accurate. Also treat any balance quality >95% as perfectly balanced. These two changes (period change and adding 95% threshold) should help eliminate false positives for _most_ schedules. - Modify backend & frontend so the backend returns all necessary user information to render without using the user store. - Move quality report generation to `OnCallSchedule` model, add more tests. ## Which issue(s) this PR fixes Related to https://github.com/grafana/oncall/issues/1552 ## Checklist - [x] Tests updated - [x] `CHANGELOG.md` updated (public docs will be added in a separate PR)
This commit is contained in:
parent
a2caeae3c7
commit
a936c8c7fe
10 changed files with 430 additions and 179 deletions
|
|
@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
using URLs without a top-level domain ([1266](https://github.com/grafana/oncall/pull/1266))
|
||||
- Updated wording when creating an integration ([1572](https://github.com/grafana/oncall/pull/1572))
|
||||
- Set FCM iOS/Android "message priority" to "high priority" for mobile app push notifications ([1612](https://github.com/grafana/oncall/pull/1612))
|
||||
- Improve schedule quality feature (by @vadimkerr in [#1602](https://github.com/grafana/oncall/pull/1602))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ from apps.auth_token.auth import PluginAuthentication
|
|||
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
|
||||
from apps.auth_token.models import ScheduleExportAuthToken
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
from apps.schedules.quality_score import get_schedule_quality_score
|
||||
from apps.slack.models import SlackChannel
|
||||
from apps.slack.tasks import update_slack_user_group_for_schedules
|
||||
from common.api_helpers.exceptions import BadRequest, Conflict
|
||||
|
|
@ -353,13 +352,12 @@ class ScheduleView(
|
|||
@action(detail=True, methods=["get"])
|
||||
def quality(self, request, pk):
|
||||
schedule = self.get_object()
|
||||
user_tz, date = self.get_request_timezone()
|
||||
days = int(self.request.query_params.get("days", 90)) # todo: check if days could be calculated more precisely
|
||||
|
||||
events = schedule.filter_events(user_tz, date, days=days, with_empty=True, with_gap=True)
|
||||
_, date = self.get_request_timezone()
|
||||
days = self.request.query_params.get("days")
|
||||
days = int(days) if days else None
|
||||
|
||||
schedule_score = get_schedule_quality_score(events, days)
|
||||
return Response(schedule_score)
|
||||
return Response(schedule.quality_report(date, days))
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def type_options(self, request):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import datetime
|
||||
import functools
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
from typing import Iterable, Optional, TypedDict
|
||||
|
||||
import icalendar
|
||||
import pytz
|
||||
|
|
@ -23,9 +26,33 @@ from apps.schedules.ical_utils import (
|
|||
list_of_oncall_shifts_from_ical,
|
||||
)
|
||||
from apps.schedules.models import CustomOnCallShift
|
||||
from apps.user_management.models import User
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
|
||||
# Utility classes for schedule quality report
|
||||
class QualityReportCommentType(str, Enum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
|
||||
|
||||
class QualityReportComment(TypedDict):
|
||||
type: QualityReportCommentType
|
||||
text: str
|
||||
|
||||
|
||||
class QualityReportOverloadedUser(TypedDict):
|
||||
id: str
|
||||
username: str
|
||||
score: int
|
||||
|
||||
|
||||
class QualityReport(TypedDict):
|
||||
total_score: int
|
||||
comments: list[QualityReportComment]
|
||||
overloaded_users: list[QualityReportOverloadedUser]
|
||||
|
||||
|
||||
def generate_public_primary_key_for_oncall_schedule_channel():
|
||||
prefix = "S"
|
||||
new_public_primary_key = generate_public_primary_key(prefix)
|
||||
|
|
@ -256,6 +283,126 @@ class OnCallSchedule(PolymorphicModel):
|
|||
events = self._resolve_schedule(events)
|
||||
return events
|
||||
|
||||
def quality_report(self, date: Optional[timezone.datetime], days: Optional[int]) -> QualityReport:
|
||||
"""
|
||||
Return schedule quality report to be used by the web UI.
|
||||
TODO: Add scores on "inside working hours" and "balance outside working hours" when
|
||||
TODO: working hours editor is implemented in the web UI.
|
||||
"""
|
||||
# get events to consider for calculation
|
||||
if date is None:
|
||||
today = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
date = today - datetime.timedelta(days=7 - today.weekday()) # start of next week in UTC
|
||||
if days is None:
|
||||
days = 52 * 7 # consider next 52 weeks (~1 year)
|
||||
|
||||
events = self.final_events(user_tz="UTC", starting_date=date, days=days)
|
||||
|
||||
# an event is “good” if it's not a gap and not empty
|
||||
good_events = [event for event in events if not event["is_gap"] and not event["is_empty"]]
|
||||
if not good_events:
|
||||
return {
|
||||
"total_score": 0,
|
||||
"comments": [{"type": QualityReportCommentType.WARNING, "text": "Schedule is empty"}],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
||||
def event_duration(ev: dict) -> datetime.timedelta:
|
||||
return ev["end"] - ev["start"]
|
||||
|
||||
def timedelta_sum(deltas: Iterable[datetime.timedelta]) -> datetime.timedelta:
|
||||
return sum(deltas, start=datetime.timedelta())
|
||||
|
||||
def score_to_percent(value: float) -> int:
|
||||
return round(value * 100)
|
||||
|
||||
def get_duration_map(evs: list[dict]) -> dict[str, datetime.timedelta]:
|
||||
"""Return a map of user PKs to total duration of events they are in."""
|
||||
result = defaultdict(datetime.timedelta)
|
||||
for ev in evs:
|
||||
for user in ev["users"]:
|
||||
user_pk = user["pk"]
|
||||
result[user_pk] += event_duration(ev)
|
||||
|
||||
return result
|
||||
|
||||
def get_balance_score_by_duration_map(dur_map: dict[str, datetime.timedelta]) -> float:
|
||||
"""
|
||||
Return a score between 0 and 1, based on how balanced the durations are in the duration map.
|
||||
The formula is taken from https://github.com/grafana/oncall/issues/118#issuecomment-1161787854.
|
||||
"""
|
||||
if len(dur_map) <= 1:
|
||||
return 1
|
||||
|
||||
result = 0
|
||||
for key_1, key_2 in itertools.combinations(dur_map, 2):
|
||||
duration_1 = dur_map[key_1]
|
||||
duration_2 = dur_map[key_2]
|
||||
|
||||
result += min(duration_1, duration_2) / max(duration_1, duration_2)
|
||||
|
||||
number_of_pairs = len(dur_map) * (len(dur_map) - 1) // 2
|
||||
return result / number_of_pairs
|
||||
|
||||
# calculate good event score
|
||||
good_events_duration = timedelta_sum(event_duration(event) for event in good_events)
|
||||
good_event_score = min(good_events_duration / datetime.timedelta(days=days), 1)
|
||||
good_event_score = score_to_percent(good_event_score)
|
||||
|
||||
# calculate balance score
|
||||
duration_map = get_duration_map(good_events)
|
||||
balance_score = get_balance_score_by_duration_map(duration_map)
|
||||
balance_score = score_to_percent(balance_score)
|
||||
|
||||
# calculate overloaded users
|
||||
if balance_score >= 95: # tolerate minor imbalance
|
||||
balance_score = 100
|
||||
overloaded_users = []
|
||||
else:
|
||||
average_duration = timedelta_sum(duration_map.values()) / len(duration_map)
|
||||
overloaded_user_pks = [user_pk for user_pk, duration in duration_map.items() if duration > average_duration]
|
||||
usernames = {
|
||||
u.public_primary_key: u.username
|
||||
for u in User.objects.filter(public_primary_key__in=overloaded_user_pks).only(
|
||||
"public_primary_key", "username"
|
||||
)
|
||||
}
|
||||
overloaded_users = []
|
||||
for user_pk in overloaded_user_pks:
|
||||
score = score_to_percent(duration_map[user_pk] / average_duration) - 100
|
||||
username = usernames.get(user_pk) or "unknown" # fallback to "unknown" if user is not found
|
||||
overloaded_users.append({"id": user_pk, "username": username, "score": score})
|
||||
|
||||
# show most overloaded users first
|
||||
overloaded_users.sort(key=lambda u: (-u["score"], u["username"]))
|
||||
|
||||
# generate comments regarding gaps
|
||||
comments = []
|
||||
if good_event_score == 100:
|
||||
comments.append({"type": QualityReportCommentType.INFO, "text": "Schedule has no gaps"})
|
||||
else:
|
||||
not_covered = 100 - good_event_score
|
||||
comments.append(
|
||||
{"type": QualityReportCommentType.WARNING, "text": f"Schedule has gaps ({not_covered}% not covered)"}
|
||||
)
|
||||
|
||||
# generate comments regarding balance
|
||||
if balance_score == 100:
|
||||
comments.append({"type": QualityReportCommentType.INFO, "text": "Schedule is perfectly balanced"})
|
||||
else:
|
||||
comments.append(
|
||||
{"type": QualityReportCommentType.WARNING, "text": "Schedule has balance issues (see overloaded users)"}
|
||||
)
|
||||
|
||||
# calculate total score (weighted sum of good event score and balance score)
|
||||
total_score = round((good_event_score + balance_score) / 2)
|
||||
|
||||
return {
|
||||
"total_score": total_score,
|
||||
"comments": comments,
|
||||
"overloaded_users": overloaded_users,
|
||||
}
|
||||
|
||||
def _resolve_schedule(self, events):
|
||||
"""Calculate final schedule shifts considering rotations and overrides."""
|
||||
if not events:
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
import datetime
|
||||
import enum
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from typing import Iterable, Union
|
||||
|
||||
import pytz
|
||||
|
||||
|
||||
class CommentType(str, enum.Enum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
|
||||
|
||||
# TODO: add "inside working hours score" and "balance outside working hours score" when working hours editor is implemented
|
||||
def get_schedule_quality_score(events: list[dict], days: int) -> dict:
|
||||
# an event is “good” if it's a primary event, not a gap and not empty
|
||||
good_events = [
|
||||
event for event in events if not event["is_override"] and not event["is_gap"] and not event["is_empty"]
|
||||
]
|
||||
good_event_score = get_good_event_score(good_events, days)
|
||||
|
||||
# formula for balance score is taken from here: https://github.com/grafana/oncall/issues/118
|
||||
balance_score, overloaded_users = get_balance_score(good_events)
|
||||
|
||||
if events:
|
||||
total_score = (good_event_score + balance_score) / 2
|
||||
else:
|
||||
total_score = 0
|
||||
|
||||
comments = []
|
||||
if good_event_score < 1:
|
||||
comments.append({"type": CommentType.WARNING, "text": "Schedule has gaps"})
|
||||
else:
|
||||
comments.append({"type": CommentType.INFO, "text": "Schedule has no gaps"})
|
||||
|
||||
if balance_score < 0.8:
|
||||
comments.append({"type": CommentType.WARNING, "text": "Schedule has balance issues"})
|
||||
elif 0.8 <= balance_score < 1:
|
||||
comments.append({"type": CommentType.INFO, "text": "Schedule is well-balanced, but still can be improved"})
|
||||
else:
|
||||
comments.append({"type": CommentType.INFO, "text": "Schedule is perfectly balanced"})
|
||||
|
||||
return {
|
||||
"total_score": score_to_percent(total_score),
|
||||
"comments": comments,
|
||||
"overloaded_users": overloaded_users,
|
||||
}
|
||||
|
||||
|
||||
def get_good_event_score(good_events: list[dict], days: int) -> float:
|
||||
good_events_duration = timedelta_sum(event_duration(event) for event in good_events)
|
||||
good_event_score = min(
|
||||
good_events_duration / datetime.timedelta(days=days), 1
|
||||
) # todo: deal with overlapping events
|
||||
|
||||
return good_event_score
|
||||
|
||||
|
||||
def get_balance_score(events: list[dict]) -> tuple[float, list[str]]:
|
||||
duration_map = defaultdict(datetime.timedelta)
|
||||
for event in events:
|
||||
for user in event["users"]:
|
||||
user_pk = user["pk"]
|
||||
duration_map[user_pk] += event_duration(event)
|
||||
|
||||
if len(duration_map) == 0:
|
||||
return 1, []
|
||||
|
||||
average_duration = timedelta_sum(duration_map.values()) / len(duration_map)
|
||||
overloaded_users = [user_pk for user_pk, duration in duration_map.items() if duration > average_duration]
|
||||
|
||||
return get_balance_score_by_duration_map(duration_map), overloaded_users
|
||||
|
||||
|
||||
def get_balance_score_by_duration_map(duration_map: dict[str, datetime.timedelta]) -> float:
|
||||
if len(duration_map) <= 1:
|
||||
return 1
|
||||
|
||||
score = 0
|
||||
for key_1, key_2 in itertools.combinations(duration_map, 2):
|
||||
duration_1 = duration_map[key_1]
|
||||
duration_2 = duration_map[key_2]
|
||||
|
||||
score += min(duration_1, duration_2) / max(duration_1, duration_2)
|
||||
|
||||
number_of_pairs = len(duration_map) * (len(duration_map) - 1) // 2
|
||||
balance_score = score / number_of_pairs
|
||||
return balance_score
|
||||
|
||||
|
||||
def get_day_start(dt: Union[datetime.datetime, datetime.date]) -> datetime.datetime:
|
||||
return datetime.datetime.combine(dt, datetime.datetime.min.time(), tzinfo=pytz.UTC)
|
||||
|
||||
|
||||
def get_day_end(dt: Union[datetime.datetime, datetime.date]) -> datetime.datetime:
|
||||
return datetime.datetime.combine(dt, datetime.datetime.max.time(), tzinfo=pytz.UTC)
|
||||
|
||||
|
||||
def event_duration(event: dict) -> datetime.timedelta:
|
||||
start = event["start"]
|
||||
end = event["end"]
|
||||
|
||||
if event["all_day"]:
|
||||
start = get_day_start(start)
|
||||
# adding one microsecond to the end datetime to make sure 1 day-long events are really 1 day long
|
||||
end = get_day_end(end) + datetime.timedelta(microseconds=1)
|
||||
|
||||
return end - start
|
||||
|
||||
|
||||
def timedelta_sum(deltas: Iterable[datetime.timedelta]) -> datetime.timedelta:
|
||||
return sum(deltas, start=datetime.timedelta())
|
||||
|
||||
|
||||
def score_to_percent(score: float) -> int:
|
||||
return round(score * 100)
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import datetime
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.schedules.ical_utils import memoized_users_in_ical
|
||||
from apps.schedules.models import OnCallScheduleICal
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleICal, OnCallScheduleWeb
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -54,8 +56,7 @@ def test_get_schedule_score_no_events(get_schedule_quality_response):
|
|||
assert response.json() == {
|
||||
"total_score": 0,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps"},
|
||||
{"type": "info", "text": "Schedule is perfectly balanced"},
|
||||
{"type": "warning", "text": "Schedule is empty"},
|
||||
],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
|
@ -67,12 +68,18 @@ def test_get_schedule_score_09_05(get_schedule_quality_response):
|
|||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
assert response.json() == {
|
||||
"total_score": 27,
|
||||
"total_score": 28,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps"},
|
||||
{"type": "warning", "text": "Schedule has balance issues"},
|
||||
{"type": "warning", "text": "Schedule has gaps (79% not covered)"},
|
||||
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
|
||||
],
|
||||
"overloaded_users": [
|
||||
{
|
||||
"id": user1.public_primary_key,
|
||||
"username": user1.username,
|
||||
"score": 49,
|
||||
},
|
||||
],
|
||||
"overloaded_users": [user1.public_primary_key],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -84,10 +91,16 @@ def test_get_schedule_score_09_09(get_schedule_quality_response):
|
|||
assert response.json() == {
|
||||
"total_score": 51,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps"},
|
||||
{"type": "info", "text": "Schedule is well-balanced, but still can be improved"},
|
||||
{"type": "warning", "text": "Schedule has gaps (81% not covered)"},
|
||||
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
|
||||
],
|
||||
"overloaded_users": [
|
||||
{
|
||||
"id": user2.public_primary_key,
|
||||
"username": user2.username,
|
||||
"score": 9,
|
||||
},
|
||||
],
|
||||
"overloaded_users": [user2.public_primary_key],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -113,8 +126,233 @@ def test_get_schedule_score_09_19(get_schedule_quality_response):
|
|||
assert response.json() == {
|
||||
"total_score": 70,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps"},
|
||||
{"type": "warning", "text": "Schedule has gaps (59% not covered)"},
|
||||
{"type": "info", "text": "Schedule is perfectly balanced"},
|
||||
],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_score_weekdays(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization = make_organization()
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_quality",
|
||||
)
|
||||
|
||||
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
|
||||
response = client.get(url, **make_user_auth_headers(users[0], token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_score": 86,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps (29% not covered)"},
|
||||
{"type": "info", "text": "Schedule is perfectly balanced"},
|
||||
],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_score_all_week(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization = make_organization()
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_quality",
|
||||
)
|
||||
|
||||
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=24),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["SA", "SU"],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
|
||||
response = client.get(url, **make_user_auth_headers(users[0], token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_score": 100,
|
||||
"comments": [
|
||||
{"type": "info", "text": "Schedule has no gaps"},
|
||||
{"type": "info", "text": "Schedule is perfectly balanced"},
|
||||
],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_score_all_week_imbalanced_weekends(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization = make_organization()
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_quality",
|
||||
)
|
||||
|
||||
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=24),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["SA", "SU"],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
|
||||
response = client.get(url, **make_user_auth_headers(users[0], token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_score": 88,
|
||||
"comments": [
|
||||
{"type": "info", "text": "Schedule has no gaps"},
|
||||
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
|
||||
],
|
||||
"overloaded_users": [
|
||||
{
|
||||
"id": user.public_primary_key,
|
||||
"username": user.username,
|
||||
"score": 29,
|
||||
}
|
||||
for user in users[:4]
|
||||
],
|
||||
}
|
||||
|
|
|
|||
19
grafana-plugin/integration-tests/schedules/quality.test.ts
Normal file
19
grafana-plugin/integration-tests/schedules/quality.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { configureOnCallPlugin } from '../utils/configurePlugin';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await configureOnCallPlugin(page);
|
||||
});
|
||||
|
||||
test('check schedule quality for simple 1-user schedule', async ({ page }) => {
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallSchedule(page, onCallScheduleName);
|
||||
|
||||
await expect(page.locator('div[class*="ScheduleQuality"]')).toHaveText('Quality: Great');
|
||||
|
||||
await page.hover('div[class*="ScheduleQuality"]');
|
||||
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=2 ')).toHaveText('Schedule has no gaps');
|
||||
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=3 ')).toHaveText('Schedule is perfectly balanced');
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
$padding: 8px;
|
||||
$width: 280px;
|
||||
$width: 340px;
|
||||
|
||||
.root {
|
||||
width: $width;
|
||||
|
|
@ -53,7 +53,7 @@ $width: 280px;
|
|||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.email {
|
||||
.username {
|
||||
max-width: calc($width - $padding);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
|
||||
import { HorizontalGroup, Icon, IconButton } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import { ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
|
||||
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
|
||||
import styles from './ScheduleQualityDetails.module.scss';
|
||||
|
|
@ -22,24 +18,13 @@ interface ScheduleQualityDetailsProps {
|
|||
}
|
||||
|
||||
export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ quality, getScheduleQualityString }) => {
|
||||
const { userStore } = useStore();
|
||||
const { total_score: score, comments, overloaded_users } = quality;
|
||||
const [expanded, setExpanded] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [overloadedUsers, setOverloadedUsers] = useState<User[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded((expanded) => !expanded);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const infoComments = comments.filter((c) => c.type === 'info');
|
||||
const warningComments = comments.filter((c) => c.type === 'warning');
|
||||
|
||||
|
|
@ -94,15 +79,15 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
</>
|
||||
)}
|
||||
|
||||
{overloadedUsers?.length > 0 && (
|
||||
{overloaded_users?.length > 0 && (
|
||||
<div className={cx('container')}>
|
||||
<div className={cx('row')}>
|
||||
<Icon name="users-alt" />
|
||||
<div className={cx('container')}>
|
||||
<Text type="secondary">Overloaded users</Text>
|
||||
{overloadedUsers.map((overloadedUser, index) => (
|
||||
<Text type="primary" className={cx('email')} key={index}>
|
||||
{overloadedUser.email} ({getTzOffsetString(dayjs().tz(overloadedUser.timezone))})
|
||||
{overloaded_users.map((overloadedUser, index) => (
|
||||
<Text type="primary" className={cx('username')} key={index}>
|
||||
{overloadedUser.username} (+{overloadedUser.score}% avg)
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -125,7 +110,7 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
</HorizontalGroup>
|
||||
{expanded && (
|
||||
<Text type="primary" className={cx('text')}>
|
||||
The next 90 days are taken into consideration when calculating the overall schedule quality.
|
||||
The next 52 weeks are taken into consideration when calculating the overall schedule quality.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -133,25 +118,6 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
</div>
|
||||
);
|
||||
|
||||
async function fetchUsers() {
|
||||
if (!overloaded_users?.length) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const allUsersList: User[] = userStore.getSearchResult().results;
|
||||
const overloadedUsers = [];
|
||||
|
||||
allUsersList.forEach((user) => {
|
||||
if (overloaded_users.indexOf(user['pk']) !== -1) {
|
||||
overloadedUsers.push(user);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
setOverloadedUsers(overloadedUsers);
|
||||
}
|
||||
|
||||
function getScheduleQualityMatchingColor(score: number): string {
|
||||
if (score < 20) {
|
||||
return getVar('--tag-text-danger');
|
||||
|
|
|
|||
|
|
@ -188,8 +188,7 @@ export class ScheduleStore extends BaseStore {
|
|||
}
|
||||
|
||||
async getScoreQuality(scheduleId: Schedule['id']): Promise<ScheduleScoreQualityResponse> {
|
||||
const tomorrow = getFromString(dayjs().add(1, 'day'));
|
||||
return await makeRequest(`/schedules/${scheduleId}/quality?date=${tomorrow}`, { method: 'GET' });
|
||||
return await makeRequest(`/schedules/${scheduleId}/quality`, { method: 'GET' });
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export interface ShiftEvents {
|
|||
export interface ScheduleScoreQualityResponse {
|
||||
total_score: number;
|
||||
comments: Array<{ type: 'warning' | 'info'; text: string }>;
|
||||
overloaded_users: string[];
|
||||
overloaded_users: Array<{ id: string; username: string; score: number }>;
|
||||
}
|
||||
|
||||
export enum ScheduleScoreQualityResult {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue