Merge pull request #3169 from grafana/dev

v1.3.45
This commit is contained in:
Matias Bordese 2023-10-19 15:17:38 -03:00 committed by GitHub
commit 1fb06bf21d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 947 additions and 339 deletions

View file

@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.3.45 (2023-10-19)
### Added
- Use shift data from event object
- Update shifts public API to improve web shifts support ([#3165](https://github.com/grafana/oncall/pull/3165))
### Fixed
- Update ical schedule creation/update to trigger final schedule refresh ([#3156](https://github.com/grafana/oncall/pull/3156))
- Handle None role when syncing users from Grafana ([#3147](https://github.com/grafana/oncall/pull/3147))
- Polish "Build 'When I am on-call' for web UI" [#2915](https://github.com/grafana/oncall/issues/2915)
- Fix iCal schedule incorrect view [#2001](https://github.com/grafana/oncall-private/issues/2001)
- Fix rotation name rendering issue [#2324](https://github.com/grafana/oncall/issues/2324)
### Changed
- Add user TZ information to next shifts per user endpoint ([#3157](https://github.com/grafana/oncall/pull/3157))
## v1.3.44 (2023-10-16)
### Added
@ -24,6 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improve alert group deletion API by @vadimkerr ([#3124](https://github.com/grafana/oncall/pull/3124))
- Removed Integrations Name max characters limit
([#3123](https://github.com/grafana/oncall/pull/3123))
- Truncate long table rows (Integration Name/Alert Group) and show tooltip for the truncated content
([#3123](https://github.com/grafana/oncall/pull/3123))
## v1.3.42 (2023-10-04)

View file

@ -80,6 +80,7 @@ class LegacyAccessControlRole(enum.IntEnum):
ADMIN = 0
EDITOR = 1
VIEWER = 2
NONE = 3
@classmethod
def choices(cls):
@ -99,9 +100,9 @@ RBACObjectPermissionsAttribute = typing.Dict[permissions.BasePermission, typing.
def get_most_authorized_role(permissions: LegacyAccessControlCompatiblePermissions) -> LegacyAccessControlRole:
if not permissions:
return LegacyAccessControlRole.VIEWER
return LegacyAccessControlRole.NONE
# ex. Admin is 0, Viewer is 2, thereby min makes sense here
# ex. Admin is 0, None is 3, thereby min makes sense here
return min({p.fallback_role for p in permissions}, key=lambda r: r.value)

View file

@ -1,6 +1,10 @@
from apps.api.serializers.schedule_base import ScheduleBaseSerializer
from apps.schedules.models import OnCallScheduleICal
from apps.schedules.tasks import schedule_notify_about_empty_shifts_in_schedule, schedule_notify_about_gaps_in_schedule
from apps.schedules.tasks import (
refresh_ical_final_schedule,
schedule_notify_about_empty_shifts_in_schedule,
schedule_notify_about_gaps_in_schedule,
)
from apps.slack.models import SlackChannel, SlackUserGroup
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
from common.api_helpers.utils import validate_ical_url
@ -37,6 +41,12 @@ class ScheduleICalCreateSerializer(ScheduleICalSerializer):
allow_null=True,
)
def create(self, validated_data):
created_schedule = super().create(validated_data)
# for iCal-based schedules we need to refresh final schedule information
refresh_ical_final_schedule.apply_async((created_schedule.pk,))
return created_schedule
class Meta:
model = OnCallScheduleICal
fields = [
@ -80,4 +90,6 @@ class ScheduleICalUpdateSerializer(ScheduleICalCreateSerializer):
updated_schedule.check_gaps_for_next_week()
schedule_notify_about_empty_shifts_in_schedule.apply_async((instance.pk,))
schedule_notify_about_gaps_in_schedule.apply_async((instance.pk,))
# for iCal-based schedules we need to refresh final schedule information
refresh_ical_final_schedule.apply_async((instance.pk,))
return updated_schedule

View file

@ -848,6 +848,7 @@ def test_get_filter_escalation_chain(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_acknowledge_permissions(
@ -883,6 +884,7 @@ def test_alert_group_acknowledge_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_unacknowledge_permissions(
@ -917,6 +919,7 @@ def test_alert_group_unacknowledge_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_resolve_permissions(
@ -951,6 +954,7 @@ def test_alert_group_resolve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_unresolve_permissions(
@ -985,6 +989,7 @@ def test_alert_group_unresolve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_silence_permissions(
@ -1019,6 +1024,7 @@ def test_alert_group_silence_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_unsilence_permissions(
@ -1053,6 +1059,7 @@ def test_alert_group_unsilence_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_attach_permissions(
@ -1087,6 +1094,7 @@ def test_alert_group_attach_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_unattach_permissions(
@ -1121,6 +1129,7 @@ def test_alert_group_unattach_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_list_permissions(
@ -1155,6 +1164,7 @@ def test_alert_group_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_stats_permissions(
@ -1189,6 +1199,7 @@ def test_alert_group_stats_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_bulk_action_permissions(
@ -1221,6 +1232,7 @@ def test_alert_group_bulk_action_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_filters_permissions(
@ -1255,6 +1267,7 @@ def test_alert_group_filters_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_detail_permissions(
@ -1678,6 +1691,7 @@ def test_alert_group_status_field(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_group_preview_template_permissions(

View file

@ -264,6 +264,7 @@ def test_integration_search(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_create_permissions(
@ -294,6 +295,7 @@ def test_alert_receive_channel_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_update_permissions(
@ -331,6 +333,7 @@ def test_alert_receive_channel_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_delete_permissions(
@ -363,6 +366,7 @@ def test_alert_receive_channel_delete_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_list_permissions(
@ -394,6 +398,7 @@ def test_alert_receive_channel_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_detail_permissions(
@ -427,6 +432,7 @@ def test_alert_receive_channel_detail_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_send_demo_alert_permissions(
@ -462,6 +468,7 @@ def test_alert_receive_channel_send_demo_alert_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_integration_options_permissions(
@ -493,6 +500,7 @@ def test_alert_receive_channel_integration_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_preview_template_permissions(
@ -606,6 +614,7 @@ def test_alert_receive_channel_preview_template_dynamic_payload(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_change_team_permissions(
@ -669,6 +678,7 @@ def test_alert_receive_channel_change_team(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_counters_permissions(
@ -702,6 +712,7 @@ def test_alert_receive_channel_counters_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_counters_per_integration_permissions(
@ -928,6 +939,7 @@ def test_alert_receive_channel_send_demo_alert_not_enabled(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_get_connected_contact_points_permissions(
@ -965,6 +977,7 @@ def test_alert_receive_channel_get_connected_contact_points_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_get_contact_points_permissions(
@ -998,6 +1011,7 @@ def test_alert_receive_channel_get_contact_points_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_connect_contact_point_permissions(
@ -1035,6 +1049,7 @@ def test_alert_receive_channel_connect_contact_point_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_create_contact_point_permissions(
@ -1072,6 +1087,7 @@ def test_alert_receive_channel_create_contact_point_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_disconnect_contact_point_permissions(

View file

@ -19,6 +19,7 @@ from apps.base.tests.messaging_backend import TestOnlyBackend
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_template_update_permissions(
@ -53,6 +54,7 @@ def test_alert_receive_channel_template_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_alert_receive_channel_template_detail_permissions(

View file

@ -17,6 +17,7 @@ from apps.api.permissions import LegacyAccessControlRole
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_channel_filter_create_permissions(
@ -48,6 +49,7 @@ def test_channel_filter_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_channel_filter_update_permissions(
@ -87,6 +89,7 @@ def test_channel_filter_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_channel_filter_list_permissions(
@ -122,6 +125,7 @@ def test_channel_filter_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_channel_filter_retrieve_permissions(
@ -157,6 +161,7 @@ def test_channel_filter_retrieve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_channel_filter_delete_permissions(
@ -192,6 +197,7 @@ def test_channel_filter_delete_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_channel_filter_move_to_position_permissions(
@ -487,6 +493,7 @@ def test_channel_filter_update_invalid_notification_backends(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_channel_filter_convert_from_regex_to_jinja2(
@ -521,6 +528,9 @@ def test_channel_filter_convert_from_regex_to_jinja2(
url = reverse("api-internal:channel_filter-detail", kwargs={"pk": regex_channel_filter.public_primary_key})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
if role == LegacyAccessControlRole.NONE:
assert response.status_code == status.HTTP_403_FORBIDDEN
return
assert response.status_code == status.HTTP_200_OK
# Check if preview of the filtering term migration is correct

View file

@ -280,6 +280,7 @@ def test_delete_custom_button(custom_button_internal_api_setup, make_user_auth_h
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_custom_button_create_permissions(
@ -311,6 +312,7 @@ def test_custom_button_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_custom_button_update_permissions(
@ -348,6 +350,7 @@ def test_custom_button_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_custom_button_list_permissions(
@ -381,6 +384,7 @@ def test_custom_button_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_custom_button_retrieve_permissions(
@ -414,6 +418,7 @@ def test_custom_button_retrieve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_custom_button_delete_permissions(

View file

@ -141,6 +141,7 @@ def test_move_to_position_invalid_index(escalation_policy_internal_api_setup, ma
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_escalation_policy_create_permissions(
@ -178,6 +179,7 @@ def test_escalation_policy_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_escalation_policy_update_permissions(
@ -219,6 +221,7 @@ def test_escalation_policy_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_escalation_policy_list_permissions(
@ -256,6 +259,7 @@ def test_escalation_policy_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_escalation_policy_retrieve_permissions(
@ -293,6 +297,7 @@ def test_escalation_policy_retrieve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_escalation_policy_delete_permissions(
@ -330,6 +335,7 @@ def test_escalation_policy_delete_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_escalation_policy_escalation_options_permissions(
@ -367,6 +373,7 @@ def test_escalation_policy_escalation_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_escalation_policy_delay_options_permissions(
@ -405,6 +412,7 @@ def test_escalation_policy_delay_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_escalation_policy_move_to_position_permissions(

View file

@ -188,6 +188,7 @@ def test_update_integration_heartbeat(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_integration_heartbeat_create_permissions(
@ -218,6 +219,7 @@ def test_integration_heartbeat_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_integration_heartbeat_update_permissions(
@ -257,6 +259,7 @@ def test_integration_heartbeat_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_integration_heartbeat_list_permissions(
@ -292,6 +295,7 @@ def test_integration_heartbeat_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_integration_heartbeat_timeout_options_permissions(
@ -323,6 +327,7 @@ def test_integration_heartbeat_timeout_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_integration_heartbeat_retrieve_permissions(

View file

@ -1213,6 +1213,7 @@ def test_create_on_call_shift_override_in_past(on_call_shift_internal_api_setup,
(LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED),
(LegacyAccessControlRole.EDITOR, status.HTTP_201_CREATED),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_create_permissions(
@ -1245,6 +1246,7 @@ def test_on_call_shift_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_update_permissions(
@ -1292,6 +1294,7 @@ def test_on_call_shift_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_list_permissions(
@ -1323,6 +1326,7 @@ def test_on_call_shift_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_retrieve_permissions(
@ -1366,6 +1370,7 @@ def test_on_call_shift_retrieve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_delete_permissions(
@ -1409,6 +1414,7 @@ def test_on_call_shift_delete_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_frequency_options_permissions(
@ -1440,6 +1446,7 @@ def test_on_call_shift_frequency_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_days_options_permissions(
@ -1471,6 +1478,7 @@ def test_on_call_shift_days_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_preview_permissions(

View file

@ -1,3 +1,4 @@
import os
from unittest.mock import patch
import pytest
@ -10,12 +11,11 @@ from apps.api.permissions import LegacyAccessControlRole
@pytest.mark.django_db
@pytest.mark.parametrize("rbac_enabled", [True, False])
def test_get_organization_rbac_enabled(
make_organization_and_user_with_plugin_token, make_user_auth_headers, rbac_enabled
):
def test_get_organization_rbac_enabled(make_organization_and_user_with_plugin_token, make_user_auth_headers):
is_rbac_enabled = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True"
organization, user, token = make_organization_and_user_with_plugin_token()
organization.is_rbac_permissions_enabled = rbac_enabled
# set rbac enabled based on env variable (factories use this value)
organization.is_rbac_permissions_enabled = is_rbac_enabled
organization.save()
client = APIClient()
@ -23,7 +23,7 @@ def test_get_organization_rbac_enabled(
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["rbac_enabled"] == rbac_enabled
assert response.json()["rbac_enabled"] == organization.is_rbac_permissions_enabled
@pytest.mark.django_db
@ -49,6 +49,7 @@ def test_update_organization_settings(make_organization_and_user_with_plugin_tok
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_organization_retrieve_permissions(
@ -79,6 +80,7 @@ def test_organization_retrieve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_organization_update_permissions(
@ -110,6 +112,7 @@ def test_organization_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_organization_get_telegram_verification_code_permissions(
@ -134,6 +137,7 @@ def test_organization_get_telegram_verification_code_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_organization_get_channel_verification_code_permissions(

View file

@ -215,6 +215,7 @@ def test_delete_resolution_note(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_resolution_note_create_permissions(
@ -248,6 +249,7 @@ def test_resolution_note_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_resolution_note_update_permissions(
@ -292,6 +294,7 @@ def test_resolution_note_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_resolution_note_delete_permissions(
@ -334,6 +337,7 @@ def test_resolution_note_delete_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_resolution_note_list_permissions(
@ -366,6 +370,7 @@ def test_resolution_note_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_resolution_note_detail_permissions(

View file

@ -13,6 +13,7 @@ from apps.api.permissions import LegacyAccessControlRole
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_public_api_tokens_retrieve_permissions(
@ -39,6 +40,7 @@ def test_public_api_tokens_retrieve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_public_api_tokens_list_permissions(
@ -65,6 +67,7 @@ def test_public_api_tokens_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_public_api_tokens_create_permissions(
@ -96,6 +99,7 @@ def test_public_api_tokens_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_public_api_tokens_delete_permissions(

View file

@ -17,6 +17,7 @@ ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_get_schedule_export_token(
@ -52,6 +53,7 @@ def test_get_schedule_export_token(
(LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND),
(LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_export_token_not_found(
@ -85,6 +87,7 @@ def test_schedule_export_token_not_found(
(LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED),
(LegacyAccessControlRole.EDITOR, status.HTTP_201_CREATED),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_create_export_token(
@ -118,6 +121,7 @@ def test_schedule_create_export_token(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_delete_export_token(

View file

@ -601,25 +601,25 @@ def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_header
user, token, _, _, _, _ = schedule_internal_api_setup
client = APIClient()
url = reverse("api-internal:schedule-list")
data = {
"ical_url_primary": ICAL_URL,
"ical_url_overrides": None,
"name": "created_ical_schedule",
"type": 1,
"slack_channel_id": None,
"user_group": None,
"team": None,
"warnings": [],
"on_call_now": [],
"has_gaps": False,
"mention_oncall_next": False,
"mention_oncall_start": True,
"notify_empty_oncall": 0,
"notify_oncall_shift_freq": 1,
}
with patch(
"apps.api.serializers.schedule_ical.ScheduleICalSerializer.validate_ical_url_primary", return_value=ICAL_URL
):
data = {
"ical_url_primary": ICAL_URL,
"ical_url_overrides": None,
"name": "created_ical_schedule",
"type": 1,
"slack_channel_id": None,
"user_group": None,
"team": None,
"warnings": [],
"on_call_now": [],
"has_gaps": False,
"mention_oncall_next": False,
"mention_oncall_start": True,
"notify_empty_oncall": 0,
"notify_oncall_shift_freq": 1,
}
), patch("apps.schedules.tasks.refresh_ical_final_schedule.apply_async") as mock_refresh_final:
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
# modify initial data by adding id and None for optional fields
schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"])
@ -628,6 +628,8 @@ def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_header
data["enable_web_overrides"] = False
assert response.status_code == status.HTTP_201_CREATED
assert response.data == data
# check final schedule refresh triggered
mock_refresh_final.assert_called_once_with((schedule.pk,))
@pytest.mark.django_db
@ -736,12 +738,40 @@ def test_update_ical_schedule(schedule_internal_api_setup, make_user_auth_header
"type": 1,
"team": None,
}
response = client.put(
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
)
with patch("apps.schedules.tasks.refresh_ical_final_schedule.apply_async") as mock_refresh_final:
response = client.put(
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
)
updated_instance = OnCallSchedule.objects.get(public_primary_key=ical_schedule.public_primary_key)
assert response.status_code == status.HTTP_200_OK
assert updated_instance.name == "updated_ical_schedule"
# check refresh final is not triggered (url unchanged)
assert not mock_refresh_final.called
@pytest.mark.django_db
def test_update_ical_schedule_url(schedule_internal_api_setup, make_user_auth_headers):
user, token, _, ical_schedule, _, _ = schedule_internal_api_setup
client = APIClient()
url = reverse("api-internal:schedule-detail", kwargs={"pk": ical_schedule.public_primary_key})
updated_url = "another-url"
data = {
"name": ical_schedule.name,
"type": 1,
"ical_url_primary": updated_url,
}
with patch(
"apps.api.serializers.schedule_ical.ScheduleICalSerializer.validate_ical_url_primary", return_value=updated_url
), patch("apps.schedules.tasks.refresh_ical_final_schedule.apply_async") as mock_refresh_final:
response = client.put(
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
)
updated_instance = OnCallSchedule.objects.get(public_primary_key=ical_schedule.public_primary_key)
assert response.status_code == status.HTTP_200_OK
# check refresh final triggered (changing url)
mock_refresh_final.assert_called_once_with((updated_instance.pk,))
@pytest.mark.django_db
@ -1368,7 +1398,15 @@ def test_next_shifts_per_user(
)
tomorrow = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + timezone.timedelta(days=1)
user_a, user_b, user_c, user_d = (make_user_for_organization(organization, username=i) for i in "ABCD")
users = (
("A", "Europe/London"),
("B", "UTC"),
("C", None),
("D", "America/Montevideo"),
)
user_a, user_b, user_c, user_d = (
make_user_for_organization(organization, username=i, _timezone=tz) for i, tz in users
)
# clear users pks <-> organization cache (persisting between tests)
memoized_users_in_ical.cache_clear()
@ -1426,13 +1464,25 @@ def test_next_shifts_per_user(
assert response.status_code == status.HTTP_200_OK
expected = {
user_a.public_primary_key: (tomorrow + timezone.timedelta(hours=15), tomorrow + timezone.timedelta(hours=16)),
user_b.public_primary_key: (tomorrow + timezone.timedelta(hours=7), tomorrow + timezone.timedelta(hours=12)),
user_c.public_primary_key: (tomorrow + timezone.timedelta(hours=17), tomorrow + timezone.timedelta(hours=18)),
user_d.public_primary_key: None,
user_a.public_primary_key: (
tomorrow + timezone.timedelta(hours=15),
tomorrow + timezone.timedelta(hours=16),
user_a.timezone,
),
user_b.public_primary_key: (
tomorrow + timezone.timedelta(hours=7),
tomorrow + timezone.timedelta(hours=12),
user_b.timezone,
),
user_c.public_primary_key: (
tomorrow + timezone.timedelta(hours=17),
tomorrow + timezone.timedelta(hours=18),
user_c.timezone,
),
user_d.public_primary_key: (None, None, user_d.timezone),
}
returned_data = {
u: (ev["start"], ev["end"]) if ev is not None else None for u, ev in response.data["users"].items()
u: (ev.get("start"), ev.get("end"), ev.get("user_timezone")) for u, ev in response.data["users"].items()
}
assert returned_data == expected
@ -1643,6 +1693,7 @@ def test_filter_events_invalid_type(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_create_permissions(
@ -1681,6 +1732,7 @@ def test_schedule_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_update_permissions(
@ -1723,6 +1775,7 @@ def test_schedule_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_list_permissions(
@ -1761,6 +1814,7 @@ def test_schedule_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_retrieve_permissions(
@ -1799,6 +1853,7 @@ def test_schedule_retrieve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_delete_permissions(
@ -1837,6 +1892,7 @@ def test_schedule_delete_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_events_permissions(
@ -1875,6 +1931,7 @@ def test_events_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_filter_shift_swaps_permissions(
@ -1913,6 +1970,7 @@ def test_filter_shift_swaps_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_reload_ical_permissions(
@ -1951,6 +2009,7 @@ def test_reload_ical_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_notify_oncall_shift_freq_options_permissions(
@ -1975,6 +2034,7 @@ def test_schedule_notify_oncall_shift_freq_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_notify_empty_oncall_options_permissions(
@ -1999,6 +2059,7 @@ def test_schedule_notify_empty_oncall_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_schedule_mention_options_permissions(
@ -2023,6 +2084,7 @@ def test_schedule_mention_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_current_user_events_permissions(

View file

@ -17,6 +17,7 @@ from apps.api.permissions import LegacyAccessControlRole
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_set_general_log_channel_permissions(

View file

@ -116,6 +116,7 @@ def test_list(ssr_setup, make_user_auth_headers, expand_users):
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_list_permissions(
@ -157,6 +158,7 @@ def test_retrieve(ssr_setup, make_user_auth_headers, expand_users):
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_retrieve_permissions(
@ -277,6 +279,7 @@ def test_create_swap_start_and_swap_end_must_include_time_zone(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_create_permissions(
@ -398,6 +401,7 @@ def test_update_swap_start_and_swap_end_must_include_time_zone(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_update_own_ssr_permissions(ssr_setup, make_user_auth_headers, role, expected_status):
@ -551,6 +555,7 @@ def test_related_shifts(ssr_setup, make_on_call_shift, make_user_auth_headers):
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_partial_update_own_ssr_permissions(ssr_setup, make_user_auth_headers, role, expected_status):
@ -670,6 +675,7 @@ def test_delete(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_delete_own_ssr_permissions(ssr_setup, make_user_auth_headers, role, expected_status):
@ -778,6 +784,7 @@ def test_take_deleted_ssr(ssr_setup, make_user_auth_headers):
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_take_permissions(

View file

@ -16,6 +16,7 @@ from apps.api.permissions import LegacyAccessControlRole
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_slack_channels_list_permissions(
@ -46,6 +47,7 @@ def test_slack_channels_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_slack_channels_detail_permissions(

View file

@ -16,6 +16,7 @@ from apps.api.permissions import LegacyAccessControlRole
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_get_slack_settings_permissions(
@ -46,6 +47,7 @@ def test_get_slack_settings_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_update_slack_settings_permissions(
@ -76,6 +78,7 @@ def test_update_slack_settings_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_get_acknowledge_remind_options_permissions(
@ -106,6 +109,7 @@ def test_get_acknowledge_remind_options_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_get_unacknowledge_timeout_options_permissions(

View file

@ -116,6 +116,7 @@ def test_list_teams_for_non_member(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_list_teams_permissions(

View file

@ -37,6 +37,7 @@ def test_not_authorized(make_organization_and_user_with_plugin_token, make_teleg
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_list_telegram_channels_permissions(
@ -61,6 +62,7 @@ def test_list_telegram_channels_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_get_telegram_channels_permissions(
@ -87,6 +89,7 @@ def test_get_telegram_channels_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_delete_telegram_channels_permissions(
@ -114,6 +117,7 @@ def test_delete_telegram_channels_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_set_default_telegram_channels_permissions(

View file

@ -327,6 +327,7 @@ def test_notification_chain_verbal(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_update_self_permissions(
@ -356,6 +357,7 @@ def test_user_update_self_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_update_other_permissions(
@ -384,6 +386,7 @@ def test_user_update_other_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_list_permissions(
@ -414,6 +417,7 @@ def test_user_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_detail_self_permissions(
@ -444,6 +448,7 @@ def test_user_detail_self_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_detail_other_permissions(
@ -470,6 +475,7 @@ def test_user_detail_other_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_get_own_verification_code(
@ -500,6 +506,7 @@ def test_user_get_own_verification_code(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_get_other_verification_code(
@ -572,6 +579,7 @@ def test_verification_code_provider_exception(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_verify_own_phone(
@ -607,6 +615,7 @@ Tests below are outdated
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_verify_another_phone(
@ -635,6 +644,7 @@ def test_user_verify_another_phone(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_get_own_telegram_verification_code(
@ -659,6 +669,7 @@ def test_user_get_own_telegram_verification_code(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_get_another_telegram_verification_code(

View file

@ -55,6 +55,7 @@ def test_usergroup_list_without_slack_installed(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_usergroup_permissions(

View file

@ -16,6 +16,7 @@ ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_get_user_schedule_export_token(
@ -47,6 +48,7 @@ def test_get_user_schedule_export_token(
(LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND),
(LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_schedule_export_token_not_found(
@ -73,6 +75,7 @@ def test_user_schedule_export_token_not_found(
(LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED),
(LegacyAccessControlRole.EDITOR, status.HTTP_201_CREATED),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_schedule_create_export_token(
@ -99,6 +102,7 @@ def test_user_schedule_create_export_token(
(LegacyAccessControlRole.ADMIN, status.HTTP_409_CONFLICT),
(LegacyAccessControlRole.EDITOR, status.HTTP_409_CONFLICT),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_schedule_create_multiple_export_tokens_fails(
@ -130,6 +134,7 @@ def test_user_schedule_create_multiple_export_tokens_fails(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_schedule_delete_export_token(
@ -166,6 +171,7 @@ def test_user_schedule_delete_export_token(
(LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND),
(LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_cannot_get_another_users_schedule_token(
@ -198,6 +204,7 @@ def test_user_cannot_get_another_users_schedule_token(
(LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND),
(LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_user_cannot_delete_another_users_schedule_token(

View file

@ -291,6 +291,7 @@ def test_delete_webhook(webhook_internal_api_setup, make_user_auth_headers):
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_webhook_create_permissions(
@ -322,6 +323,7 @@ def test_webhook_create_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_webhook_update_permissions(
@ -359,6 +361,7 @@ def test_webhook_update_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_webhook_list_permissions(
@ -392,6 +395,7 @@ def test_webhook_list_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_webhook_retrieve_permissions(
@ -425,6 +429,7 @@ def test_webhook_retrieve_permissions(
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_webhook_delete_permissions(

View file

@ -22,7 +22,7 @@ class CurrentOrganizationView(APIView):
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"get": [],
"get": [RBACPermission.Permissions.OTHER_SETTINGS_READ],
"put": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE],
}

View file

@ -375,11 +375,14 @@ class ScheduleView(
events = schedule.final_events(now, datetime_end)
users = {u.public_primary_key: None for u in schedule.related_users()}
# include user TZ information for every user
users = {u.public_primary_key: {"user_timezone": u.timezone} for u in schedule.related_users()}
added_users = set()
for e in events:
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:
users[user] = e
if user is not None and user not in added_users and e["end"] > now:
users[user].update(e)
added_users.add(user)
result = {"users": users}
return Response(result, status=status.HTTP_200_OK)

View file

@ -3,6 +3,7 @@ from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import GenericViewSet
from apps.api.permissions import RBACPermission
from apps.api.serializers.slack_channel import SlackChannelSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.slack.models import SlackChannel
@ -12,7 +13,7 @@ from common.api_helpers.paginators import HundredPageSizePaginator
class SlackChannelView(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, GenericViewSet):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (IsAuthenticated, RBACPermission)
pagination_class = HundredPageSizePaginator
@ -21,6 +22,11 @@ class SlackChannelView(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.Retr
serializer_class = SlackChannelSerializer
search_fields = ["name"]
rbac_permissions = {
"list": [RBACPermission.Permissions.CHATOPS_READ],
"retrieve": [RBACPermission.Permissions.CHATOPS_READ],
}
def get_queryset(self):
organization = self.request.auth.organization
slack_team_identity = organization.slack_team_identity

View file

@ -44,7 +44,11 @@ class SlackTeamSettingsAPIView(views.APIView):
class AcknowledgeReminderOptionsAPIView(views.APIView):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"get": [RBACPermission.Permissions.CHATOPS_READ],
}
def get(self, request):
choices = []
@ -57,7 +61,11 @@ class AcknowledgeReminderOptionsAPIView(views.APIView):
class UnAcknowledgeTimeoutOptionsAPIView(views.APIView):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"get": [RBACPermission.Permissions.CHATOPS_READ],
}
def get(self, request):
choices = []

View file

@ -2,6 +2,7 @@ from rest_framework import mixins, viewsets
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from apps.api.permissions import RBACPermission
from apps.api.serializers.user_group import UserGroupSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.slack.models import SlackUserGroup
@ -9,9 +10,14 @@ from apps.slack.models import SlackUserGroup
class UserGroupViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (IsAuthenticated, RBACPermission)
serializer_class = UserGroupSerializer
rbac_permissions = {
"list": [RBACPermission.Permissions.CHATOPS_READ],
"retrieve": [RBACPermission.Permissions.CHATOPS_READ],
}
filter_backends = (SearchFilter,)
search_fields = ("name", "handle")

View file

@ -5,6 +5,7 @@ from rest_framework import fields, serializers
from apps.schedules.models import CustomOnCallShift
from apps.user_management.models import User
from common.api_helpers.custom_fields import (
OrganizationFilteredPrimaryKeyRelatedField,
RollingUsersField,
TeamPrimaryKeyRelatedField,
TimeZoneField,
@ -70,6 +71,7 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
id = serializers.CharField(read_only=True, source="public_primary_key")
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team")
schedule = OrganizationFilteredPrimaryKeyRelatedField(read_only=True)
type = CustomOnCallShiftTypeField()
time_zone = TimeZoneField(required=False, allow_null=True)
users = UsersFilteredByOrganizationField(queryset=User.objects, required=False)
@ -92,6 +94,7 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
"id",
"organization",
"team_id",
"schedule",
"name",
"type",
"time_zone",
@ -116,7 +119,8 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
"source": {"required": False, "write_only": True},
}
PREFETCH_RELATED = ["users"]
SELECT_RELATED = ["schedule"]
PREFETCH_RELATED = ["schedules", "users"]
def create(self, validated_data):
self._validate_frequency_and_week_start(
@ -244,6 +248,9 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
def to_representation(self, instance):
result = super().to_representation(instance)
if result["schedule"] is None:
related_schedules = instance.schedules.all()
result["schedule"] = related_schedules[0].public_primary_key if related_schedules else None
result["duration"] = int(instance.duration.total_seconds())
result["start"] = instance.start.strftime("%Y-%m-%dT%H:%M:%S")
result["rotation_start"] = instance.rotation_start.strftime("%Y-%m-%dT%H:%M:%S")
@ -377,4 +384,7 @@ class CustomOnCallShiftUpdateSerializer(CustomOnCallShiftSerializer):
result = super().update(instance, validated_data)
for schedule in instance.schedules.all():
instance.start_drop_ical_and_check_schedule_tasks(schedule)
if instance.schedule:
# web-schedule shifts use FK instead
instance.start_drop_ical_and_check_schedule_tasks(instance.schedule)
return result

View file

@ -2,6 +2,7 @@ from apps.public_api.serializers.schedules_base import ScheduleBaseSerializer
from apps.schedules.models import OnCallScheduleICal
from apps.schedules.tasks import (
drop_cached_ical_task,
refresh_ical_final_schedule,
schedule_notify_about_empty_shifts_in_schedule,
schedule_notify_about_gaps_in_schedule,
)
@ -32,6 +33,12 @@ class ScheduleICalSerializer(ScheduleBaseSerializer):
def validate_ical_url_overrides(self, url):
return validate_ical_url(url)
def create(self, validated_data):
created_schedule = super().create(validated_data)
# for iCal-based schedules we need to refresh final schedule information
refresh_ical_final_schedule.apply_async((created_schedule.pk,))
return created_schedule
class ScheduleICalUpdateSerializer(ScheduleICalSerializer):
team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team")
@ -70,4 +77,5 @@ class ScheduleICalUpdateSerializer(ScheduleICalSerializer):
)
schedule_notify_about_empty_shifts_in_schedule.apply_async((instance.pk,))
schedule_notify_about_gaps_in_schedule.apply_async((instance.pk,))
refresh_ical_final_schedule.apply_async((instance.pk,))
return super().update(instance, validated_data)

View file

@ -1,3 +1,5 @@
from unittest.mock import patch
import pytest
from django.urls import reverse
from django.utils import timezone
@ -48,6 +50,43 @@ invalid_field_data_10 = {
}
@pytest.mark.django_db
def test_filter_on_call_shift_schedule(make_organization_and_user_with_token, make_on_call_shift, make_schedule):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
schedule_1 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
schedule_2 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
start_date = timezone.now().replace(microsecond=0)
shifts = []
for schedule in (schedule_1, schedule_2):
data = {
"start": start_date,
"rotation_start": start_date,
"duration": timezone.timedelta(seconds=7200),
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_SINGLE_EVENT, **data
)
on_call_shift.users.add(user)
shifts.append(on_call_shift)
url = reverse("api-public:on_call_shifts-list")
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
expected = sorted([s.public_primary_key for s in shifts])
returned = sorted([s["id"] for s in response.json()["results"]])
assert returned == expected
url += f"?schedule_id={schedule_1.public_primary_key}"
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
expected = [shifts[0].public_primary_key]
returned = [s["id"] for s in response.json()["results"]]
assert returned == expected
@pytest.mark.django_db
def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule):
organization, user, token = make_organization_and_user_with_token()
@ -73,6 +112,7 @@ def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_s
result = {
"id": on_call_shift.public_primary_key,
"team_id": None,
"schedule": schedule.public_primary_key,
"name": on_call_shift.name,
"type": "single_event",
"time_zone": None,
@ -111,6 +151,7 @@ def test_get_override_on_call_shift(make_organization_and_user_with_token, make_
result = {
"id": on_call_shift.public_primary_key,
"team_id": None,
"schedule": schedule.public_primary_key,
"name": on_call_shift.name,
"type": "override",
"time_zone": None,
@ -155,6 +196,7 @@ def test_create_on_call_shift(make_organization_and_user_with_token):
result = {
"id": on_call_shift.public_primary_key,
"team_id": None,
"schedule": None,
"name": data["name"],
"type": "recurrent_event",
"time_zone": None,
@ -206,6 +248,7 @@ def test_create_on_call_shift_using_default_interval(make_organization_and_user_
expected = {
"id": on_call_shift.public_primary_key,
"team_id": None,
"schedule": None,
"name": data["name"],
"type": "recurrent_event",
"time_zone": None,
@ -282,6 +325,7 @@ def test_create_override_on_call_shift(make_organization_and_user_with_token):
result = {
"id": on_call_shift.public_primary_key,
"team_id": None,
"schedule": None,
"name": data["name"],
"type": "override",
"time_zone": None,
@ -360,11 +404,13 @@ def test_update_on_call_shift(make_organization_and_user_with_token, make_on_cal
assert on_call_shift.by_day != data_to_update["by_day"]
assert len(on_call_shift.users.filter(public_primary_key=user.public_primary_key)) == 0
response = client.put(url, data=data_to_update, format="json", HTTP_AUTHORIZATION=f"{token}")
with patch("apps.schedules.models.CustomOnCallShift.start_drop_ical_and_check_schedule_tasks") as mock_drop_ical:
response = client.put(url, data=data_to_update, format="json", HTTP_AUTHORIZATION=f"{token}")
result = {
"id": on_call_shift.public_primary_key,
"team_id": None,
"schedule": schedule.public_primary_key,
"name": on_call_shift.name,
"type": "recurrent_event",
"time_zone": None,
@ -389,6 +435,69 @@ def test_update_on_call_shift(make_organization_and_user_with_token, make_on_cal
assert on_call_shift.by_day == data_to_update["by_day"]
assert len(on_call_shift.users.filter(public_primary_key=user.public_primary_key)) == 1
assert response.data == result
mock_drop_ical.assert_called_once_with(schedule)
@pytest.mark.django_db
def test_update_on_call_shift_web_schedule(make_organization_and_user_with_token, make_on_call_shift, make_schedule):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
start_date = timezone.now().replace(microsecond=0)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
data = {
"start": start_date,
"rotation_start": start_date,
"duration": timezone.timedelta(seconds=7200),
"frequency": CustomOnCallShift.FREQUENCY_WEEKLY,
"interval": 2,
"by_day": ["MO", "FR"],
"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]])
url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": on_call_shift.public_primary_key})
data_to_update = {
"duration": 14400,
"by_day": ["MO", "WE", "FR"],
}
assert int(on_call_shift.duration.total_seconds()) != data_to_update["duration"]
assert on_call_shift.by_day != data_to_update["by_day"]
with patch("apps.schedules.models.CustomOnCallShift.start_drop_ical_and_check_schedule_tasks") as mock_drop_ical:
response = client.put(url, data=data_to_update, format="json", HTTP_AUTHORIZATION=f"{token}")
result = {
"id": on_call_shift.public_primary_key,
"team_id": None,
"schedule": schedule.public_primary_key,
"name": on_call_shift.name,
"type": "rolling_users",
"time_zone": None,
"level": 0,
"start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%S"),
"rotation_start": on_call_shift.rotation_start.strftime("%Y-%m-%dT%H:%M:%S"),
"duration": data_to_update["duration"],
"frequency": "weekly",
"interval": on_call_shift.interval,
"until": None,
"week_start": "SU",
"by_day": data_to_update["by_day"],
"rolling_users": [[user.public_primary_key]],
"start_rotation_from_user_index": None,
"by_month": None,
"by_monthday": None,
}
assert response.status_code == status.HTTP_200_OK
on_call_shift.refresh_from_db()
assert int(on_call_shift.duration.total_seconds()) == data_to_update["duration"]
assert on_call_shift.by_day == data_to_update["by_day"]
assert response.data == result
mock_drop_ical.assert_called_once_with(schedule)
@pytest.mark.django_db
@ -482,6 +591,7 @@ def test_create_web_override(make_organization_and_user_with_token, make_on_call
expected_response = {
"id": shift.public_primary_key,
"team_id": None,
"schedule": None,
"name": "test web override",
"type": "override",
"start": start_str,

View file

@ -393,24 +393,24 @@ def test_update_ical_url_overrides_calendar_schedule(
with patch("common.api_helpers.utils.validate_ical_url", return_value=ICAL_URL):
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
result = {
"id": schedule.public_primary_key,
"team_id": None,
"name": schedule.name,
"type": "calendar",
"time_zone": schedule.time_zone,
"on_call_now": [],
"shifts": [],
"slack": {
"channel_id": "SLACKCHANNELID",
"user_group_id": None,
},
"ical_url_overrides": ICAL_URL,
"enable_web_overrides": False,
}
result = {
"id": schedule.public_primary_key,
"team_id": None,
"name": schedule.name,
"type": "calendar",
"time_zone": schedule.time_zone,
"on_call_now": [],
"shifts": [],
"slack": {
"channel_id": "SLACKCHANNELID",
"user_group_id": None,
},
"ical_url_overrides": ICAL_URL,
"enable_web_overrides": False,
}
assert response.status_code == status.HTTP_200_OK
assert response.json() == result
assert response.status_code == status.HTTP_200_OK
assert response.json() == result
@pytest.mark.django_db
@ -633,7 +633,7 @@ def test_create_ical_schedule(make_organization_and_user_with_token):
with patch(
"apps.public_api.serializers.schedules_ical.ScheduleICalSerializer.validate_ical_url_primary",
return_value=ICAL_URL,
):
), patch("apps.schedules.tasks.refresh_ical_final_schedule.apply_async") as mock_refresh_final:
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"])
@ -653,6 +653,7 @@ def test_create_ical_schedule(make_organization_and_user_with_token):
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == result
mock_refresh_final.assert_called_once_with((schedule.pk,))
@pytest.mark.django_db
@ -680,7 +681,8 @@ def test_update_ical_schedule(
assert schedule.name != data["name"]
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
with patch("apps.schedules.tasks.refresh_ical_final_schedule.apply_async") as mock_refresh_final:
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
result = {
"id": schedule.public_primary_key,
@ -700,6 +702,7 @@ def test_update_ical_schedule(
schedule.refresh_from_db()
assert schedule.name == data["name"]
assert response.json() == result
assert not mock_refresh_final.called
@pytest.mark.django_db

View file

@ -1,3 +1,4 @@
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
@ -35,7 +36,9 @@ class CustomOnCallShiftView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelV
queryset = CustomOnCallShift.objects.filter(organization=self.request.auth.organization)
if schedule_id:
queryset = queryset.filter(schedules__public_primary_key=schedule_id)
queryset = queryset.filter(
Q(schedules__public_primary_key=schedule_id) | Q(schedule__public_primary_key=schedule_id)
)
if name:
queryset = queryset.filter(name=name)
return queryset.order_by("schedules")

View file

@ -20,6 +20,7 @@ from apps.user_management.models import User
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_reset_slack_integration_permissions(

View file

@ -83,12 +83,13 @@ def _get_payload(action_type="button", **kwargs):
@pytest.mark.parametrize("step_class", ALERT_GROUP_ACTIONS_STEPS)
@pytest.mark.parametrize("role", (LegacyAccessControlRole.VIEWER, LegacyAccessControlRole.NONE))
@pytest.mark.django_db
def test_alert_group_actions_unauthorized(
step_class, make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group
step_class, make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group, role
):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities(
role=LegacyAccessControlRole.VIEWER
role=role
)
alert_receive_channel = make_alert_receive_channel(organization)

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.20 on 2023-10-18 18:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user_management', '0015_auto_20230926_2203'),
]
operations = [
migrations.AlterField(
model_name='user',
name='role',
field=models.PositiveSmallIntegerField(choices=[(0, 'ADMIN'), (1, 'EDITOR'), (2, 'VIEWER'), (3, 'NONE')]),
),
]

View file

@ -88,7 +88,7 @@ class UserManager(models.Manager["User"]):
email=user["email"],
name=user["name"],
username=user["login"],
role=LegacyAccessControlRole[user["role"].upper()],
role=getattr(LegacyAccessControlRole, user["role"].upper(), LegacyAccessControlRole.NONE),
avatar_url=user["avatarUrl"],
permissions=user["permissions"],
)
@ -120,7 +120,7 @@ class UserManager(models.Manager["User"]):
users_to_update = []
for user in organization.users.filter(user_id__in=existing_user_ids):
grafana_user = grafana_users[user.user_id]
g_user_role = LegacyAccessControlRole[grafana_user["role"].upper()]
g_user_role = getattr(LegacyAccessControlRole, grafana_user["role"].upper(), LegacyAccessControlRole.NONE)
if (
user.email != grafana_user["email"]

View file

@ -5,6 +5,7 @@ from django.conf import settings
from django.test import override_settings
from apps.alerts.models import AlertReceiveChannel
from apps.api.permissions import LegacyAccessControlRole
from apps.grafana_plugin.helpers.client import GcomAPIClient, GrafanaAPIClient
from apps.user_management.models import Team, User
from apps.user_management.sync import check_grafana_incident_is_enabled, cleanup_organization, sync_organization
@ -62,6 +63,43 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati
)
@pytest.mark.django_db
def test_sync_users_for_organization_role_none(make_organization, make_user_for_organization):
organization = make_organization(grafana_url="https://test.test")
users = tuple(make_user_for_organization(organization, user_id=user_id) for user_id in (1, 2))
api_users = tuple(
{
"userId": user_id,
"email": "test@test.test",
"name": "Test",
"login": "test",
"role": "None",
"avatarUrl": "/test/1234",
"permissions": [],
}
for user_id in (2, 3)
)
User.objects.sync_for_organization(organization, api_users=api_users)
assert organization.users.count() == 2
# check that excess users are deleted
assert not organization.users.filter(pk=users[0].pk).exists()
# check that existing users are updated
updated_user = organization.users.filter(pk=users[1].pk).first()
assert updated_user is not None
assert updated_user.role == LegacyAccessControlRole.NONE
# check that missing users are created
created_user = organization.users.filter(user_id=api_users[1]["userId"]).first()
assert created_user is not None
assert created_user.user_id == api_users[1]["userId"]
assert created_user.role == LegacyAccessControlRole.NONE
@pytest.mark.django_db
def test_sync_teams_for_organization(make_organization, make_team):
organization = make_organization()

View file

@ -279,6 +279,7 @@ def get_user_permission_role_mapping_from_frontend_plugin_json() -> RoleMapping:
plugin_json: PluginJSON = json.load(fp)
role_mapping: RoleMapping = {
LegacyAccessControlRole.NONE: [],
LegacyAccessControlRole.VIEWER: [],
LegacyAccessControlRole.EDITOR: [],
LegacyAccessControlRole.ADMIN: [],

View file

@ -47,6 +47,9 @@
.rc-table-cell {
padding-left: 4px;
padding-right: 4px;
/* works better than break-all, especially for table headers */
word-break: break-word;
}
.grecaptcha-badge {

View file

@ -1,23 +0,0 @@
/*
Make sure if you chage max-width here
You also change it in consts.ts
*/
@media screen and (max-width: 1500px) {
.table__email-column {
max-width: 175px;
}
.table__email-content {
text-overflow: ellipsis;
overflow: hidden;
}
.incident__title-column {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
}
.table__wrap-column {
word-break: break-word;
}

View file

@ -3,7 +3,7 @@
*/
.u-flex {
display: flex;
display: flex !important;
flex-direction: row;
}
@ -84,10 +84,6 @@
position: relative;
}
.u-overflow-x-auto {
overflow-x: auto;
}
.u-break-word {
word-break: break-word;
}
@ -129,3 +125,28 @@
margin-bottom: 0;
margin-right: 4px;
}
/* -----
* Overflow
*/
.u-overflow-x-auto {
overflow-x: auto;
}
.overflow-child {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
white-space: initial;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.break-word {
word-break: break-all;
}
.line-clamp-3 {
-webkit-line-clamp: 3;
}

View file

@ -1,53 +0,0 @@
import React, { FC, useEffect, useState } from 'react';
import { Tooltip } from '@grafana/ui';
import { debounce } from 'throttle-debounce';
interface MatchMediaTooltipProps {
placement: 'top' | 'bottom' | 'right' | 'left';
content: string;
children: JSX.Element;
maxWidth?: number;
minWidth?: number;
}
const DEBOUNCE_MS = 200;
export const MatchMediaTooltip: FC<MatchMediaTooltipProps> = ({ minWidth, maxWidth, placement, content, children }) => {
const [match, setMatch] = useState<MediaQueryList>(getMatch());
useEffect(() => {
const debouncedResize = debounce(DEBOUNCE_MS, onWindowResize);
window.addEventListener('resize', debouncedResize);
return () => {
window.removeEventListener('resize', debouncedResize);
};
}, []);
if (match?.matches) {
return (
<Tooltip placement={placement} content={content}>
{children}
</Tooltip>
);
}
return <>{children}</>;
function onWindowResize() {
setMatch(getMatch());
}
function getMatch() {
if (minWidth && maxWidth) {
return window.matchMedia(`(min-width: ${minWidth}px) and (max-width: ${maxWidth}px)`);
} else if (minWidth) {
return window.matchMedia(`(min-width: ${minWidth}px)`);
} else if (maxWidth) {
return window.matchMedia(`(max-width: ${maxWidth}px)`);
}
return undefined;
}
};

View file

@ -0,0 +1,60 @@
import React, { useEffect, useRef, useState } from 'react';
import { Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import styles from 'assets/style/utils.css';
import { TEXT_ELLIPSIS_CLASS } from 'utils/consts';
const cx = cn.bind(styles);
interface TextEllipsisTooltipProps {
content: string;
queryClassName?: string;
placement?: string;
className?: string;
children: JSX.Element | JSX.Element[];
}
const TextEllipsisTooltip: React.FC<TextEllipsisTooltipProps> = ({
queryClassName = TEXT_ELLIPSIS_CLASS,
className,
content: textContent,
placement,
children,
}) => {
const [isEllipsis, setIsEllipsis] = useState(true);
const elContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setEllipsis();
}, []);
const elContent = (
<div className={cx(className)} ref={elContentRef} onMouseOver={setEllipsis}>
{children}
</div>
);
if (isEllipsis) {
return (
<Tooltip content={textContent} placement={placement as any}>
{/* The wrapping div is needed, otherwise the attached ref will be lost when <Tooltip /> mounts */}
<div>{elContent}</div>
</Tooltip>
);
}
return elContent;
function setEllipsis() {
const el = elContentRef?.current?.querySelector<HTMLElement>(`.${queryClassName}`);
if (!el) {
return;
}
setIsEllipsis(el.offsetHeight < el.scrollHeight);
}
};
export default TextEllipsisTooltip;

View file

@ -17,6 +17,7 @@ interface TooltipBadgeProps {
icon?: IconName;
customIcon?: React.ReactNode;
addPadding?: boolean;
placement?;
onHover?: () => void;
}
@ -24,14 +25,25 @@ interface TooltipBadgeProps {
const cx = cn.bind(styles);
const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
const { borderType, text, tooltipTitle, tooltipContent, onHover, addPadding, icon, customIcon, className, ...rest } =
props;
const {
borderType,
text,
tooltipTitle,
tooltipContent,
placement,
onHover,
addPadding,
icon,
customIcon,
className,
...rest
} = props;
const testId = rest['data-testid'];
return (
<Tooltip
placement="bottom-start"
placement={placement || 'bottom-start'}
interactive
content={
<div className={cx('tooltip')}>

View file

@ -8,7 +8,7 @@ import hash from 'object-hash';
import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types';
import Text from 'components/Text/Text';
import ScheduleSlot from 'containers/ScheduleSlot/ScheduleSlot';
import { Event, RotationFormLiveParams, Shift, ShiftSwap } from 'models/schedule/schedule.types';
import { Event, RotationFormLiveParams, ShiftSwap } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import RotationTutorial from './RotationTutorial';
@ -34,7 +34,7 @@ interface RotationProps {
tutorialParams?: RotationFormLiveParams;
simplified?: boolean;
filters?: ScheduleFiltersType;
getColor?: (shiftId: Shift['id']) => string;
getColor?: (event: Event) => string;
onSlotClick?: (event: Event) => void;
emptyText?: string;
showScheduleNameAsSlotTitle?: boolean;
@ -156,7 +156,7 @@ const Rotation: FC<RotationProps> = (props) => {
event={event}
startMoment={startMoment}
currentTimezone={currentTimezone}
color={propsColor || getColor(event.shift?.pk)}
color={propsColor || getColor(event)}
handleAddOverride={getAddOverrideClickHandler(event)}
handleAddShiftSwap={getAddShiftSwapClickHandler(event)}
handleOpenSchedule={getOpenScheduleClickHandler(event)}

View file

@ -16,7 +16,7 @@ import {
getOverridesFromStore,
getShiftsFromStore,
} from 'models/schedule/schedule.helpers';
import { Schedule, Shift, ShiftSwap, Event } from 'models/schedule/schedule.types';
import { Schedule, ShiftSwap, Event } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
@ -59,7 +59,7 @@ class ScheduleFinal extends Component<ScheduleFinalProps> {
const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1;
const getColor = (shiftId: Shift['id']) => findColor(shiftId, layers, overrides);
const getColor = (event: Event) => findColor(event.shift?.pk, layers, overrides);
return (
<>

View file

@ -46,6 +46,7 @@ interface ScheduleOverridesProps extends WithStoreProps {
onUpdate: () => void;
onDelete: () => void;
disabled: boolean;
disableShiftSwaps: boolean;
filters: ScheduleFiltersType;
}
@ -72,6 +73,7 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
store,
shiftIdToShowRotationForm,
disabled,
disableShiftSwaps,
shiftStartToShowOverrideForm: propsShiftStartToShowOverrideForm,
shiftEndToShowOverrideForm: propsShiftEndToShowOverrideForm,
onShowShiftSwapForm,
@ -112,7 +114,7 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
<HorizontalGroup>
<Button
variant="secondary"
disabled={disabled}
disabled={disableShiftSwaps}
onClick={() => {
const closestEvent = findClosestUserEvent(dayjs(), currentUserPk, layers);
const swapStart = closestEvent

View file

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import { Badge, HorizontalGroup } from '@grafana/ui';
import { Badge, Button, HorizontalGroup, Icon } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
@ -12,9 +12,10 @@ import Text from 'components/Text/Text';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import Rotation from 'containers/Rotation/Rotation';
import { getColorForSchedule, getPersonalShiftsFromStore } from 'models/schedule/schedule.helpers';
import { Shift, Event } from 'models/schedule/schedule.types';
import { Event } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
import { getStartOfWeek } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { PLUGIN_ROOT } from 'utils/consts';
@ -32,24 +33,69 @@ interface SchedulePersonalProps extends WithStoreProps, RouteComponentProps {
onSlotClick?: (event: Event) => void;
}
@observer
class SchedulePersonal extends Component<SchedulePersonalProps> {
componentDidMount() {
const { store, startMoment } = this.props;
interface SchedulePersonalState {
startMoment?: dayjs.Dayjs;
}
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment);
@observer
class SchedulePersonal extends Component<SchedulePersonalProps, SchedulePersonalState> {
state: SchedulePersonalState = {};
constructor(props) {
super(props);
this.state = {
startMoment: props.startMoment,
};
}
componentDidUpdate(prevProps: Readonly<SchedulePersonalProps>): void {
const { store, startMoment } = this.props;
componentDidMount() {
const { store } = this.props;
const { startMoment } = this.state;
if (prevProps.startMoment !== this.props.startMoment) {
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment, 9, true);
}
componentDidUpdate(prevProps: Readonly<SchedulePersonalProps>, prevState: Readonly<SchedulePersonalState>): void {
const { store } = this.props;
const { startMoment } = this.state;
if (prevProps.currentTimezone !== this.props.currentTimezone) {
const oldTimezone = prevProps.currentTimezone;
this.setState((oldState) => {
const wDiff = oldState.startMoment.diff(getStartOfWeek(oldTimezone), 'weeks');
return { ...oldState, startMoment: getStartOfWeek(this.props.currentTimezone).add(wDiff, 'weeks') };
});
}
if (prevState.startMoment !== startMoment) {
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment);
}
}
handleTodayClick = () => {
const { store } = this.props;
this.setState({ startMoment: getStartOfWeek(store.currentTimezone) });
};
handleLeftClick = () => {
const { startMoment } = this.state;
this.setState({ startMoment: startMoment.add(-7, 'day') });
};
handleRightClick = () => {
const { startMoment } = this.state;
this.setState({ startMoment: startMoment.add(7, 'day') });
};
render() {
const { userPk, startMoment, currentTimezone, store, onSlotClick } = this.props;
const { userPk, currentTimezone, store, onSlotClick } = this.props;
const { startMoment } = this.state;
const base = 7 * 24 * 60; // in minutes
const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes');
@ -60,18 +106,7 @@ class SchedulePersonal extends Component<SchedulePersonalProps> {
const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1;
const getColor = (shiftId: Shift['id']) => {
const shift = store.scheduleStore.shifts[shiftId];
if (!shift) {
if (shiftId) {
store.scheduleStore.updateOncallShift(shiftId);
}
return;
}
return getColorForSchedule(shift.schedule);
};
const getColor = (event: Event) => getColorForSchedule(event.schedule?.id);
const isOncall = store.scheduleStore.onCallNow[userPk];
@ -82,12 +117,37 @@ class SchedulePersonal extends Component<SchedulePersonalProps> {
<div className={cx('root')}>
<div className={cx('header')}>
<div className={cx('title')}>
<HorizontalGroup>
<Text type="secondary">
On-call schedule <Avatar src={storeUser.avatar} size="small" /> {store.userStore.currentUser.name}
</Text>
{/* @ts-ignore */}
{isOncall ? <Badge text="On-call now" color="green" /> : <Badge text="Not on-call now" color="gray" />}
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Text type="secondary">
On-call schedule <Avatar src={storeUser.avatar} size="small" /> {storeUser.username}
</Text>
{isOncall ? (
<Badge text="On-call now" color="green" />
) : (
/* @ts-ignore */
<Badge text="Not on-call now" color="gray" />
)}
</HorizontalGroup>
<HorizontalGroup>
<HorizontalGroup>
<Text type="secondary">
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
</Text>
<Button variant="secondary" size="sm" onClick={this.handleTodayClick}>
Today
</Button>
<HorizontalGroup spacing="xs">
<Button variant="secondary" size="sm" onClick={this.handleLeftClick}>
<Icon name="angle-left" />
</Button>
<Button variant="secondary" size="sm" onClick={this.handleRightClick}>
<Icon name="angle-right" />
</Button>
</HorizontalGroup>
</HorizontalGroup>
</HorizontalGroup>
</HorizontalGroup>
</div>
</div>

View file

@ -57,7 +57,7 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
const base = 60 * 60 * 24 * 7;
const width = duration / base;
const width = Math.max(duration / base, 0);
const currentMoment = useMemo(() => dayjs(), []);
@ -172,6 +172,7 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => {
content={
<ScheduleSlotDetails
isShiftSwap
title="Shift swap"
beneficiaryName={beneficiary?.display_name}
user={benefactorStoreUser || beneficiaryStoreUser}
benefactorName={benefactor?.display_name}
@ -237,13 +238,17 @@ const RegularEvent = (props: RegularEventProps) => {
{users.map(({ display_name, pk: userPk, swap_request }) => {
const storeUser = store.userStore.items[userPk];
const { schedule, shift } = event;
const isCurrentUserSlot = userPk === store.userStore.currentUserPk;
const inactive = filters && filters.users.length && !filters.users.includes(userPk);
const userTitle = storeUser ? getTitle(storeUser) : display_name;
const userTitle = showScheduleNameAsSlotTitle ? schedule?.name : storeUser ? getTitle(storeUser) : display_name;
const isShiftSwap = Boolean(swap_request);
const title = isShiftSwap ? 'Shift swap' : showScheduleNameAsSlotTitle ? schedule?.name : getShiftName(shift);
let backgroundColor = color;
if (isShiftSwap) {
backgroundColor = SHIFT_SWAP_COLOR;
@ -282,7 +287,7 @@ const RegularEvent = (props: RegularEventProps) => {
key={userPk}
content={
<ScheduleSlotDetails
showScheduleNameAsSlotTitle={showScheduleNameAsSlotTitle}
title={title}
isShiftSwap={isShiftSwap}
beneficiaryName={
isShiftSwap ? (swap_request.user ? swap_request.user.display_name : display_name) : undefined
@ -328,7 +333,7 @@ interface ScheduleSlotDetailsProps {
beneficiaryName?: string;
benefactorName?: string;
currentMoment: dayjs.Dayjs;
showScheduleNameAsSlotTitle?: boolean;
title: string;
}
const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
@ -344,7 +349,7 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
beneficiaryName,
benefactorName,
currentMoment,
showScheduleNameAsSlotTitle,
title,
} = props;
const { scheduleStore } = useStore();
@ -368,8 +373,6 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
}
}, [shift]);
const title = isShiftSwap ? 'Shift swap' : showScheduleNameAsSlotTitle ? schedule?.name : getShiftName(shift);
// const onCallNow = schedule?.on_call_now;
// const isOncall = Boolean(storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk));

View file

@ -14,11 +14,12 @@ const cx = cn.bind(styles);
interface TeamNameProps {
team: GrafanaTeam;
className?: string;
size?: 'small' | 'medium' | 'large';
}
const TeamName = observer((props: TeamNameProps) => {
const { team, size = 'medium' } = props;
const { team, size = 'medium', className } = props;
if (!team) {
return null;
}
@ -26,7 +27,7 @@ const TeamName = observer((props: TeamNameProps) => {
return <Badge text={team.name} color={'blue'} tooltip={'Resource is not assigned to any team (ex General team)'} />;
}
return (
<Text type="secondary" size={size}>
<Text type="secondary" size={size} className={className}>
<Avatar size="small" src={team.avatar_url} className={cx('avatar')} />
<Tooltip placement="top" content={'Resource is assigned to ' + team.name}>
<Text type="primary">{team.name}</Text>

View file

@ -63,7 +63,7 @@ export const fillGaps = (events: Event[]) => {
return newEvents;
};
export const splitToShiftsAndFillGaps = (events: Event[]) => {
export const splitToShifts = (events: Event[]) => {
const shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }> = [];
for (const [_i, event] of events.entries()) {
@ -77,13 +77,20 @@ export const splitToShiftsAndFillGaps = (events: Event[]) => {
}
}
shifts.forEach((shift) => {
shift.events = fillGaps(shift.events);
});
return shifts;
};
export const fillGapsInShifts = (shifts: ShiftEvents[]) => {
return shifts.map((shift) => ({
...shift,
events: fillGaps(shift.events),
}));
};
export const enrichEventsWithScheduleData = (events: Event[], schedule: Partial<Schedule>) => {
return events.map((event) => ({ ...event, schedule }));
};
export const getPersonalShiftsFromStore = (
store: RootStore,
userPk: User['pk'],
@ -102,6 +109,44 @@ export const getShiftsFromStore = (
: (store.scheduleStore.events[scheduleId]?.['final']?.[getFromString(startMoment)] as any);
};
export const unFlattenShiftEvents = (shifts: ShiftEvents[]) => {
for (let i = 0; i < shifts.length; i++) {
const shift = shifts[i];
for (let j = 0; j < shift.events.length - 1; j++) {
for (let k = j + 1; k < shift.events.length; k++) {
const event1 = shift.events[j];
const event2 = shift.events[k];
const event1Start = dayjs(event1.start);
const event1End = dayjs(event1.end);
const event2Start = dayjs(event2.start);
const event2End = dayjs(event2.end);
if (
(event1Start.isBefore(event2Start) && event1End.isAfter(event2Start)) ||
(event1End.isAfter(event2End) && event1Start.isBefore(event2End))
) {
const firstEvent = event1Start.isBefore(event2Start) ? event1 : event2;
const secondEvent = firstEvent === event1 ? event2 : event1;
const oldShift = { ...shift, events: shift.events.filter((event) => event !== secondEvent) };
const newShift = { ...shift, events: [secondEvent] };
shifts[i] = oldShift;
shifts.push(newShift);
return unFlattenShiftEvents(shifts);
}
}
}
}
return shifts;
};
export const flattenShiftEvents = (shifts: ShiftEvents[]) => {
if (!shifts) {
return undefined;
@ -241,9 +286,7 @@ export const getOverridesFromStore = (
: (store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)] as ShiftEvents[]);
};
export const splitToLayers = (
shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }>
) => {
export const splitToLayers = (shifts: ShiftEvents[]) => {
return shifts
.reduce((memo, shift) => {
let layer = memo.find((level) => level.priority === shift.priority);
@ -395,7 +438,7 @@ export const getOverrideColor = (rotationIndex: number) => {
return OVERRIDE_COLORS[normalizedRotationIndex];
};
export const getShiftName = (shift: Shift) => {
export const getShiftName = (shift: Partial<Shift>) => {
if (!shift) {
return '';
}
@ -408,5 +451,5 @@ export const getShiftName = (shift: Shift) => {
return 'Override';
}
return `[L${shift.priority_level}] Rotation`;
return 'Rotation';
};

View file

@ -11,12 +11,15 @@ import { SelectOption } from 'state/types';
import {
createShiftSwapEventFromShiftSwap,
enrichEventsWithScheduleData,
enrichLayers,
enrichOverrides,
fillGapsInShifts,
flattenShiftEvents,
getFromString,
splitToLayers,
splitToShiftsAndFillGaps,
splitToShifts,
unFlattenShiftEvents,
} from './schedule.helpers';
import {
Rotation,
@ -34,7 +37,7 @@ import {
export class ScheduleStore extends BaseStore {
@observable
searchResult: { count?: number; results?: Array<Schedule['id']> } = {};
searchResult: { page_size?: number; count?: number; results?: Array<Schedule['id']> } = {};
@observable.shallow
items: { [id: string]: Schedule } = {};
@ -137,7 +140,7 @@ export class ScheduleStore extends BaseStore {
shouldUpdateFn: () => boolean = undefined
) {
const filters = typeof f === 'string' ? { search: f } : f;
const { count, results } = await makeRequest(this.path, {
const { page_size, count, results } = await makeRequest(this.path, {
method: 'GET',
params: { ...filters, page },
});
@ -157,6 +160,7 @@ export class ScheduleStore extends BaseStore {
),
};
this.searchResult = {
page_size,
count,
results: results.map((item: Schedule) => item.id),
};
@ -193,6 +197,7 @@ export class ScheduleStore extends BaseStore {
return undefined;
}
return {
page_size: this.searchResult.page_size,
count: this.searchResult.count,
results: this.searchResult.results?.map((scheduleId: Schedule['id']) => this.items[scheduleId]),
};
@ -287,7 +292,7 @@ export class ScheduleStore extends BaseStore {
this.rotationPreview = { ...this.rotationPreview, [fromString]: layers };
}
this.finalPreview = { ...this.finalPreview, [fromString]: splitToShiftsAndFillGaps(response.final) };
this.finalPreview = { ...this.finalPreview, [fromString]: fillGapsInShifts(splitToShifts(response.final)) };
}
@action
@ -450,7 +455,9 @@ export class ScheduleStore extends BaseStore {
});
const fromString = getFromString(startMoment);
const shifts = splitToShiftsAndFillGaps(response.events);
const shiftsRaw = splitToShifts(response.events);
const shiftsUnflattened = unFlattenShiftEvents(shiftsRaw);
const shifts = fillGapsInShifts(shiftsUnflattened);
const layers = type === 'rotation' ? splitToLayers(shifts) : undefined;
this.events = {
@ -535,7 +542,7 @@ export class ScheduleStore extends BaseStore {
};
}
async updatePersonalEvents(userPk: User['pk'], startMoment: dayjs.Dayjs, days = 9) {
async updatePersonalEvents(userPk: User['pk'], startMoment: dayjs.Dayjs, days = 9, isUpdateOnCallNow = false) {
const fromString = getFromString(startMoment);
const dayBefore = startMoment.subtract(1, 'day');
@ -548,8 +555,8 @@ export class ScheduleStore extends BaseStore {
},
});
const shiftEventsList = schedules.reduce((acc, schedule) => {
return [...acc, ...splitToShiftsAndFillGaps(schedule.events)];
const shiftEventsList = schedules.reduce((acc, { events, id, name }) => {
return [...acc, ...fillGapsInShifts(splitToShifts(enrichEventsWithScheduleData(events, { id, name })))];
}, []);
const shiftEventsListFlattened = flattenShiftEvents(shiftEventsList);
@ -562,9 +569,12 @@ export class ScheduleStore extends BaseStore {
},
};
this.onCallNow = {
...this.onCallNow,
[userPk]: is_oncall,
};
if (isUpdateOnCallNow) {
// since current endpoint works incorrectly we are waiting for https://github.com/grafana/oncall/issues/3164
this.onCallNow = {
...this.onCallNow,
[userPk]: is_oncall,
};
}
}
}

View file

@ -94,7 +94,7 @@ export interface Event {
is_gap: boolean;
missing_users: Array<{ display_name: User['username']; pk: User['pk'] }>;
priority_level: number;
shift: { pk: Shift['id'] | null };
shift: Pick<Shift, 'name' | 'type'> & { pk: string };
source: string;
start: string;
users: Array<{
@ -104,6 +104,7 @@ export interface Event {
}>;
is_override: boolean;
schedule?: Partial<Schedule>; // populated by frontend for personal schedule to display schedule name instead of user name
shiftSwapId?: ShiftSwap['id']; // if event is acually shift swap request (filled out by frontend)
}

View file

@ -4,10 +4,10 @@ import { Button, HorizontalGroup, IconButton, Tooltip, VerticalGroup } from '@gr
import cn from 'classnames/bind';
import Avatar from 'components/Avatar/Avatar';
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
import PluginLink from 'components/PluginLink/PluginLink';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { Alert as AlertType, Alert, IncidentStatus } from 'models/alertgroup/alertgroup.types';
import { User } from 'models/user/user.types';
@ -15,7 +15,7 @@ import { SilenceButtonCascader } from 'pages/incidents/parts/SilenceButtonCascad
import { move } from 'state/helpers';
import { getVar } from 'utils/DOM';
import { UserActions } from 'utils/authorization';
import { TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
import { TEXT_ELLIPSIS_CLASS } from 'utils/consts';
import styles from './Incident.module.scss';
@ -78,14 +78,13 @@ export function renderRelatedUsers(incident: Alert, isFull = false) {
}
return (
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} wrap={false} className="table__email-content">
<Text type="secondary">
<Avatar size="small" src={user.avatar} />{' '}
<MatchMediaTooltip placement="top" content={user.username} maxWidth={TABLE_COLUMN_MAX_WIDTH}>
<span>{user.username}</span>
</MatchMediaTooltip>{' '}
{badge}
</Text>
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} wrap={false}>
<TextEllipsisTooltip placement="top" content={user.username}>
<Text type="secondary" className={cx(TEXT_ELLIPSIS_CLASS)}>
<Avatar size="small" src={user.avatar} /> <span className={cx('break-word')}>{user.username}</span>
<span className={cx('user-badge')}>{badge}</span>
</Text>
</TextEllipsisTooltip>
</PluginLink>
);
}
@ -117,32 +116,30 @@ export function renderRelatedUsers(incident: Alert, isFull = false) {
}
return (
<div className={'table__email-column'}>
<VerticalGroup spacing="xs">
{visibleUsers.map(renderUser)}
{Boolean(otherUsers.length) && (
<Tooltip
placement="top"
content={
<>
{otherUsers.map((user, index) => (
<>
{index ? ', ' : ''}
{renderUser(user)}
</>
))}
</>
}
>
<span>
<Text type="secondary" underline size="small">
+{otherUsers.length} user{otherUsers.length > 1 ? 's' : ''}
</Text>
</span>
</Tooltip>
)}
</VerticalGroup>
</div>
<VerticalGroup spacing="xs">
{visibleUsers.map(renderUser)}
{Boolean(otherUsers.length) && (
<Tooltip
placement="top"
content={
<>
{otherUsers.map((user, index) => (
<>
{index ? ', ' : ''}
{renderUser(user)}
</>
))}
</>
}
>
<span>
<Text type="secondary" underline size="small">
+{otherUsers.length} user{otherUsers.length > 1 ? 's' : ''}
</Text>
</span>
</Tooltip>
)}
</VerticalGroup>
);
}

View file

@ -196,3 +196,7 @@
}
}
}
.user-badge {
vertical-align: middle;
}

View file

@ -1,6 +1,6 @@
import React, { ReactElement, SyntheticEvent } from 'react';
import { Button, HorizontalGroup, Icon, LoadingPlaceholder, Tooltip, VerticalGroup } from '@grafana/ui';
import { Button, HorizontalGroup, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { get } from 'lodash-es';
import { observer } from 'mobx-react';
@ -15,6 +15,7 @@ import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import ManualAlertGroup from 'components/ManualAlertGroup/ManualAlertGroup';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import Tutorial from 'components/Tutorial/Tutorial';
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilters.types';
@ -27,7 +28,7 @@ import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import { PAGE, PLUGIN_ROOT } from 'utils/consts';
import { PAGE, PLUGIN_ROOT, TEXT_ELLIPSIS_CLASS } from 'utils/consts';
import styles from './Incidents.module.scss';
import { IncidentDropdown } from './parts/IncidentDropdown';
@ -463,7 +464,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
const columns = [
{
width: '5%',
width: '140px',
title: 'Status',
key: 'time',
render: withSkeleton(this.renderStatus),
@ -553,7 +554,13 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
};
renderId(record: AlertType) {
return <Text type="secondary">#{record.inside_organization_number}</Text>;
return (
<TextEllipsisTooltip placement="top" content={`#${record.inside_organization_number}`}>
<Text type="secondary" className={cx(TEXT_ELLIPSIS_CLASS)}>
#{record.inside_organization_number}
</Text>
</TextEllipsisTooltip>
);
}
renderTitle = (record: AlertType) => {
@ -565,25 +572,25 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
const { incidentsItemsPerPage, incidentsCursor } = store.alertGroupStore;
return (
<VerticalGroup spacing="none" justify="center">
<div className={'table__wrap-column'}>
<PluginLink
query={{
page: 'alert-groups',
id: record.pk,
cursor: incidentsCursor,
perpage: incidentsItemsPerPage,
start,
...query,
}}
>
<Tooltip placement="top" content={record.render_for_web.title}>
<span>{record.render_for_web.title}</span>
</Tooltip>
</PluginLink>
{Boolean(record.dependent_alert_groups.length) && ` + ${record.dependent_alert_groups.length} attached`}
</div>
</VerticalGroup>
<div>
<TextEllipsisTooltip placement="top" content={record.render_for_web.title}>
<Text type="link" size="medium" className={cx('overflow-parent')}>
<PluginLink
query={{
page: 'alert-groups',
id: record.pk,
cursor: incidentsCursor,
perpage: incidentsItemsPerPage,
start,
...query,
}}
>
<Text className={cx(TEXT_ELLIPSIS_CLASS)}>{record.render_for_web.title}</Text>
</PluginLink>
</Text>
</TextEllipsisTooltip>
{Boolean(record.dependent_alert_groups.length) && ` + ${record.dependent_alert_groups.length} attached`}
</div>
);
};
@ -598,10 +605,14 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
const integration = alertReceiveChannelStore.getIntegration(record.alert_receive_channel);
return (
<HorizontalGroup spacing="sm">
<TextEllipsisTooltip
className={cx('u-flex', 'u-flex-gap-xs', 'overflow-parent')}
placement="top"
content={record?.alert_receive_channel?.verbal_name || ''}
>
<IntegrationLogo integration={integration} scale={0.1} />
<Emoji text={record.alert_receive_channel?.verbal_name || ''} />
</HorizontalGroup>
<Emoji className={cx(TEXT_ELLIPSIS_CLASS)} text={record.alert_receive_channel?.verbal_name || ''} />
</TextEllipsisTooltip>
);
};
@ -631,7 +642,11 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
}
renderTeam(record: AlertType, teams: any) {
return <TeamName team={teams[record.team]} />;
return (
<TextEllipsisTooltip placement="top" content={teams[record.team]?.name}>
<TeamName className={TEXT_ELLIPSIS_CLASS} team={teams[record.team]} />
</TextEllipsisTooltip>
);
}
getOnActionButtonClick = (incidentId: string, action: AlertAction): ((e: SyntheticEvent) => Promise<void>) => {

View file

@ -2,7 +2,12 @@
width: 180px;
}
.title {
.heartbeat-badge {
padding: 4px 10px;
width: 40px;
}
.integrations-header {
margin-bottom: 24px;
right: 0;
}
@ -15,11 +20,6 @@
margin-top: 16px;
}
.heartbeat-badge {
padding: 4px 10px;
width: 40px;
}
.integrations-actionsList {
display: flex;
flex-direction: column;
@ -44,4 +44,4 @@
&:hover {
background: var(--cards-background);
}
}
}

View file

@ -19,6 +19,7 @@ import {
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import IntegrationForm from 'containers/IntegrationForm/IntegrationForm';
@ -34,14 +35,13 @@ import { withMobXProviderContext } from 'state/withStore';
import { openNotification } from 'utils';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import { PAGE } from 'utils/consts';
import { PAGE, TEXT_ELLIPSIS_CLASS } from 'utils/consts';
import styles from './Integrations.module.scss';
const cx = cn.bind(styles);
const FILTERS_DEBOUNCE_MS = 500;
const ITEMS_PER_PAGE = 15;
const MAX_LINE_LENGTH = 40;
interface IntegrationsState extends PageBaseState {
integrationsFilters: Filters;
@ -227,16 +227,11 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
...query,
}}
>
<Text type="link" size="medium">
<Emoji
className={cx('title')}
text={
item.verbal_name?.length > MAX_LINE_LENGTH
? item.verbal_name?.substring(0, MAX_LINE_LENGTH) + '...'
: item.verbal_name
}
/>
</Text>
<TextEllipsisTooltip placement="top" content={item.verbal_name}>
<Text type="link" size="medium">
<Emoji className={cx('title', TEXT_ELLIPSIS_CLASS)} text={item.verbal_name} />
</Text>
</TextEllipsisTooltip>
</PluginLink>
);
};
@ -278,6 +273,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
<PluginLink query={{ page: 'incidents', integration: item.id }} className={cx('alertsInfoText')}>
<TooltipBadge
borderType="primary"
placement="top"
text={alertReceiveChannelCounter?.alerts_count + '/' + alertReceiveChannelCounter?.alert_groups_count}
tooltipTitle=""
tooltipContent={
@ -298,6 +294,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
icon="link"
text={`${connectedEscalationsChainsCount}/${routesCounter}`}
tooltipContent={undefined}
placement="top"
tooltipTitle={
connectedEscalationsChainsCount +
' connected escalation chain' +
@ -328,6 +325,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
<TooltipBadge
text={undefined}
className={cx('heartbeat-badge')}
placement="top"
borderType={heartbeatStatus ? 'success' : 'danger'}
customIcon={heartbeatStatus ? <HeartIcon /> : <HeartRedIcon />}
tooltipTitle={`Last heartbeat: ${heartbeat?.last_heartbeat_time_verbal}`}
@ -347,6 +345,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
<TooltipBadge
borderType="primary"
icon="pause"
placement="top"
text={IntegrationHelper.getMaintenanceText(item.maintenance_till)}
tooltipTitle={IntegrationHelper.getMaintenanceText(item.maintenance_till, maintenanceMode)}
tooltipContent={undefined}
@ -359,7 +358,11 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
}
renderTeam(item: AlertReceiveChannel, teams: any) {
return <TeamName team={teams[item.team]} />;
return (
<TextEllipsisTooltip placement="top" content={teams[item.team]?.name}>
<TeamName className={TEXT_ELLIPSIS_CLASS} team={teams[item.team]} />
</TextEllipsisTooltip>
);
}
renderButtons = (item: AlertReceiveChannel) => {

View file

@ -26,6 +26,7 @@ import {
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import OutgoingWebhookForm from 'containers/OutgoingWebhookForm/OutgoingWebhookForm';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
@ -36,7 +37,7 @@ import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { openErrorNotification, openNotification } from 'utils';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { PAGE, PLUGIN_ROOT } from 'utils/consts';
import { PAGE, PLUGIN_ROOT, TEXT_ELLIPSIS_CLASS } from 'utils/consts';
import styles from './OutgoingWebhooks.module.scss';
import { WebhookFormActionType } from './OutgoingWebhooks.types';
@ -246,7 +247,11 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
};
renderTeam(record: OutgoingWebhook, teams: any) {
return <TeamName team={teams[record.team]} />;
return (
<TextEllipsisTooltip placement="top" content={teams[record.team]?.name}>
<TeamName className={TEXT_ELLIPSIS_CLASS} team={teams[record.team]} />
</TextEllipsisTooltip>
);
}
renderActionButtons = (record: OutgoingWebhook) => {
@ -342,9 +347,13 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
renderUrl(url: string) {
return (
<div className="u-break-word">
<span>{url}</span>
</div>
<TextEllipsisTooltip content={url} placement="top">
<CopyToClipboard text={url} onCopy={() => openNotification('URL has been copied')}>
<Text type="link" className={cx(TEXT_ELLIPSIS_CLASS, 'line-clamp-3')}>
{url}
</Text>
</CopyToClipboard>
</TextEllipsisTooltip>
);
}

View file

@ -156,6 +156,12 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
shiftIdToShowRotationForm ||
shiftSwapIdToShowForm;
const disabledShiftSwaps =
!isUserActionAllowed(UserActions.SchedulesWrite) ||
!!shiftIdToShowOverridesForm ||
shiftIdToShowRotationForm ||
shiftSwapIdToShowForm;
return (
<PageErrorHandlingWrapper errorData={errorData} objectName="schedule" pageName="schedules">
{() => (
@ -314,6 +320,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
onShowRotationForm={this.handleShowOverridesForm}
disabled={disabledOverrideForm}
disableShiftSwaps={disabledShiftSwaps}
shiftStartToShowOverrideForm={shiftStartToShowOverrideForm}
shiftEndToShowOverrideForm={shiftEndToShowOverrideForm}
onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined}

View file

@ -35,9 +35,3 @@
flex-grow: 1;
gap: 8px;
}
.schedules__user-on-call {
display: flex;
flex-wrap: nowrap;
gap: 4px;
}

View file

@ -9,11 +9,11 @@ import qs from 'query-string';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import Avatar from 'components/Avatar/Avatar';
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector';
import PluginLink from 'components/PluginLink/PluginLink';
import Table from 'components/Table/Table';
import Text from 'components/Text/Text';
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect';
@ -25,7 +25,7 @@ import SchedulePersonal from 'containers/Rotations/SchedulePersonal';
import ScheduleForm from 'containers/ScheduleForm/ScheduleForm';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { Schedule, ScheduleType } from 'models/schedule/schedule.types';
import { Schedule } from 'models/schedule/schedule.types';
import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { getStartOfWeek } from 'pages/schedule/Schedule.helpers';
@ -33,13 +33,13 @@ import { WithStoreProps, PageProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import { PAGE, PLUGIN_ROOT, TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
import { PAGE, PLUGIN_ROOT, TEXT_ELLIPSIS_CLASS } from 'utils/consts';
import styles from './Schedules.module.css';
const cx = cn.bind(styles);
const FILTERS_DEBOUNCE_MS = 500;
const ITEMS_PER_PAGE = 10;
const PAGE_SIZE_DEFAULT = 15;
interface SchedulesPageProps extends WithStoreProps, RouteComponentProps, PageProps {}
@ -83,7 +83,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
const { grafanaTeamStore } = store;
const { showNewScheduleSelector, expandedRowKeys, scheduleIdToEdit, page, startMoment } = this.state;
const { results, count } = store.scheduleStore.getSearchResult();
const { results, count, page_size } = store.scheduleStore.getSearchResult();
const columns = [
{
@ -162,9 +162,6 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
userPk={store.userStore.currentUserPk}
currentTimezone={store.currentTimezone}
startMoment={startMoment}
onSlotClick={(...rest) => {
console.log(rest);
}}
/>
</div>
<div className={cx('schedules__filters-container')}>
@ -179,7 +176,11 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
columns={columns}
data={results}
loading={!results}
pagination={{ page, total: Math.ceil((count || 0) / ITEMS_PER_PAGE), onChange: this.handlePageChange }}
pagination={{
page,
total: Math.ceil((count || 0) / (page_size || PAGE_SIZE_DEFAULT)),
onChange: this.handlePageChange,
}}
rowKey="id"
expandable={{
expandedRowKeys: expandedRowKeys,
@ -234,9 +235,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
handleCreateSchedule = (data: Schedule) => {
const { history, query } = this.props;
if (data.type === ScheduleType.API) {
history.push(`${PLUGIN_ROOT}/schedules/${data.id}?${qs.stringify(query)}`);
}
history.push(`${PLUGIN_ROOT}/schedules/${data.id}?${qs.stringify(query)}`);
};
handleExpandRow = (expanded: boolean, data: Schedule) => {
@ -369,14 +368,14 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
{item.on_call_now.map((user) => {
return (
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} className="table__email-content">
<div className={cx('schedules__user-on-call')}>
<div>
<Avatar size="medium" src={user.avatar} />
</div>
<MatchMediaTooltip placement="top" content={user.username} maxWidth={TABLE_COLUMN_MAX_WIDTH}>
<span className="table__email-content">{user.username}</span>
</MatchMediaTooltip>
</div>
<HorizontalGroup>
<TextEllipsisTooltip placement="top" content={user.username}>
<Text type="secondary" className={cx(TEXT_ELLIPSIS_CLASS)}>
<Avatar size="small" src={user.avatar} />{' '}
<span className={cx('break-word')}>{user.username}</span>
</Text>
</TextEllipsisTooltip>
</HorizontalGroup>
</PluginLink>
);
})}
@ -455,7 +454,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
const { store } = this.props;
const { page, startMoment } = this.state;
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment);
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment, 9, true);
// For removal we need to check if count is 1
// which means we should change the page to the previous one

View file

@ -53,7 +53,6 @@ dayjs.extend(customParseFormat);
import 'assets/style/vars.css';
import 'assets/style/global.css';
import 'assets/style/utils.css';
import 'assets/style/responsive.css';
import { getQueryParams, isTopNavbar } from './GrafanaPluginRootPage.helpers';
import PluginSetup from './PluginSetup';

View file

@ -41,9 +41,6 @@ export const FARO_ENDPOINT_PROD =
export const DOCS_SLACK_SETUP = 'https://grafana.com/docs/oncall/latest/open-source/#slack-setup';
export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/oncall/latest/notify/telegram/';
// Make sure if you chage max-width here you also change it in responsive.css
export const TABLE_COLUMN_MAX_WIDTH = 1500;
export const generateAssignToTeamInputDescription = (objectName: string): string =>
`Assigning to a team allows you to filter ${objectName} and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details.`;
@ -54,3 +51,5 @@ export enum PAGE {
Webhooks = 'webhooks',
Schedules = 'schedules',
}
export const TEXT_ELLIPSIS_CLASS = 'overflow-child';