From 3ff6e0e492b86efe07c49fd4184e3820d87a37f1 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 28 Jul 2023 08:59:33 -0300 Subject: [PATCH 01/13] Refactoring schedule final events (#2651) Update `list_users_to_notify_from_ical` to use schedule final events --- CHANGELOG.md | 1 + .../escalation_policy_snapshot.py | 2 +- .../tests/test_escalation_policy_snapshot.py | 7 +- engine/apps/api/tests/test_schedules.py | 2 +- engine/apps/api/views/on_call_shifts.py | 10 +- engine/apps/api/views/schedule.py | 30 ++++-- engine/apps/mobile_app/tasks.py | 3 +- engine/apps/public_api/views/schedules.py | 8 +- engine/apps/schedules/ical_utils.py | 58 +++-------- .../apps/schedules/models/on_call_schedule.py | 76 ++++++++------- .../apps/schedules/tests/test_ical_utils.py | 95 ++++++++++++++----- .../schedules/tests/test_on_call_schedule.py | 69 +++++++++----- .../schedules/tests/test_quality_score.py | 4 +- 13 files changed, 211 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c55a5766..7ea6c4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update the direct paging feature to page for acknowledged & silenced alert groups, and show a warning for resolved alert groups by @vadimkerr ([#2639](https://github.com/grafana/oncall/pull/2639)) +- Update checking on-call users to use schedule final events ([#2651](https://github.com/grafana/oncall/pull/2651)) ### Fixed diff --git a/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py b/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py index ef5c202e..8ff60c30 100644 --- a/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py +++ b/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py @@ -275,7 +275,7 @@ class EscalationPolicySnapshot: escalation_policy_step=self.step, ) else: - notify_to_users_list = list_users_to_notify_from_ical(on_call_schedule, include_viewers=True) + notify_to_users_list = list_users_to_notify_from_ical(on_call_schedule) if notify_to_users_list is None: log_record = AlertGroupLogRecord( type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED, diff --git a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py index 7ba39f00..bc79abc7 100644 --- a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py +++ b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py @@ -246,11 +246,8 @@ def test_escalation_step_notify_on_call_schedule_viewer_user( ) assert expected_eta + timezone.timedelta(seconds=15) > result.eta > expected_eta - timezone.timedelta(seconds=15) assert result == expected_result - assert notify_schedule_step.log_records.filter(type=AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED).exists() - assert list(escalation_policy_snapshot.notify_to_users_queue) == list( - list_users_to_notify_from_ical(schedule, include_viewers=True) - ) - assert list(escalation_policy_snapshot.notify_to_users_queue) == [viewer] + assert notify_schedule_step.log_records.filter(type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED).exists() + assert list(escalation_policy_snapshot.notify_to_users_queue) == [] assert mocked_execute_tasks.called diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 4cf8eb9c..ad7f64c5 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -1227,7 +1227,7 @@ def test_filter_events_final_schedule( "is_gap": is_gap, "is_override": is_override, "priority_level": priority, - "start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0), + "start": start_date + timezone.timedelta(hours=start), "user": user, } for start, duration, user, priority, is_gap, is_override in expected diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index c760b53f..62c4e36f 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -1,3 +1,6 @@ +import datetime + +import pytz from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend from rest_framework import status @@ -106,8 +109,13 @@ class OnCallShiftView(TeamFilteringMixin, PublicPrimaryKeyMixin, UpdateSerialize updated_shift_pk = self.request.data.get("shift_pk") shift = CustomOnCallShift(**validated_data) schedule = shift.schedule + + pytz_tz = pytz.timezone(user_tz) + datetime_start = datetime.datetime.combine(starting_date, datetime.time.min, tzinfo=pytz_tz) + datetime_end = datetime_start + datetime.timedelta(days=days) + shift_events, final_events = schedule.preview_shift( - shift, user_tz, starting_date, days, updated_shift_pk=updated_shift_pk + shift, datetime_start, datetime_end, updated_shift_pk=updated_shift_pk ) data = { "rotation": shift_events, diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index d6960c9d..7413920f 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -1,6 +1,8 @@ +import datetime import functools import operator +import pytz from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, OuterRef, Subquery from django.db.utils import IntegrityError @@ -274,12 +276,16 @@ class ScheduleView( @action(detail=True, methods=["get"]) def events(self, request, pk): - user_tz, date = self.get_request_timezone() + user_tz, starting_date = self.get_request_timezone() with_empty = self.request.query_params.get("with_empty", False) == "true" with_gap = self.request.query_params.get("with_gap", False) == "true" schedule = self.get_object() - events = schedule.filter_events(user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap) + + pytz_tz = pytz.timezone(user_tz) + datetime_start = datetime.datetime.combine(starting_date, datetime.time.min, tzinfo=pytz_tz) + datetime_end = datetime_start + datetime.timedelta(days=1) + events = schedule.filter_events(datetime_start, datetime_end, with_empty=with_empty, with_gap=with_gap) slack_channel = ( { @@ -312,19 +318,22 @@ class ScheduleView( schedule = self.get_object() + pytz_tz = pytz.timezone(user_tz) + datetime_start = datetime.datetime.combine(starting_date, datetime.time.min, tzinfo=pytz_tz) + datetime_end = datetime_start + datetime.timedelta(days=days) + if filter_by is not None and filter_by != EVENTS_FILTER_BY_FINAL: filter_by = OnCallSchedule.PRIMARY if filter_by == EVENTS_FILTER_BY_ROTATION else OnCallSchedule.OVERRIDES events = schedule.filter_events( - user_tz, - starting_date, - days=days, + datetime_start, + datetime_end, with_empty=True, with_gap=resolve_schedule, filter_by=filter_by, all_day_datetime=True, ) else: # return final schedule - events = schedule.final_events(user_tz, starting_date, days) + events = schedule.final_events(datetime_start, datetime_end) result = { "id": schedule.public_primary_key, @@ -337,11 +346,11 @@ class ScheduleView( @action(detail=True, methods=["get"]) def next_shifts_per_user(self, request, pk): """Return next shift for users in schedule.""" - user_tz, _ = self.get_request_timezone() now = timezone.now() - starting_date = now.date() + datetime_end = now + datetime.timedelta(days=30) schedule = self.get_object() - events = schedule.final_events(user_tz, starting_date, days=30) + + events = schedule.final_events(now, datetime_end) users = {u.public_primary_key: None for u in schedule.related_users()} for e in events: @@ -373,10 +382,11 @@ class ScheduleView( schedule = self.get_object() _, date = self.get_request_timezone() + datetime_start = datetime.datetime.combine(date, datetime.time.min, tzinfo=pytz.UTC) days = self.request.query_params.get("days") days = int(days) if days else None - return Response(schedule.quality_report(date, days)) + return Response(schedule.quality_report(datetime_start, days)) @action(detail=False, methods=["get"]) def type_options(self, request): diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 98101494..41b36a31 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -439,7 +439,8 @@ def conditionally_send_going_oncall_push_notifications_for_schedule(schedule_pk) return now = timezone.now() - schedule_final_events = schedule.final_events("UTC", now, days=7) + datetime_end = now + datetime.timedelta(days=7) + schedule_final_events = schedule.final_events(now, datetime_end) relevant_cache_keys = [ _generate_going_oncall_push_notification_cache_key(user["pk"], schedule_event) diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index 83aa3207..9c398f26 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -1,5 +1,7 @@ +import datetime import logging +import pytz from django_filters import rest_framework as filters from rest_framework import status from rest_framework.decorators import action @@ -147,8 +149,12 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo end_date = serializer.validated_data["end_date"] days_between_start_and_end = (end_date - start_date).days - final_schedule_events: ScheduleEvents = schedule.final_events("UTC", start_date, days_between_start_and_end) + datetime_start = datetime.datetime.combine(start_date, datetime.time.min, tzinfo=pytz.UTC) + datetime_end = datetime_start + datetime.timedelta( + days=days_between_start_and_end - 1, hours=23, minutes=59, seconds=59 + ) + final_schedule_events: ScheduleEvents = schedule.final_events(datetime_start, datetime_end) logger.info( f"Exporting oncall shifts for schedule {pk} between dates {start_date} and {end_date}. {len(final_schedule_events)} shift events were found." ) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index bbdda581..72285e71 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -61,7 +61,6 @@ IcalEvents = typing.List[IcalEvent] def users_in_ical( usernames_from_ical: typing.List[str], organization: "Organization", - include_viewers=False, users_to_filter: typing.Optional["UserQuerySet"] = None, ) -> typing.Sequence["User"]: """ @@ -94,11 +93,10 @@ def users_in_ical( } ) - users_found_in_ical = organization.users - if not include_viewers: - users_found_in_ical = users_found_in_ical.filter( - **User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization) - ) + # users_found_in_ical = organization.users + users_found_in_ical = organization.users.filter( + **User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization) + ) users_found_in_ical = users_found_in_ical.filter( (Q(username__in=usernames_from_ical) | Q(email__lower__in=emails_from_ical)) @@ -118,11 +116,10 @@ def memoized_users_in_ical( # used for display schedule events on web def list_of_oncall_shifts_from_ical( schedule: "OnCallSchedule", - date: datetime.date, - user_timezone: str = "UTC", + datetime_start: datetime.datetime, + datetime_end: datetime.datetime, with_empty_shifts: bool = False, with_gaps: bool = False, - days: int = 1, filter_by: str | None = None, from_cached_final: bool = False, ): @@ -152,16 +149,6 @@ def list_of_oncall_shifts_from_ical( else: calendars = schedule.get_icalendars() - # TODO: Review offset usage - pytz_tz = pytz.timezone(user_timezone) - - # utcoffset can technically return None, but we're confident it is a timedelta here - user_timezone_offset: datetime.timedelta = datetime.datetime.now().astimezone(pytz_tz).utcoffset() # type: ignore[assignment] - - datetime_min = datetime.datetime.combine(date, datetime.time.min) + datetime.timedelta(milliseconds=1) - datetime_start = (datetime_min - user_timezone_offset).astimezone(pytz.UTC) - datetime_end = datetime_start + datetime.timedelta(days=days - 1, hours=23, minutes=59, seconds=59) - result_datetime = [] result_date = [] @@ -204,6 +191,7 @@ def list_of_oncall_shifts_from_ical( ) def event_start_cmp_key(e): + pytz_tz = pytz.timezone("UTC") return ( datetime.datetime.combine(e["start"], datetime.datetime.min.time(), tzinfo=pytz_tz) if type(e["start"]) == datetime.date @@ -348,7 +336,6 @@ def list_of_empty_shifts_in_schedule( def list_users_to_notify_from_ical( schedule: "OnCallSchedule", events_datetime: typing.Optional[datetime.datetime] = None, - include_viewers: bool = False, users_to_filter: typing.Optional["UserQuerySet"] = None, ) -> typing.Sequence["User"]: """ @@ -359,7 +346,6 @@ def list_users_to_notify_from_ical( schedule, events_datetime, events_datetime, - include_viewers=include_viewers, users_to_filter=users_to_filter, ) @@ -368,35 +354,15 @@ def list_users_to_notify_from_ical_for_period( schedule: "OnCallSchedule", start_datetime: datetime.datetime, end_datetime: datetime.datetime, - include_viewers=False, users_to_filter=None, ) -> typing.Sequence["User"]: - # get list of iCalendars from current iCal files. If there is more than one calendar, primary calendar will always - # be the first - calendars = schedule.get_icalendars() - # reverse calendars to make overrides calendar the first, if schedule is iCal - calendars = calendars[::-1] users_found_in_ical: typing.Sequence["User"] = [] - # at first check overrides calendar and return users from it if it exists and on-call users are found - for calendar in calendars: - if calendar is None: - continue - events = ical_events.get_events_from_ical_between(calendar, start_datetime, end_datetime) + events = schedule.final_events(start_datetime, end_datetime) + usernames = [] + for event in events: + usernames += [u["email"] for u in event.get("users", [])] - parsed_ical_events: typing.Dict[int, typing.List[str]] = {} - for event in events: - current_usernames, current_priority = get_usernames_from_ical_event(event) - parsed_ical_events.setdefault(current_priority, []).extend(current_usernames) - # find users by usernames. if users are not found for shift, get users from lower priority - for _, usernames in sorted(parsed_ical_events.items(), reverse=True): - users_found_in_ical = users_in_ical( - usernames, schedule.organization, include_viewers=include_viewers, users_to_filter=users_to_filter - ) - if users_found_in_ical: - break - if users_found_in_ical: - # if users are found in the overrides calendar, there is no need to check primary calendar - break + users_found_in_ical = users_in_ical(usernames, schedule.organization, users_to_filter=users_to_filter) return users_found_in_ical diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index a63f46b0..62cc061f 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -307,9 +307,8 @@ class OnCallSchedule(PolymorphicModel): def filter_events( self, - user_timezone, - starting_date, - days, + datetime_start, + datetime_end, with_empty=False, with_gap=False, filter_by=None, @@ -320,11 +319,10 @@ class OnCallSchedule(PolymorphicModel): shifts = ( list_of_oncall_shifts_from_ical( self, - starting_date, - user_timezone, + datetime_start, + datetime_end, with_empty, with_gap, - days=days, filter_by=filter_by, from_cached_final=from_cached_final, ) @@ -369,26 +367,23 @@ class OnCallSchedule(PolymorphicModel): # combine multiple-users same-shift events into one return self._merge_events(events) - def final_events(self, user_tz, starting_date, days): + def final_events(self, datetime_start, datetime_end): """Return schedule final events, after resolving shifts and overrides.""" - events = self.filter_events( - user_tz, starting_date, days=days, with_empty=True, with_gap=True, all_day_datetime=True - ) - events = self._resolve_schedule(events) + events = self.filter_events(datetime_start, datetime_end, with_empty=True, with_gap=True, all_day_datetime=True) + events = self._resolve_schedule(events, datetime_start, datetime_end) return events def refresh_ical_final_schedule(self): - tz = "UTC" now = timezone.now() # window to consider: from now, -15 days + 6 months delta = EXPORT_WINDOW_DAYS_BEFORE - starting_datetime = now - datetime.timedelta(days=delta) - starting_date = starting_datetime.date() days = EXPORT_WINDOW_DAYS_AFTER + delta + datetime_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - datetime.timedelta(days=delta) + datetime_end = datetime_start + datetime.timedelta(days=days - 1, hours=23, minutes=59, seconds=59) # setup calendar with final schedule shift events calendar = create_base_icalendar(self.name) - events = self.final_events(tz, starting_date, days) + events = self.final_events(datetime_start, datetime_end) updated_ids = set() for e in events: for u in e["users"]: @@ -417,12 +412,12 @@ class OnCallSchedule(PolymorphicModel): dtend_datetime = datetime.datetime.combine( dtend.dt, datetime.datetime.min.time(), tzinfo=pytz.UTC ) - if dtend_datetime and dtend_datetime < starting_datetime: + if dtend_datetime and dtend_datetime < datetime_start: # event ended before window start continue is_cancelled = component.get(ICAL_STATUS) last_modified = component.get(ICAL_LAST_MODIFIED) - if is_cancelled and last_modified and last_modified.dt < starting_datetime: + if is_cancelled and last_modified and last_modified.dt < datetime_start: # drop already ended events older than the window we consider continue elif is_cancelled and not last_modified: @@ -441,17 +436,18 @@ class OnCallSchedule(PolymorphicModel): self.save(update_fields=["cached_ical_final_schedule"]) def upcoming_shift_for_user(self, user, days=7): - user_tz = user.timezone or "UTC" now = timezone.now() # consider an extra day before to include events from UTC yesterday - starting_date = now.date() - datetime.timedelta(days=1) + datetime_start = now - datetime.timedelta(days=1) + datetime_end = datetime_start + datetime.timedelta(days=days) + current_shift = upcoming_shift = None if self.cached_ical_final_schedule is None: # no final schedule info available return None, None - events = self.filter_events(user_tz, starting_date, days=days, all_day_datetime=True, from_cached_final=True) + events = self.filter_events(datetime_start, datetime_end, all_day_datetime=True, from_cached_final=True) for e in events: if e["end"] < now: # shift is finished, ignore @@ -475,13 +471,13 @@ class OnCallSchedule(PolymorphicModel): """ # get events to consider for calculation if date is None: - today = datetime.datetime.now(tz=timezone.utc) + today = timezone.now() date = today - datetime.timedelta(days=7 - today.weekday()) # start of next week in UTC if days is None: days = 52 * 7 # consider next 52 weeks (~1 year) + datetime_end = date + datetime.timedelta(days=days - 1, hours=23, minutes=59, seconds=59) - events = self.final_events(user_tz="UTC", starting_date=date, days=days) - + events = self.final_events(date, datetime_end) # an event is “good” if it's not a gap and not empty good_events: ScheduleEvents = [event for event in events if not event["is_gap"] and not event["is_empty"]] if not good_events: @@ -591,8 +587,13 @@ class OnCallSchedule(PolymorphicModel): "overloaded_users": overloaded_users, } - def _resolve_schedule(self, events: ScheduleEvents) -> ScheduleEvents: - """Calculate final schedule shifts considering rotations and overrides.""" + def _resolve_schedule( + self, events: ScheduleEvents, datetime_start: datetime.datetime, datetime_end: datetime.datetime + ) -> ScheduleEvents: + """Calculate final schedule shifts considering rotations and overrides. + + Exclude events that after split/update are out of the requested (datetime_start, datetime_end) range. + """ if not events: return [] @@ -678,16 +679,20 @@ class OnCallSchedule(PolymorphicModel): # 1. add a split event copy to schedule the time before the already scheduled interval to_add = ev.copy() to_add["end"] = intervals[current_interval_idx][0] - resolved.append(to_add) + if to_add["end"] >= datetime_start: + # only include if updated event ends inside the requested time range + resolved.append(to_add) # 2. check if there is still time to be scheduled after the current scheduled interval ends if ev["end"] > intervals[current_interval_idx][1]: # event ends after current interval, update event start timestamp to match the interval end # and process the updated event as any other event ev["start"] = intervals[current_interval_idx][1] - # reorder pending events after updating current event start date - # (ie. insert the event where it should be to keep the order criteria) - # TODO: switch to bisect insert on python 3.10 (or consider heapq) - insort_event(pending, ev) + if ev["start"] < datetime_end: + # only include event if it is still inside the requested time range + # reorder pending events after updating current event start date + # (ie. insert the event where it should be to keep the order criteria) + # TODO: switch to bisect insert on python 3.10 (or consider heapq) + insort_event(pending, ev) # done, go to next event elif ev["start"] >= intervals[current_interval_idx][0] and ev["end"] <= intervals[current_interval_idx][1]: @@ -757,7 +762,7 @@ class OnCallSchedule(PolymorphicModel): ical += f"{end_line}\r\n" return ical - def preview_shift(self, custom_shift, user_tz, starting_date, days, updated_shift_pk=None): + def preview_shift(self, custom_shift, datetime_start, datetime_end, updated_shift_pk=None): """Return unsaved rotation and final schedule preview events.""" if custom_shift.type == CustomOnCallShift.TYPE_OVERRIDE: qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE) @@ -790,11 +795,12 @@ class OnCallSchedule(PolymorphicModel): setattr(self, ical_attr, ical_file) # filter events using a temporal overriden calendar including the not-yet-saved shift - events = self.filter_events(user_tz, starting_date, days=days, with_empty=True, with_gap=True) + events = self.filter_events(datetime_start, datetime_end, with_empty=True, with_gap=True) + # return preview events for affected shifts updated_shift_pks = {s.public_primary_key for s in extra_shifts} shift_events = [e.copy() for e in events if e["shift"]["pk"] in updated_shift_pks] - final_events = self._resolve_schedule(events) + final_events = self._resolve_schedule(events, datetime_start, datetime_end) _invalidate_cache(self, ical_property) setattr(self, ical_attr, original_value) @@ -993,11 +999,11 @@ class OnCallScheduleCalendar(OnCallSchedule): ical += f"{end_line}\r\n" return ical - def preview_shift(self, custom_shift, user_tz, starting_date, days, updated_shift_pk=None): + def preview_shift(self, custom_shift, datetime_start, datetime_end, updated_shift_pk=None): """Return unsaved rotation and final schedule preview events.""" if custom_shift.type != CustomOnCallShift.TYPE_OVERRIDE: raise ValueError("Invalid shift type") - return super().preview_shift(custom_shift, user_tz, starting_date, days, updated_shift_pk=updated_shift_pk) + return super().preview_shift(custom_shift, datetime_start, datetime_end, updated_shift_pk=updated_shift_pk) @property def insight_logs_type_verbal(self): diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index 822ac6ad..fdd4b365 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -83,23 +83,18 @@ def test_users_in_ical_email_case_insensitive(make_organization_and_user, make_u @pytest.mark.django_db -@pytest.mark.parametrize("include_viewers", [True, False]) -def test_users_in_ical_viewers_inclusion(make_organization_and_user, make_user_for_organization, include_viewers): +def test_users_in_ical_viewers_inclusion(make_organization_and_user, make_user_for_organization): organization, user = make_organization_and_user() viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) usernames = [user.username, viewer.username] - result = users_in_ical(usernames, organization, include_viewers=include_viewers) - if include_viewers: - assert set(result) == {user, viewer} - else: - assert set(result) == {user} + result = users_in_ical(usernames, organization) + assert set(result) == {user} @pytest.mark.django_db -@pytest.mark.parametrize("include_viewers", [True, False]) def test_list_users_to_notify_from_ical_viewers_inclusion( - make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift, include_viewers + make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift ): organization, user = make_organization_and_user() viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) @@ -121,14 +116,10 @@ def test_list_users_to_notify_from_ical_viewers_inclusion( # get users on-call date = date + timezone.timedelta(minutes=5) - users_on_call = list_users_to_notify_from_ical(schedule, date, include_viewers=include_viewers) + users_on_call = list_users_to_notify_from_ical(schedule, date) - if include_viewers: - assert len(users_on_call) == 2 - assert set(users_on_call) == {user, viewer} - else: - assert len(users_on_call) == 1 - assert set(users_on_call) == {user} + assert len(users_on_call) == 1 + assert set(users_on_call) == {user} @pytest.mark.django_db @@ -161,7 +152,49 @@ def test_list_users_to_notify_from_ical_until_terminated_event( date = date + timezone.timedelta(minutes=5) # this should not raise despite the shift configuration (until < rotation start) users_on_call = list_users_to_notify_from_ical(schedule, date) - assert users_on_call == [] + assert list(users_on_call) == [] + + +@pytest.mark.django_db +def test_list_users_to_notify_from_ical_overlapping_events( + make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift +): + organization, user = make_organization_and_user() + another_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + start = timezone.now() - timezone.timedelta(hours=1) + data = { + "start": start, + "rotation_start": start, + "duration": timezone.timedelta(hours=3), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + data = { + "start": start + timezone.timedelta(minutes=30), + "rotation_start": start + timezone.timedelta(minutes=30), + "duration": timezone.timedelta(hours=2), + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[another_user]]) + + # get users on-call now + users_on_call = list_users_to_notify_from_ical(schedule) + + assert len(users_on_call) == 1 + assert set(users_on_call) == {another_user} @pytest.mark.django_db @@ -173,17 +206,26 @@ def test_shifts_dict_all_day_middle_event(make_organization, make_schedule, get_ day_to_check_iso = "2021-01-27T15:27:14.448059+00:00" parsed_iso_day_to_check = datetime.datetime.fromisoformat(day_to_check_iso).replace(tzinfo=pytz.UTC) - requested_date = (parsed_iso_day_to_check - timezone.timedelta(days=1)).date() - shifts = list_of_oncall_shifts_from_ical(schedule, requested_date, days=3, with_empty_shifts=True) + requested_datetime = parsed_iso_day_to_check - timezone.timedelta(days=1) + datetime_end = requested_datetime + timezone.timedelta(days=2) + shifts = list_of_oncall_shifts_from_ical(schedule, requested_datetime, datetime_end, with_empty_shifts=True) assert len(shifts) == 5 for s in shifts: - start = s["start"].date() if isinstance(s["start"], datetime.datetime) else s["start"] - end = s["end"].date() if isinstance(s["end"], datetime.datetime) else s["end"] + start = ( + s["start"] + if isinstance(s["start"], datetime.datetime) + else datetime.datetime.combine(s["start"], datetime.time.min, tzinfo=pytz.UTC) + ) + end = ( + s["end"] + if isinstance(s["end"], datetime.datetime) + else datetime.datetime.combine(s["start"], datetime.time.max, tzinfo=pytz.UTC) + ) # event started in the given period, or ended in that period, or is happening during the period assert ( - requested_date <= start <= requested_date + timezone.timedelta(days=3) - or requested_date <= end <= requested_date + timezone.timedelta(days=3) - or start <= requested_date <= end + requested_datetime <= start <= requested_datetime + timezone.timedelta(days=2) + or requested_datetime <= end <= requested_datetime + timezone.timedelta(days=2) + or start <= requested_datetime <= end ) @@ -197,7 +239,8 @@ def test_shifts_dict_from_cached_final( organization = make_organization() u1 = make_user_for_organization(organization) - yesterday = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - timezone.timedelta(days=1) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + yesterday = today - timezone.timedelta(days=1) schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) data = { "start": yesterday + timezone.timedelta(hours=10), @@ -227,7 +270,7 @@ def test_shifts_dict_from_cached_final( shifts = [ (s["calendar_type"], s["start"], list(s["users"])) - for s in list_of_oncall_shifts_from_ical(schedule, yesterday, days=1, from_cached_final=True) + for s in list_of_oncall_shifts_from_ical(schedule, yesterday, today, from_cached_final=True) ] expected_events = [ (OnCallSchedule.PRIMARY, on_call_shift.start, [u1]), diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 8817b747..6b8e61ef 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -81,7 +81,8 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched override.add_rolling_users([[user]]) # filter primary non-empty shifts only - events = schedule.filter_events("UTC", start_date, days=3, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY) + end_date = start_date + timezone.timedelta(days=3) + events = schedule.filter_events(start_date, end_date, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY) expected = [ { "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, @@ -109,7 +110,8 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched assert events == expected # filter overrides only - events = schedule.filter_events("UTC", start_date, days=3, filter_by=OnCallSchedule.TYPE_ICAL_OVERRIDES) + end_date = start_date + timezone.timedelta(days=3) + events = schedule.filter_events(start_date, end_date, filter_by=OnCallSchedule.TYPE_ICAL_OVERRIDES) expected_override = [ { "calendar_type": OnCallSchedule.TYPE_ICAL_OVERRIDES, @@ -136,7 +138,8 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched assert events == expected_override # no type filter - events = schedule.filter_events("UTC", start_date, days=3) + end_date = start_date + timezone.timedelta(days=3) + events = schedule.filter_events(start_date, end_date) assert events == expected_override + expected @@ -165,13 +168,12 @@ def test_filter_events_include_gaps(make_organization, make_user_for_organizatio ) on_call_shift.add_rolling_users([[user]]) - events = schedule.filter_events( - "UTC", start_date, days=1, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY, with_gap=True - ) + end_date = start_date + timezone.timedelta(days=1) + events = schedule.filter_events(start_date, end_date, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY, with_gap=True) expected = [ { "calendar_type": None, - "start": start_date + timezone.timedelta(milliseconds=1), + "start": start_date, "end": on_call_shift.start, "all_day": False, "is_override": False, @@ -207,7 +209,7 @@ def test_filter_events_include_gaps(make_organization, make_user_for_organizatio { "calendar_type": None, "start": on_call_shift.start + on_call_shift.duration, - "end": on_call_shift.start + timezone.timedelta(hours=13, minutes=59, seconds=59, milliseconds=1), + "end": on_call_shift.start + timezone.timedelta(hours=14), "all_day": False, "is_override": False, "is_empty": False, @@ -247,9 +249,8 @@ def test_filter_events_include_empty(make_organization, make_user_for_organizati ) on_call_shift.add_rolling_users([[user]]) - events = schedule.filter_events( - "UTC", start_date, days=1, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY, with_empty=True - ) + end_date = start_date + timezone.timedelta(days=1) + events = schedule.filter_events(start_date, end_date, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY, with_empty=True) expected = [ { "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, @@ -282,9 +283,10 @@ def test_filter_events_ical_all_day(make_organization, make_user_for_organizatio day_to_check_iso = "2021-01-27T15:27:14.448059+00:00" parsed_iso_day_to_check = datetime.datetime.fromisoformat(day_to_check_iso).replace(tzinfo=pytz.UTC) - start_date = (parsed_iso_day_to_check - timezone.timedelta(days=1)).date() + datetime_start = parsed_iso_day_to_check - timezone.timedelta(days=1) + datetime_end = datetime_start + datetime.timedelta(days=1, hours=23, minutes=59, seconds=59) - events = schedule.final_events("UTC", start_date, days=2) + events = schedule.final_events(datetime_start, datetime_end) expected_events = [ # all_day, users, start, end ( @@ -311,6 +313,12 @@ def test_filter_events_ical_all_day(make_organization, make_user_for_organizatio datetime.datetime(2021, 1, 27, 8, 0, tzinfo=pytz.UTC), datetime.datetime(2021, 1, 27, 17, 0, tzinfo=pytz.UTC), ), + ( + False, + ["@Bernard Desruisseaux"], + datetime.datetime(2021, 1, 28, 8, 0, tzinfo=pytz.UTC), + datetime.datetime(2021, 1, 28, 17, 0, tzinfo=pytz.UTC), + ), ] expected = [ {"all_day": all_day, "users": users, "start": start, "end": end} @@ -388,7 +396,8 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma ) on_call_shift.add_rolling_users([[user]]) - returned_events = schedule.final_events("UTC", start_date, days=1) + datetime_end = start_date + timezone.timedelta(days=1) + returned_events = schedule.final_events(start_date, datetime_end) expected = ( # start (h), duration (H), user, priority, is_gap, is_override @@ -414,7 +423,7 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma "is_gap": is_gap, "is_override": is_override, "priority_level": priority, - "start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0), + "start": start_date + timezone.timedelta(hours=start), "user": user, } for start, duration, user, priority, is_gap, is_override in expected @@ -482,7 +491,8 @@ def test_final_schedule_override_no_priority_shift( ) override.add_rolling_users([[user_b]]) - returned_events = schedule.final_events("UTC", start_date, days=1) + datetime_end = start_date + timezone.timedelta(days=1) + returned_events = schedule.final_events(start_date, datetime_end) expected = ( # start (h), duration (H), user, priority, is_override @@ -552,7 +562,8 @@ def test_final_schedule_splitting_events( ) on_call_shift.add_rolling_users([[user]]) - returned_events = schedule.final_events("UTC", start_date, days=1) + datetime_end = start_date + timezone.timedelta(days=1) + returned_events = schedule.final_events(start_date, datetime_end) expected = ( # start (h), duration (H), user, priority @@ -621,7 +632,8 @@ def test_final_schedule_splitting_same_time_events( ) on_call_shift.add_rolling_users([[user]]) - returned_events = schedule.final_events("UTC", start_date, days=1) + datetime_end = start_date + timezone.timedelta(days=1) + returned_events = schedule.final_events(start_date, datetime_end) expected = ( # start (h), duration (H), user, priority @@ -695,7 +707,8 @@ def test_preview_shift(make_organization, make_user_for_organization, make_sched rolling_users=[{other_user.pk: other_user.public_primary_key}], ) - rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1) + datetime_end = start_date + timezone.timedelta(days=1) + rotation_events, final_events = schedule.preview_shift(new_shift, start_date, datetime_end) # check rotation events expected_rotation_events = [ @@ -796,7 +809,8 @@ def test_preview_shift_do_not_change_rotation_events( ) other_shift.add_rolling_users([[other_user]]) - rotation_events, final_events = schedule.preview_shift(on_call_shift, "UTC", start_date, days=1) + datetime_end = start_date + timezone.timedelta(days=1) + rotation_events, final_events = schedule.preview_shift(on_call_shift, start_date, datetime_end) # check rotation events expected_rotation_events = [ @@ -852,7 +866,8 @@ def test_preview_shift_no_user(make_organization, make_user_for_organization, ma rolling_users=[], ) - rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1) + datetime_end = start_date + timezone.timedelta(days=1) + rotation_events, final_events = schedule.preview_shift(new_shift, start_date, datetime_end) # check rotation events expected_rotation_events = [ @@ -930,7 +945,8 @@ def test_preview_override_shift(make_organization, make_user_for_organization, m rolling_users=[{other_user.pk: other_user.public_primary_key}], ) - rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1) + datetime_end = start_date + timezone.timedelta(days=1) + rotation_events, final_events = schedule.preview_shift(new_shift, start_date, datetime_end) # check rotation events expected_rotation_events = [ @@ -1089,7 +1105,8 @@ def test_filter_events_none_cache_unchanged( # schedule is removed from db schedule.delete() - events = schedule.filter_events("UTC", start_date, days=5, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY) + end_date = start_date + timezone.timedelta(days=5) + events = schedule.filter_events(start_date, end_date, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY) expected = [] assert events == expected @@ -1272,7 +1289,8 @@ def test_api_schedule_preview_requires_override(make_organization, make_schedule ) with pytest.raises(ValueError): - schedule.preview_shift(non_override_shift, "UTC", now, 1) + datetime_end = now + timezone.timedelta(days=1) + schedule.preview_shift(non_override_shift, now, datetime_end) @pytest.mark.django_db @@ -1817,4 +1835,5 @@ def test_event_until_non_utc(make_organization, make_schedule): now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) # check this works without raising exception - schedule.final_events("UTC", now, days=7) + datetime_end = now + timezone.timedelta(days=7) + schedule.final_events(now, datetime_end) diff --git a/engine/apps/schedules/tests/test_quality_score.py b/engine/apps/schedules/tests/test_quality_score.py index 5926f929..bbcb7949 100644 --- a/engine/apps/schedules/tests/test_quality_score.py +++ b/engine/apps/schedules/tests/test_quality_score.py @@ -190,7 +190,7 @@ def test_get_schedule_score_weekdays( assert response.json() == { "total_score": 86, "comments": [ - {"type": "warning", "text": "Schedule has gaps (29% not covered)"}, + {"type": "warning", "text": "Schedule has gaps (28% not covered)"}, {"type": "info", "text": "Schedule is perfectly balanced"}, ], "overloaded_users": [], @@ -351,7 +351,7 @@ def test_get_schedule_score_all_week_imbalanced_weekends( { "id": user.public_primary_key, "username": user.username, - "score": 29, + "score": 28, } for user in users[:4] ], From f77a54b518ab8f1f42e33ca43328b75fde1903b9 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Fri, 28 Jul 2023 17:11:38 +0200 Subject: [PATCH 02/13] Shift Swap Requests in Slack + improve typing for Slack django app (#2653) # What this PR does **Shift Swap Requests** https://www.loom.com/share/860c3337b338412cbd2ac4024260f3e8?sid=3d91b558-b4de-4351-8b45-8a99b7302346 **Other** - Drastically improve the typing in the `slack` Django app, and several other models/functions that were consumed by logic within the `slack` Django app (ex. setting `RelatedManager` type hints on various models) https://www.loom.com/share/da6b9984519c48d59a45d3c93c08d7dc ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- .../renderers/base_renderer.py | 8 +- .../renderers/slack_renderer.py | 17 +- .../incident_log_builder.py | 29 +- .../migrations/0029_auto_20230728_0802.py | 25 + engine/apps/alerts/models/alert.py | 9 + engine/apps/alerts/models/alert_group.py | 31 +- .../alerts/models/alert_group_log_record.py | 13 + .../alerts/models/alert_manager_models.py | 12 +- .../alerts/models/alert_receive_channel.py | 6 +- engine/apps/alerts/models/channel_filter.py | 8 + engine/apps/alerts/models/resolution_note.py | 17 +- engine/apps/alerts/paging.py | 46 +- engine/apps/alerts/tests/test_paging.py | 17 +- engine/apps/api/tests/test_shift_swaps.py | 29 +- engine/apps/api/views/shift_swap.py | 29 +- .../0015_shiftswaprequest_slack_message.py | 20 + .../apps/schedules/models/on_call_schedule.py | 16 +- .../schedules/models/shift_swap_request.py | 73 ++- .../schedules/tasks/shift_swaps/__init__.py | 1 + .../tasks/shift_swaps/slack_messages.py | 64 +++ engine/apps/schedules/tests/tasks/__init__.py | 0 .../tests/tasks/shift_swaps/__init__.py | 0 .../tasks/shift_swaps/test_slack_messages.py | 114 +++++ .../test_drop_cached_ical.py} | 0 .../test_refresh_ical_files.py} | 0 .../tests/test_shift_swap_request.py | 62 +-- .../apps/slack/alert_group_slack_service.py | 19 +- engine/apps/slack/constants.py | 4 + engine/apps/slack/models/slack_message.py | 8 +- .../apps/slack/models/slack_team_identity.py | 8 + .../apps/slack/models/slack_user_identity.py | 14 +- .../slack/scenarios/alertgroup_appearance.py | 45 +- .../apps/slack/scenarios/declare_incident.py | 19 +- .../apps/slack/scenarios/distribute_alerts.py | 303 +++++++----- .../slack/scenarios/escalation_delivery.py | 10 +- .../slack/scenarios/invited_to_channel.py | 18 +- .../apps/slack/scenarios/manage_responders.py | 111 +++-- .../apps/slack/scenarios/manual_incident.py | 170 ++++--- .../slack/scenarios/notification_delivery.py | 12 +- .../scenarios/notified_user_not_in_channel.py | 18 +- engine/apps/slack/scenarios/onboarding.py | 29 +- engine/apps/slack/scenarios/paging.py | 430 +++++++++++------- engine/apps/slack/scenarios/profile_update.py | 23 +- .../apps/slack/scenarios/resolution_note.py | 256 ++++++----- engine/apps/slack/scenarios/scenario_step.py | 75 +-- engine/apps/slack/scenarios/schedules.py | 186 +++----- .../slack/scenarios/shift_swap_requests.py | 201 ++++++++ engine/apps/slack/scenarios/slack_channel.py | 55 ++- .../scenarios/slack_channel_integration.py | 50 +- engine/apps/slack/scenarios/slack_renderer.py | 7 +- .../apps/slack/scenarios/slack_usergroup.py | 34 +- engine/apps/slack/scenarios/step_mixins.py | 13 +- engine/apps/slack/slack_formatter.py | 3 - .../slack_teams_summary_change_list.html | 76 ---- .../slack/tests/test_create_message_blocks.py | 56 --- .../tests/test_interactive_api_endpoint.py | 4 +- .../tests/test_scenario_steps/test_paging.py | 55 ++- .../test_shift_swap_requests.py | 236 ++++++++++ engine/apps/slack/types/__init__.py | 8 + engine/apps/slack/types/block_elements.py | 256 +++++++++++ engine/apps/slack/types/blocks.py | 229 ++++++++++ engine/apps/slack/types/common.py | 215 +++++++++ .../apps/slack/types/composition_objects.py | 194 ++++++++ .../types/interaction_payloads/__init__.py | 24 + .../interaction_payloads/block_actions.py | 118 +++++ .../interaction_payloads/dialog_submission.py | 37 ++ .../interactive_messages.py | 77 ++++ .../types/interaction_payloads/shortcuts.py | 20 + .../interaction_payloads/slash_command.py | 50 ++ .../interaction_payloads/view_submission.py | 24 + engine/apps/slack/types/scenario_routes.py | 60 +++ engine/apps/slack/types/views.py | 90 ++++ engine/apps/slack/urls.py | 2 - engine/apps/slack/utils.py | 51 +-- engine/apps/slack/views.py | 94 ++-- .../migrations/0014_auto_20230728_0802.py | 25 + .../user_management/models/organization.py | 8 +- engine/apps/user_management/models/team.py | 5 +- engine/apps/user_management/models/user.py | 23 +- engine/conftest.py | 26 +- engine/settings/prod_without_db.py | 2 + 81 files changed, 3713 insertions(+), 1119 deletions(-) create mode 100644 engine/apps/alerts/migrations/0029_auto_20230728_0802.py create mode 100644 engine/apps/schedules/migrations/0015_shiftswaprequest_slack_message.py create mode 100644 engine/apps/schedules/tasks/shift_swaps/__init__.py create mode 100644 engine/apps/schedules/tasks/shift_swaps/slack_messages.py create mode 100644 engine/apps/schedules/tests/tasks/__init__.py create mode 100644 engine/apps/schedules/tests/tasks/shift_swaps/__init__.py create mode 100644 engine/apps/schedules/tests/tasks/shift_swaps/test_slack_messages.py rename engine/apps/schedules/tests/{test_tasks_drop_cached_ical.py => tasks/test_drop_cached_ical.py} (100%) rename engine/apps/schedules/tests/{test_tasks_refresh_ical_files.py => tasks/test_refresh_ical_files.py} (100%) create mode 100644 engine/apps/slack/scenarios/shift_swap_requests.py delete mode 100644 engine/apps/slack/templates/admin/slack_teams_summary_change_list.html delete mode 100644 engine/apps/slack/tests/test_create_message_blocks.py create mode 100644 engine/apps/slack/tests/test_scenario_steps/test_shift_swap_requests.py create mode 100644 engine/apps/slack/types/__init__.py create mode 100644 engine/apps/slack/types/block_elements.py create mode 100644 engine/apps/slack/types/blocks.py create mode 100644 engine/apps/slack/types/common.py create mode 100644 engine/apps/slack/types/composition_objects.py create mode 100644 engine/apps/slack/types/interaction_payloads/__init__.py create mode 100644 engine/apps/slack/types/interaction_payloads/block_actions.py create mode 100644 engine/apps/slack/types/interaction_payloads/dialog_submission.py create mode 100644 engine/apps/slack/types/interaction_payloads/interactive_messages.py create mode 100644 engine/apps/slack/types/interaction_payloads/shortcuts.py create mode 100644 engine/apps/slack/types/interaction_payloads/slash_command.py create mode 100644 engine/apps/slack/types/interaction_payloads/view_submission.py create mode 100644 engine/apps/slack/types/scenario_routes.py create mode 100644 engine/apps/slack/types/views.py create mode 100644 engine/apps/user_management/migrations/0014_auto_20230728_0802.py diff --git a/engine/apps/alerts/incident_appearance/renderers/base_renderer.py b/engine/apps/alerts/incident_appearance/renderers/base_renderer.py index f18fd6a3..d3161239 100644 --- a/engine/apps/alerts/incident_appearance/renderers/base_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/base_renderer.py @@ -1,10 +1,14 @@ +import typing from abc import ABC, abstractmethod from django.utils.functional import cached_property +if typing.TYPE_CHECKING: + from apps.alerts.models import Alert, AlertGroup + class AlertBaseRenderer(ABC): - def __init__(self, alert): + def __init__(self, alert: "Alert"): self.alert = alert @cached_property @@ -18,7 +22,7 @@ class AlertBaseRenderer(ABC): class AlertGroupBaseRenderer(ABC): - def __init__(self, alert_group, alert=None): + def __init__(self, alert_group: "AlertGroup", alert: typing.Optional["Alert"] = None): if alert is None: alert = alert_group.alerts.first() diff --git a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py index 1d4065d3..21d85260 100644 --- a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py @@ -1,15 +1,20 @@ import json +import typing from django.utils.text import Truncator from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer from apps.alerts.incident_appearance.templaters import AlertSlackTemplater from apps.slack.scenarios.scenario_step import ScenarioStep +from apps.slack.types import Block from common.utils import is_string_with_visible_characters, str_or_backup +if typing.TYPE_CHECKING: + from apps.alerts.models import Alert, AlertGroup + class AlertSlackRenderer(AlertBaseRenderer): - def __init__(self, alert): + def __init__(self, alert: "Alert"): super().__init__(alert) self.channel = alert.group.channel @@ -17,9 +22,9 @@ class AlertSlackRenderer(AlertBaseRenderer): def templater_class(self): return AlertSlackTemplater - def render_alert_blocks(self): + def render_alert_blocks(self) -> Block.AnyBlocks: BLOCK_SECTION_TEXT_MAX_SIZE = 2800 - blocks = [] + blocks: Block.AnyBlocks = [] title = Truncator(str_or_backup(self.templated_alert.title, "Alert")) blocks.append( @@ -62,7 +67,7 @@ class AlertSlackRenderer(AlertBaseRenderer): class AlertGroupSlackRenderer(AlertGroupBaseRenderer): - def __init__(self, alert_group): + def __init__(self, alert_group: "AlertGroup"): super().__init__(alert_group) # render the last alert content as Slack message, so Slack message is updated when a new alert comes @@ -72,8 +77,8 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): def alert_renderer_class(self): return AlertSlackRenderer - def render_alert_group_blocks(self): - blocks = self.alert_renderer.render_alert_blocks() + def render_alert_group_blocks(self) -> Block.AnyBlocks: + blocks: Block.AnyBlocks = self.alert_renderer.render_alert_blocks() alerts_count = self.alert_group.alerts.count() if alerts_count > 1: text = ( diff --git a/engine/apps/alerts/incident_log_builder/incident_log_builder.py b/engine/apps/alerts/incident_log_builder/incident_log_builder.py index 90f6fd30..86850715 100644 --- a/engine/apps/alerts/incident_log_builder/incident_log_builder.py +++ b/engine/apps/alerts/incident_log_builder/incident_log_builder.py @@ -1,20 +1,33 @@ +import typing + from django.db.models import Q from django.utils import timezone from apps.base.messaging import get_messaging_backend_from_id from apps.schedules.ical_utils import list_users_to_notify_from_ical +if typing.TYPE_CHECKING: + from django.db.models.manager import RelatedManager + + from apps.alerts.models import AlertGroup, AlertGroupLogRecord, ResolutionNote + from apps.base.models import UserNotificationPolicyLogRecord + class IncidentLogBuilder: - def __init__(self, alert_group): + def __init__(self, alert_group: "AlertGroup"): self.alert_group = alert_group - def get_log_records_list(self, with_resolution_notes=False): + def get_log_records_list( + self, with_resolution_notes: bool = False + ) -> typing.List[typing.Union["AlertGroupLogRecord", "ResolutionNote", "UserNotificationPolicyLogRecord"]]: """ - Generates list with AlertGroupLogRecord and UserNotificationPolicyLogRecord logs - :return: list with logs + Generates list of `AlertGroupLogRecord` and `UserNotificationPolicyLogRecord` logs. + + `ResolutionNote`s are optionally included if `with_resolution_notes` is `True`. """ - all_log_records = list() + all_log_records: typing.List[ + typing.Union["AlertGroupLogRecord", "ResolutionNote", "UserNotificationPolicyLogRecord"] + ] = list() # get logs from AlertGroupLogRecord alert_group_log_records = self._get_log_records_for_after_resolve_report() all_log_records.extend(alert_group_log_records) @@ -30,7 +43,7 @@ class IncidentLogBuilder: all_log_records_sorted = sorted(all_log_records, key=lambda log: log.created_at) return all_log_records_sorted - def _get_log_records_for_after_resolve_report(self): + def _get_log_records_for_after_resolve_report(self) -> "RelatedManager['AlertGroupLogRecord']": from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy excluded_log_types = [ @@ -83,7 +96,7 @@ class IncidentLogBuilder: .order_by("created_at") ) - def _get_user_notification_log_records_for_log_report(self): + def _get_user_notification_log_records_for_log_report(self) -> "RelatedManager['UserNotificationPolicyLogRecord']": from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord # exclude user notification logs with step 'wait' or with status 'finished' @@ -100,7 +113,7 @@ class IncidentLogBuilder: .order_by("created_at") ) - def _get_resolution_notes(self): + def _get_resolution_notes(self) -> "RelatedManager['ResolutionNote']": return self.alert_group.resolution_notes.select_related("author", "resolution_note_slack_message").order_by( "created_at" ) diff --git a/engine/apps/alerts/migrations/0029_auto_20230728_0802.py b/engine/apps/alerts/migrations/0029_auto_20230728_0802.py new file mode 100644 index 00000000..66b6583c --- /dev/null +++ b/engine/apps/alerts/migrations/0029_auto_20230728_0802.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.20 on 2023-07-28 08:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0013_alter_organization_acknowledge_remind_timeout'), + ('alerts', '0028_drop_alertreceivechannel_restricted_at'), + ] + + operations = [ + migrations.AlterField( + model_name='alertgroup', + name='acknowledged_by_user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acknowledged_alert_groups', to='user_management.user'), + ), + migrations.AlterField( + model_name='alertgroup', + name='wiped_by', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wiped_alert_groups', to='user_management.user'), + ), + ] diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index 2bc5e4df..dce585a2 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -1,5 +1,6 @@ import hashlib import logging +import typing from uuid import uuid4 from django.conf import settings @@ -14,6 +15,11 @@ from common.jinja_templater import apply_jinja_template from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length +if typing.TYPE_CHECKING: + from django.db.models.manager import RelatedManager + + from apps.alerts.models import AlertGroup + logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -33,6 +39,9 @@ def generate_public_primary_key_for_alert(): class Alert(models.Model): + group: typing.Optional["AlertGroup"] + resolved_alert_groups: "RelatedManager['AlertGroup']" + public_primary_key = models.CharField( max_length=20, validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)], diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 97e544be..7f78064e 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -34,7 +34,15 @@ from .alert_group_counter import AlertGroupCounter if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager - from apps.alerts.models import AlertGroupLogRecord + from apps.alerts.models import ( + Alert, + AlertGroupLogRecord, + AlertReceiveChannel, + ResolutionNote, + ResolutionNoteSlackMessage, + ) + from apps.base.models import UserNotificationPolicyLogRecord + from apps.slack.models import SlackMessage logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -131,9 +139,21 @@ class AlertGroupSlackRenderingMixin: class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.Model): + alerts: "RelatedManager['Alert']" + dependent_alert_groups: "RelatedManager['AlertGroup']" + channel: "AlertReceiveChannel" log_records: "RelatedManager['AlertGroupLogRecord']" + personal_log_records: "RelatedManager['UserNotificationPolicyLogRecord']" + resolution_notes: "RelatedManager['ResolutionNote']" + resolution_note_slack_messages: "RelatedManager['ResolutionNoteSlackMessage']" + resolved_by_alert: typing.Optional["Alert"] + root_alert_group: typing.Optional["AlertGroup"] + slack_message: typing.Optional["SlackMessage"] + slack_log_message: typing.Optional["SlackMessage"] + slack_messages: "RelatedManager['SlackMessage']" + users: "RelatedManager['User']" - objects = AlertGroupQuerySet.as_manager() + objects: models.Manager["AlertGroup"] = AlertGroupQuerySet.as_manager() ( NEW, @@ -231,6 +251,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. on_delete=models.SET_NULL, null=True, default=None, + related_name="acknowledged_alert_groups", ) acknowledged_by_confirmed = models.DateTimeField(null=True, default=None) @@ -315,7 +336,11 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. wiped_at = models.DateTimeField(null=True, default=None) wiped_by = models.ForeignKey( - "user_management.User", on_delete=models.SET_NULL, null=True, default=None, related_name="wiped_by_user" + "user_management.User", + on_delete=models.SET_NULL, + null=True, + default=None, + related_name="wiped_alert_groups", ) slack_message = models.OneToOneField( diff --git a/engine/apps/alerts/models/alert_group_log_record.py b/engine/apps/alerts/models/alert_group_log_record.py index e62d19cf..fedcbf6d 100644 --- a/engine/apps/alerts/models/alert_group_log_record.py +++ b/engine/apps/alerts/models/alert_group_log_record.py @@ -1,5 +1,6 @@ import json import logging +import typing import humanize from django.db import models @@ -13,11 +14,23 @@ from apps.alerts.utils import render_relative_timeline from apps.slack.slack_formatter import SlackFormatter from common.utils import clean_markup +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroup, CustomButton, EscalationPolicy, Invitation + from apps.user_management.models import User + logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) class AlertGroupLogRecord(models.Model): + alert_group: "AlertGroup" + author: typing.Optional["User"] + custom_button: typing.Optional["CustomButton"] + dependent_alert_group: typing.Optional["AlertGroup"] + escalation_policy: typing.Optional["EscalationPolicy"] + invitation: typing.Optional["Invitation"] + root_alert_group: typing.Optional["AlertGroup"] + ( TYPE_ACK, TYPE_UN_ACK, diff --git a/engine/apps/alerts/models/alert_manager_models.py b/engine/apps/alerts/models/alert_manager_models.py index 57995933..37e48383 100644 --- a/engine/apps/alerts/models/alert_manager_models.py +++ b/engine/apps/alerts/models/alert_manager_models.py @@ -6,7 +6,15 @@ from django.db import transaction from apps.alerts.models import Alert, AlertGroup -class AlertGroupForAlertManager(AlertGroup): +# NOTE: mypy was complaining about the following for both of these models. Likely because they subclass +# a model and django-mypy can't yet properly handle this +# +# error: Couldn't resolve related manager for relation 'users' +# (from apps.user_management.models.user.User.user_management.User.notification). [django-manager-missing] +# +# error: Couldn't resolve related manager for relation 'dependent_alert_groups' +# (from apps.alerts.models.alert_group.AlertGroup.alerts.AlertGroup.root_alert_group). [django-manager-missing] +class AlertGroupForAlertManager(AlertGroup): # type: ignore[django-manager-missing] MAX_ALERTS_IN_GROUP_FOR_AUTO_RESOLVE = 500 def is_alert_a_resolve_signal(self, alert): @@ -38,7 +46,7 @@ class AlertGroupForAlertManager(AlertGroup): proxy = True -class AlertForAlertManager(Alert): +class AlertForAlertManager(Alert): # type: ignore[django-manager-missing] def get_integration_optimization_hash(self): if self.integration_optimization_hash is None: with transaction.atomic(): diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index 5041a873..f060ce88 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -40,7 +40,8 @@ from common.public_primary_keys import generate_public_primary_key, increase_pub if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager - from apps.alerts.models import ChannelFilter, GrafanaAlertingContactPoint + from apps.alerts.models import AlertGroup, ChannelFilter, GrafanaAlertingContactPoint + from apps.user_management.models import Organization, Team logger = logging.getLogger(__name__) @@ -113,8 +114,11 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): Channel generated by user to receive Alerts to. """ + alert_groups: "RelatedManager['AlertGroup']" channel_filters: "RelatedManager['ChannelFilter']" contact_points: "RelatedManager['GrafanaAlertingContactPoint']" + organization: "Organization" + team: typing.Optional["Team"] objects = AlertReceiveChannelManager() objects_with_maintenance = AlertReceiveChannelManagerWithMaintenance() diff --git a/engine/apps/alerts/models/channel_filter.py b/engine/apps/alerts/models/channel_filter.py index ff98ff6c..2a9afdfe 100644 --- a/engine/apps/alerts/models/channel_filter.py +++ b/engine/apps/alerts/models/channel_filter.py @@ -1,6 +1,7 @@ import json import logging import re +import typing from django.conf import settings from django.core.validators import MinLengthValidator @@ -11,6 +12,11 @@ from common.jinja_templater.apply_jinja_template import JinjaTemplateError, Jinj from common.ordered_model.ordered_model import OrderedModel from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length +if typing.TYPE_CHECKING: + from django.db.models.manager import RelatedManager + + from apps.alerts.models import AlertGroup + logger = logging.getLogger(__name__) @@ -33,6 +39,8 @@ class ChannelFilter(OrderedModel): Actually it's a Router based on terms now. Not a Filter. """ + alert_groups: "RelatedManager['AlertGroup']" + order_with_respect_to = ["alert_receive_channel_id", "is_default"] public_primary_key = models.CharField( diff --git a/engine/apps/alerts/models/resolution_note.py b/engine/apps/alerts/models/resolution_note.py index 8086a235..da65ab94 100644 --- a/engine/apps/alerts/models/resolution_note.py +++ b/engine/apps/alerts/models/resolution_note.py @@ -1,3 +1,5 @@ +import typing + import humanize from django.conf import settings from django.core.validators import MinLengthValidator @@ -9,6 +11,9 @@ from apps.slack.slack_formatter import SlackFormatter from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length from common.utils import clean_markup +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroup + def generate_public_primary_key_for_alert_group_postmortem(): prefix = "P" @@ -47,6 +52,9 @@ class ResolutionNoteSlackMessageQueryset(models.QuerySet): class ResolutionNoteSlackMessage(models.Model): + alert_group: "AlertGroup" + resolution_note: typing.Optional["ResolutionNote"] + alert_group = models.ForeignKey( "alerts.AlertGroup", on_delete=models.CASCADE, @@ -75,17 +83,17 @@ class ResolutionNoteSlackMessage(models.Model): class Meta: unique_together = ("thread_ts", "ts") - def get_resolution_note(self): + def get_resolution_note(self) -> typing.Optional["ResolutionNote"]: try: return self.resolution_note except ResolutionNoteSlackMessage.resolution_note.RelatedObjectDoesNotExist: return None - def delete(self): + def delete(self, *args, **kwargs) -> typing.Tuple[int, typing.Dict[str, int]]: resolution_note = self.get_resolution_note() if resolution_note: resolution_note.delete() - super().delete() + return super().delete(*args, **kwargs) class ResolutionNoteQueryset(models.QuerySet): @@ -100,6 +108,9 @@ class ResolutionNoteQueryset(models.QuerySet): class ResolutionNote(models.Model): + alert_group: "AlertGroup" + resolution_note_slack_message: typing.Optional[ResolutionNoteSlackMessage] + objects = ResolutionNoteQueryset.as_manager() objects_with_deleted = models.Manager() diff --git a/engine/apps/alerts/paging.py b/engine/apps/alerts/paging.py index ddcc4ac8..0d275387 100644 --- a/engine/apps/alerts/paging.py +++ b/engine/apps/alerts/paging.py @@ -1,4 +1,5 @@ -from typing import Any +import enum +import typing from uuid import uuid4 from django.db import transaction @@ -18,14 +19,37 @@ from apps.schedules.ical_utils import list_users_to_notify_from_ical from apps.schedules.models import OnCallSchedule from apps.user_management.models import Organization, Team, User -USER_HAS_NO_NOTIFICATION_POLICY = "USER_HAS_NO_NOTIFICATION_POLICY" -USER_IS_NOT_ON_CALL = "USER_IS_NOT_ON_CALL" + +class PagingError(enum.StrEnum): + USER_HAS_NO_NOTIFICATION_POLICY = "USER_HAS_NO_NOTIFICATION_POLICY" + USER_IS_NOT_ON_CALL = "USER_IS_NOT_ON_CALL" + # notifications: (User|Schedule, important) UserNotifications = list[tuple[User, bool]] ScheduleNotifications = list[tuple[OnCallSchedule, bool]] +class NoNotificationPolicyWarning(typing.TypedDict): + error: typing.Literal[PagingError.USER_HAS_NO_NOTIFICATION_POLICY] + data: typing.Dict + + +ScheduleWarnings = typing.Dict[str, typing.List[str]] + + +class _NotOnCallWarningData(typing.TypedDict): + schedules: ScheduleWarnings + + +class NotOnCallWarning(typing.TypedDict): + error: typing.Literal[PagingError.USER_IS_NOT_ON_CALL] + data: _NotOnCallWarningData + + +AvailabilityWarning = NoNotificationPolicyWarning | NotOnCallWarning + + class DirectPagingAlertGroupResolvedError(Exception): """Raised when trying to use direct paging for a resolved alert group.""" @@ -96,16 +120,16 @@ def _trigger_alert( return alert.group -def check_user_availability(user: User) -> list[dict[str, Any]]: +def check_user_availability(user: User) -> typing.List[AvailabilityWarning]: """Check user availability to be paged. Return a warnings list indicating `error` and any additional related `data`. """ - warnings = [] + warnings: typing.List[AvailabilityWarning] = [] if not user.notification_policies.exists(): warnings.append( { - "error": USER_HAS_NO_NOTIFICATION_POLICY, + "error": PagingError.USER_HAS_NO_NOTIFICATION_POLICY, "data": {}, } ) @@ -115,7 +139,7 @@ def check_user_availability(user: User) -> list[dict[str, Any]]: Q(cached_ical_file_primary__contains=user.username) | Q(cached_ical_file_primary__contains=user.email), organization=user.organization, ) - schedules_data = {} + schedules_data: ScheduleWarnings = {} for s in schedules: # keep track of schedules and on call users to suggest if needed oncall_users = list_users_to_notify_from_ical(s) @@ -129,7 +153,7 @@ def check_user_availability(user: User) -> list[dict[str, Any]]: # TODO: check working hours warnings.append( { - "error": USER_IS_NOT_ON_CALL, + "error": PagingError.USER_IS_NOT_ON_CALL, "data": {"schedules": schedules_data}, } ) @@ -143,9 +167,9 @@ def direct_paging( from_user: User, title: str = None, message: str = None, - users: UserNotifications = None, - schedules: ScheduleNotifications = None, - escalation_chain: EscalationChain = None, + users: UserNotifications | None = None, + schedules: ScheduleNotifications | None = None, + escalation_chain: EscalationChain | None = None, alert_group: AlertGroup | None = None, ) -> AlertGroup | None: """Trigger escalation targeting given users/schedules. diff --git a/engine/apps/alerts/tests/test_paging.py b/engine/apps/alerts/tests/test_paging.py index 21dca596..64627314 100644 --- a/engine/apps/alerts/tests/test_paging.py +++ b/engine/apps/alerts/tests/test_paging.py @@ -4,13 +4,7 @@ import pytest from django.utils import timezone from apps.alerts.models import AlertGroup, AlertGroupLogRecord, UserHasNotification -from apps.alerts.paging import ( - USER_HAS_NO_NOTIFICATION_POLICY, - USER_IS_NOT_ON_CALL, - check_user_availability, - direct_paging, - unpage_user, -) +from apps.alerts.paging import PagingError, check_user_availability, direct_paging, unpage_user from apps.base.models import UserNotificationPolicy from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb @@ -72,8 +66,8 @@ def test_check_user_availability_no_policies(make_organization, make_user_for_or warnings = check_user_availability(user) assert warnings == [ - {"data": {}, "error": USER_HAS_NO_NOTIFICATION_POLICY}, - {"data": {"schedules": {}}, "error": USER_IS_NOT_ON_CALL}, + {"data": {}, "error": PagingError.USER_HAS_NO_NOTIFICATION_POLICY}, + {"data": {"schedules": {}}, "error": PagingError.USER_IS_NOT_ON_CALL}, ] @@ -97,7 +91,10 @@ def test_check_user_availability_not_on_call( warnings = check_user_availability(user) assert warnings == [ - {"data": {"schedules": {schedule.name: {other_user.public_primary_key}}}, "error": USER_IS_NOT_ON_CALL}, + { + "data": {"schedules": {schedule.name: {other_user.public_primary_key}}}, + "error": PagingError.USER_IS_NOT_ON_CALL, + }, ] diff --git a/engine/apps/api/tests/test_shift_swaps.py b/engine/apps/api/tests/test_shift_swaps.py index ae492fe5..094b9f71 100644 --- a/engine/apps/api/tests/test_shift_swaps.py +++ b/engine/apps/api/tests/test_shift_swaps.py @@ -147,9 +147,14 @@ def test_retrieve_permissions( @patch("apps.api.views.shift_swap.write_resource_insight_log") +@patch("apps.api.views.shift_swap.create_shift_swap_request_message") @pytest.mark.django_db def test_create( - mock_write_resource_insight_log, make_organization_and_user_with_plugin_token, make_schedule, make_user_auth_headers + mock_create_shift_swap_request_message, + mock_write_resource_insight_log, + make_organization_and_user_with_plugin_token, + make_schedule, + make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token() schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) @@ -175,6 +180,7 @@ def test_create( assert response.json() == expected_response mock_write_resource_insight_log.assert_called_once_with(instance=ssr, author=user, event=EntityEvent.CREATED) + mock_create_shift_swap_request_message.apply_async.assert_called_once_with((ssr.pk,)) @pytest.mark.django_db @@ -261,8 +267,11 @@ def test_create_permissions( @patch("apps.api.views.shift_swap.write_resource_insight_log") +@patch("apps.api.views.shift_swap.update_shift_swap_request_message") @pytest.mark.django_db -def test_update(mock_write_resource_insight_log, ssr_setup, make_user_auth_headers): +def test_update( + mock_update_shift_swap_request_message, mock_write_resource_insight_log, ssr_setup, make_user_auth_headers +): ssr, beneficiary, token, _ = ssr_setup(description=description) insights_log_prev_state = ssr.insight_logs_serialized @@ -296,6 +305,8 @@ def test_update(mock_write_resource_insight_log, ssr_setup, make_user_auth_heade new_state=ssr.insight_logs_serialized, ) + mock_update_shift_swap_request_message.apply_async.assert_called_once_with((ssr.pk,)) + @pytest.mark.django_db @pytest.mark.parametrize( @@ -394,8 +405,11 @@ def test_update_others_ssr_permissions(ssr_setup, make_user_auth_headers): @patch("apps.api.views.shift_swap.write_resource_insight_log") +@patch("apps.api.views.shift_swap.update_shift_swap_request_message") @pytest.mark.django_db -def test_partial_update(mock_write_resource_insight_log, ssr_setup, make_user_auth_headers): +def test_partial_update( + mock_update_shift_swap_request_message, mock_write_resource_insight_log, ssr_setup, make_user_auth_headers +): ssr, beneficiary, token, _ = ssr_setup(description=description) insights_log_prev_state = ssr.insight_logs_serialized @@ -424,6 +438,8 @@ def test_partial_update(mock_write_resource_insight_log, ssr_setup, make_user_au new_state=ssr.insight_logs_serialized, ) + mock_update_shift_swap_request_message.apply_async.assert_called_once_with((ssr.pk,)) + @pytest.mark.django_db def test_partial_update_time_related_fields(ssr_setup, make_user_auth_headers): @@ -547,8 +563,11 @@ def test_benefactor_and_beneficiary_are_read_only_fields(ssr_setup, make_user_au @patch("apps.api.views.shift_swap.write_resource_insight_log") +@patch("apps.api.views.shift_swap.update_shift_swap_request_message") @pytest.mark.django_db -def test_delete(mock_write_resource_insight_log, ssr_setup, make_user_auth_headers): +def test_delete( + mock_update_shift_swap_request_message, mock_write_resource_insight_log, ssr_setup, make_user_auth_headers +): ssr, beneficiary, token, _ = ssr_setup() client = APIClient() url = reverse("api-internal:shift_swap-detail", kwargs={"pk": ssr.public_primary_key}) @@ -566,6 +585,8 @@ def test_delete(mock_write_resource_insight_log, ssr_setup, make_user_auth_heade event=EntityEvent.DELETED, ) + mock_update_shift_swap_request_message.apply_async.assert_called_once_with((ssr.pk,)) + @pytest.mark.django_db @pytest.mark.parametrize( diff --git a/engine/apps/api/views/shift_swap.py b/engine/apps/api/views/shift_swap.py index 0120a16f..30a2a8f9 100644 --- a/engine/apps/api/views/shift_swap.py +++ b/engine/apps/api/views/shift_swap.py @@ -12,6 +12,7 @@ from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.schedules import exceptions from apps.schedules.models import ShiftSwapRequest +from apps.schedules.tasks.shift_swaps import create_shift_swap_request_message, update_shift_swap_request_message from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import FiftyPageSizePaginator @@ -55,27 +56,37 @@ class ShiftSwapViewSet(PublicPrimaryKeyMixin, ModelViewSet): queryset = ShiftSwapRequest.objects.filter(schedule__organization=self.request.auth.organization) return self.serializer_class.setup_eager_loading(queryset) - def perform_destroy(self, instance): + def perform_destroy(self, instance) -> None: + # TODO: should we allow deleting a taken request? if so we will have to undo the overrides that were generated + super().perform_destroy(instance) write_resource_insight_log(instance=instance, author=self.request.user, event=EntityEvent.DELETED) - def perform_create(self, serializer): - beneficiary = self.request.user - serializer.save(beneficiary=beneficiary) - write_resource_insight_log(instance=serializer.instance, author=beneficiary, event=EntityEvent.CREATED) + update_shift_swap_request_message.apply_async((instance.pk,)) - def perform_update(self, serializer): + def perform_create(self, serializer) -> None: + beneficiary = self.request.user + shift_swap_request = serializer.save(beneficiary=beneficiary) + + write_resource_insight_log(instance=shift_swap_request, author=beneficiary, event=EntityEvent.CREATED) + + create_shift_swap_request_message.apply_async((shift_swap_request.pk,)) + + def perform_update(self, serializer) -> None: prev_state = serializer.instance.insight_logs_serialized serializer.save() - new_state = serializer.instance.insight_logs_serialized + shift_swap_request = serializer.instance + write_resource_insight_log( - instance=serializer.instance, + instance=shift_swap_request, author=self.request.user, event=EntityEvent.UPDATED, prev_state=prev_state, - new_state=new_state, + new_state=shift_swap_request.insight_logs_serialized, ) + update_shift_swap_request_message.apply_async((shift_swap_request.pk,)) + @action(methods=["post"], detail=True) def take(self, request, pk) -> Response: shift_swap = self.get_object() diff --git a/engine/apps/schedules/migrations/0015_shiftswaprequest_slack_message.py b/engine/apps/schedules/migrations/0015_shiftswaprequest_slack_message.py new file mode 100644 index 00000000..660be65d --- /dev/null +++ b/engine/apps/schedules/migrations/0015_shiftswaprequest_slack_message.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.20 on 2023-07-26 07:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('slack', '0003_delete_slackactionrecord'), + ('schedules', '0014_shiftswaprequest'), + ] + + operations = [ + migrations.AddField( + model_name='shiftswaprequest', + name='slack_message', + field=models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shift_swap_request', to='slack.slackmessage'), + ), + ] diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 62cc061f..ade4da18 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -50,6 +50,8 @@ if typing.TYPE_CHECKING: from apps.alerts.models import EscalationPolicy from apps.auth_token.models import ScheduleExportAuthToken + from apps.slack.models import SlackUserGroup + from apps.user_management.models import Organization, Team RE_ICAL_SEARCH_USERNAME = r"SUMMARY:(\[L[0-9]+\] )?{}" @@ -153,7 +155,11 @@ class OnCallScheduleQuerySet(PolymorphicQuerySet): class OnCallSchedule(PolymorphicModel): - objects = PolymorphicManager.from_queryset(OnCallScheduleQuerySet)() + organization: "Organization" + slack_user_group: typing.Optional["SlackUserGroup"] + team: typing.Optional["Team"] + + objects: models.Manager["OnCallSchedule"] = PolymorphicManager.from_queryset(OnCallScheduleQuerySet)() # type of calendars in schedule TYPE_ICAL_PRIMARY, TYPE_ICAL_OVERRIDES, TYPE_CALENDAR = range( @@ -229,6 +235,14 @@ class OnCallSchedule(PolymorphicModel): has_empty_shifts = models.BooleanField(default=False) empty_shifts_report_sent_at = models.DateField(null=True, default=None) + @property + def web_page_link(self) -> str: + return f"{self.organization.web_link}schedules" + + @property + def web_detail_page_link(self) -> str: + return f"{self.web_page_link}/{self.public_primary_key}" + def get_icalendars(self) -> typing.Tuple[typing.Optional[icalendar.Calendar], typing.Optional[icalendar.Calendar]]: """Returns list of calendars. Primary calendar should always be the first""" calendar_primary: typing.Optional[icalendar.Calendar] = None diff --git a/engine/apps/schedules/models/shift_swap_request.py b/engine/apps/schedules/models/shift_swap_request.py index c8453f02..f7ce7997 100644 --- a/engine/apps/schedules/models/shift_swap_request.py +++ b/engine/apps/schedules/models/shift_swap_request.py @@ -10,7 +10,9 @@ from apps.schedules import exceptions from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: - from apps.user_management.models import User + from apps.schedules.models import OnCallSchedule + from apps.slack.models import SlackMessage + from apps.user_management.models import Organization, User def generate_public_primary_key_for_shift_swap_request() -> str: @@ -41,8 +43,13 @@ class ShiftSwapRequestManager(models.Manager): class ShiftSwapRequest(models.Model): - objects = ShiftSwapRequestManager() - objects_with_deleted = models.Manager() + beneficiary: "User" + benefactor: typing.Optional["User"] + schedule: "OnCallSchedule" + slack_message: typing.Optional["SlackMessage"] + + objects: models.Manager["ShiftSwapRequest"] = ShiftSwapRequestManager() + objects_with_deleted: models.Manager["ShiftSwapRequest"] = models.Manager() public_primary_key = models.CharField( max_length=20, @@ -87,6 +94,17 @@ class ShiftSwapRequest(models.Model): the person taking on shift workload from the beneficiary """ + slack_message = models.OneToOneField( + "slack.SlackMessage", + on_delete=models.SET_NULL, + null=True, + default=None, + related_name="shift_swap_request", + ) + """ + if set, represents the Slack message that was sent when the shift swap request was created + """ + class Statuses(enum.StrEnum): OPEN = "open" TAKEN = "taken" @@ -96,6 +114,45 @@ class ShiftSwapRequest(models.Model): def __str__(self) -> str: return f"{self.schedule.name} {self.beneficiary.username} {self.swap_start} - {self.swap_end}" + @property + def is_deleted(self) -> bool: + return self.deleted_at is not None + + @property + def is_taken(self) -> bool: + return self.benefactor is not None + + @property + def is_past_due(self) -> bool: + return timezone.now() > self.swap_start + + @property + def status(self) -> str: + if self.is_deleted: + return self.Statuses.DELETED + elif self.is_taken: + return self.Statuses.TAKEN + elif self.is_past_due: + return self.Statuses.PAST_DUE + return self.Statuses.OPEN + + @property + def slack_channel_id(self) -> str | None: + """ + This is only set if the schedule associated with the shift swap request + has a Slack channel configured for it. + """ + return self.schedule.channel + + @property + def organization(self) -> "Organization": + return self.schedule.organization + + @property + def web_link(self) -> str: + # TODO: finish this once we know the proper URL we'll need + return f"{self.schedule.web_detail_page_link}" + def delete(self): self.deleted_at = timezone.now() self.save() @@ -103,16 +160,6 @@ class ShiftSwapRequest(models.Model): def hard_delete(self): super().delete() - @property - def status(self) -> str: - if self.deleted_at is not None: - return self.Statuses.DELETED - elif self.benefactor is not None: - return self.Statuses.TAKEN - elif timezone.now() > self.swap_start: - return self.Statuses.PAST_DUE - return self.Statuses.OPEN - def take(self, benefactor: "User") -> None: if benefactor == self.beneficiary: raise exceptions.BeneficiaryCannotTakeOwnShiftSwapRequest() diff --git a/engine/apps/schedules/tasks/shift_swaps/__init__.py b/engine/apps/schedules/tasks/shift_swaps/__init__.py new file mode 100644 index 00000000..69c45308 --- /dev/null +++ b/engine/apps/schedules/tasks/shift_swaps/__init__.py @@ -0,0 +1 @@ +from .slack_messages import create_shift_swap_request_message, update_shift_swap_request_message # noqa: F401 diff --git a/engine/apps/schedules/tasks/shift_swaps/slack_messages.py b/engine/apps/schedules/tasks/shift_swaps/slack_messages.py new file mode 100644 index 00000000..b552e3d0 --- /dev/null +++ b/engine/apps/schedules/tasks/shift_swaps/slack_messages.py @@ -0,0 +1,64 @@ +from celery.utils.log import get_task_logger + +from common.custom_celery_tasks import shared_dedicated_queue_retry_task + +task_logger = get_task_logger(__name__) + + +@shared_dedicated_queue_retry_task() +def create_shift_swap_request_message(shift_swap_request_pk: str) -> None: + from apps.schedules.models import ShiftSwapRequest + from apps.slack.scenarios.shift_swap_requests import BaseShiftSwapRequestStep + + task_logger.info(f"Start create_shift_swap_request_message: pk = {shift_swap_request_pk}") + + try: + shift_swap_request = ShiftSwapRequest.objects.get(pk=shift_swap_request_pk) + except ShiftSwapRequest.DoesNotExist: + task_logger.info( + f"Tried to create_shift_swap_request_message for non-existing shift swap request {shift_swap_request_pk}" + ) + return + + if shift_swap_request.slack_channel_id is None: + task_logger.info( + f"Skipping create_shift_swap_request_message for shift_swap_request {shift_swap_request_pk} because channel_id is None" + ) + return + + organization = shift_swap_request.organization + + step = BaseShiftSwapRequestStep(organization.slack_team_identity, organization) + slack_message = step.create_message(shift_swap_request) + + shift_swap_request.slack_message = slack_message + shift_swap_request.save(update_fields=["slack_message"]) + + +@shared_dedicated_queue_retry_task() +def update_shift_swap_request_message(shift_swap_request_pk: str) -> None: + from apps.schedules.models import ShiftSwapRequest + from apps.slack.scenarios.shift_swap_requests import BaseShiftSwapRequestStep + + task_logger.info(f"Start update_shift_swap_request_message: pk = {shift_swap_request_pk}") + + try: + # NOTE: need to use objects_with_deleted here because we may be updating the slack message + # for a swap request that was deleted + shift_swap_request = ShiftSwapRequest.objects_with_deleted.get(pk=shift_swap_request_pk) + except ShiftSwapRequest.DoesNotExist: + task_logger.info( + f"Tried to update_shift_swap_request_message for non-existing shift swap request {shift_swap_request_pk}" + ) + return + + if shift_swap_request.slack_channel_id is None: + task_logger.info( + f"Skipping update_shift_swap_request_message for shift_swap_request {shift_swap_request_pk} because channel_id is None" + ) + return + + organization = shift_swap_request.organization + + step = BaseShiftSwapRequestStep(organization.slack_team_identity, organization) + step.update_message(shift_swap_request) diff --git a/engine/apps/schedules/tests/tasks/__init__.py b/engine/apps/schedules/tests/tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/schedules/tests/tasks/shift_swaps/__init__.py b/engine/apps/schedules/tests/tasks/shift_swaps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/schedules/tests/tasks/shift_swaps/test_slack_messages.py b/engine/apps/schedules/tests/tasks/shift_swaps/test_slack_messages.py new file mode 100644 index 00000000..dadcdb71 --- /dev/null +++ b/engine/apps/schedules/tests/tasks/shift_swaps/test_slack_messages.py @@ -0,0 +1,114 @@ +from unittest.mock import patch + +import pytest + +from apps.schedules.tasks.shift_swaps import slack_messages as slack_msg_tasks + + +@patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep") +@pytest.mark.django_db +def test_create_shift_swap_request_message_not_found(MockBaseShiftSwapRequestStep): + slack_msg_tasks.create_shift_swap_request_message("12345") + + MockBaseShiftSwapRequestStep.assert_not_called() + MockBaseShiftSwapRequestStep.return_value.create_message.assert_not_called() + + +@patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep") +@pytest.mark.django_db +def test_create_shift_swap_request_message_no_configured_slack_channel_for_schedule( + MockBaseShiftSwapRequestStep, + shift_swap_request_setup, +): + ssr, _, _ = shift_swap_request_setup() + assert ssr.schedule.channel is None + + slack_msg_tasks.create_shift_swap_request_message(ssr.pk) + + MockBaseShiftSwapRequestStep.assert_not_called() + MockBaseShiftSwapRequestStep.return_value.create_message.assert_not_called() + + +@patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep") +@pytest.mark.django_db +def test_create_shift_swap_request_message_post_message_to_channel_called( + MockBaseShiftSwapRequestStep, + shift_swap_request_setup, + make_slack_message, + make_slack_team_identity, +): + slack_channel_id = "C1234ASDFJ" + + ssr, _, _ = shift_swap_request_setup() + schedule = ssr.schedule + organization = schedule.organization + + slack_message = make_slack_message(alert_group=None, organization=organization, slack_id="12345") + slack_team_identity = make_slack_team_identity() + + MockBaseShiftSwapRequestStep.return_value.create_message.return_value = slack_message + + schedule.channel = slack_channel_id + schedule.save() + + organization.slack_team_identity = slack_team_identity + organization.save() + + slack_msg_tasks.create_shift_swap_request_message(ssr.pk) + + MockBaseShiftSwapRequestStep.assert_called_once_with(slack_team_identity, organization) + MockBaseShiftSwapRequestStep.return_value.create_message.assert_called_once_with(ssr) + + ssr.refresh_from_db() + assert ssr.slack_message.pk == str(slack_message.pk) + + +@patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep") +@pytest.mark.django_db +def test_update_shift_swap_request_message_not_found(MockBaseShiftSwapRequestStep): + slack_msg_tasks.update_shift_swap_request_message("12345") + + MockBaseShiftSwapRequestStep.assert_not_called() + MockBaseShiftSwapRequestStep.return_value.update_message.assert_not_called() + + +@patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep") +@pytest.mark.django_db +def test_update_shift_swap_request_message_no_configured_slack_channel_for_schedule( + MockBaseShiftSwapRequestStep, + shift_swap_request_setup, +): + ssr, _, _ = shift_swap_request_setup() + assert ssr.schedule.channel is None + + slack_msg_tasks.update_shift_swap_request_message(ssr.pk) + + MockBaseShiftSwapRequestStep.assert_not_called() + MockBaseShiftSwapRequestStep.return_value.update_message.assert_not_called() + + +@patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep") +@pytest.mark.django_db +def test_update_shift_swap_request_message_post_message_to_channel_called( + MockBaseShiftSwapRequestStep, + shift_swap_request_setup, + make_slack_team_identity, +): + slack_channel_id = "C1234ASDFJ" + + ssr, _, _ = shift_swap_request_setup() + schedule = ssr.schedule + organization = schedule.organization + + slack_team_identity = make_slack_team_identity() + + schedule.channel = slack_channel_id + schedule.save() + + organization.slack_team_identity = slack_team_identity + organization.save() + + slack_msg_tasks.update_shift_swap_request_message(ssr.pk) + + MockBaseShiftSwapRequestStep.assert_called_once_with(slack_team_identity, organization) + MockBaseShiftSwapRequestStep.return_value.update_message.assert_called_once_with(ssr) diff --git a/engine/apps/schedules/tests/test_tasks_drop_cached_ical.py b/engine/apps/schedules/tests/tasks/test_drop_cached_ical.py similarity index 100% rename from engine/apps/schedules/tests/test_tasks_drop_cached_ical.py rename to engine/apps/schedules/tests/tasks/test_drop_cached_ical.py diff --git a/engine/apps/schedules/tests/test_tasks_refresh_ical_files.py b/engine/apps/schedules/tests/tasks/test_refresh_ical_files.py similarity index 100% rename from engine/apps/schedules/tests/test_tasks_refresh_ical_files.py rename to engine/apps/schedules/tests/tasks/test_refresh_ical_files.py diff --git a/engine/apps/schedules/tests/test_shift_swap_request.py b/engine/apps/schedules/tests/test_shift_swap_request.py index fbfa02df..68c187ad 100644 --- a/engine/apps/schedules/tests/test_shift_swap_request.py +++ b/engine/apps/schedules/tests/test_shift_swap_request.py @@ -1,32 +1,14 @@ import datetime import pytest -from django.utils import timezone from apps.schedules import exceptions -from apps.schedules.models import OnCallScheduleWeb, ShiftSwapRequest - - -@pytest.fixture -def ssr_setup(make_schedule, make_organization_and_user, make_user_for_organization, make_shift_swap_request): - def _ssr_setup(): - organization, beneficiary = make_organization_and_user() - benefactor = make_user_for_organization(organization) - - schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) - tomorrow = timezone.now() + datetime.timedelta(days=1) - two_days_from_now = tomorrow + datetime.timedelta(days=1) - - ssr = make_shift_swap_request(schedule, beneficiary, swap_start=tomorrow, swap_end=two_days_from_now) - - return ssr, beneficiary, benefactor - - return _ssr_setup +from apps.schedules.models import ShiftSwapRequest @pytest.mark.django_db -def test_soft_delete(ssr_setup): - ssr, _, _ = ssr_setup() +def test_soft_delete(shift_swap_request_setup): + ssr, _, _ = shift_swap_request_setup() assert ssr.deleted_at is None ssr.delete() @@ -38,43 +20,49 @@ def test_soft_delete(ssr_setup): @pytest.mark.django_db -def test_status_open(ssr_setup) -> None: - ssr, _, _ = ssr_setup() +def test_status_open(shift_swap_request_setup) -> None: + ssr, _, _ = shift_swap_request_setup() assert ssr.status == ShiftSwapRequest.Statuses.OPEN @pytest.mark.django_db -def test_status_taken(ssr_setup) -> None: - ssr, _, benefactor = ssr_setup() +def test_status_taken(shift_swap_request_setup) -> None: + ssr, _, benefactor = shift_swap_request_setup() assert ssr.status == ShiftSwapRequest.Statuses.OPEN + assert ssr.is_taken is False ssr.benefactor = benefactor ssr.save() assert ssr.status == ShiftSwapRequest.Statuses.TAKEN + assert ssr.is_taken is True @pytest.mark.django_db -def test_status_past_due(ssr_setup) -> None: - ssr, _, _ = ssr_setup() +def test_status_past_due(shift_swap_request_setup) -> None: + ssr, _, _ = shift_swap_request_setup() assert ssr.status == ShiftSwapRequest.Statuses.OPEN + assert ssr.is_past_due is False ssr.swap_start = ssr.swap_start - datetime.timedelta(days=5) ssr.save() assert ssr.status == ShiftSwapRequest.Statuses.PAST_DUE + assert ssr.is_past_due is True @pytest.mark.django_db -def test_status_deleted(ssr_setup) -> None: - ssr, _, _ = ssr_setup() +def test_status_deleted(shift_swap_request_setup) -> None: + ssr, _, _ = shift_swap_request_setup() assert ssr.status == ShiftSwapRequest.Statuses.OPEN + assert ssr.is_deleted is False ssr.delete() assert ssr.status == ShiftSwapRequest.Statuses.DELETED + assert ssr.is_deleted is True @pytest.mark.django_db -def test_take(ssr_setup) -> None: - ssr, _, benefactor = ssr_setup() +def test_take(shift_swap_request_setup) -> None: + ssr, _, benefactor = shift_swap_request_setup() original_updated_at = ssr.updated_at ssr.take(benefactor) @@ -86,9 +74,9 @@ def test_take(ssr_setup) -> None: @pytest.mark.django_db -def test_take_only_works_for_open_requests(ssr_setup) -> None: +def test_take_only_works_for_open_requests(shift_swap_request_setup) -> None: # already taken - ssr, _, benefactor = ssr_setup() + ssr, _, benefactor = shift_swap_request_setup() ssr.benefactor = benefactor ssr.save() @@ -98,7 +86,7 @@ def test_take_only_works_for_open_requests(ssr_setup) -> None: ssr.take(benefactor) # past due - ssr, _, benefactor = ssr_setup() + ssr, _, benefactor = shift_swap_request_setup() ssr.swap_start = ssr.swap_start - datetime.timedelta(days=5) ssr.save() @@ -108,7 +96,7 @@ def test_take_only_works_for_open_requests(ssr_setup) -> None: ssr.take(benefactor) # deleted - ssr, _, benefactor = ssr_setup() + ssr, _, benefactor = shift_swap_request_setup() ssr.delete() assert ssr.status == ShiftSwapRequest.Statuses.DELETED @@ -118,7 +106,7 @@ def test_take_only_works_for_open_requests(ssr_setup) -> None: @pytest.mark.django_db -def test_take_own_ssr(ssr_setup) -> None: - ssr, beneficiary, _ = ssr_setup() +def test_take_own_ssr(shift_swap_request_setup) -> None: + ssr, beneficiary, _ = shift_swap_request_setup() with pytest.raises(exceptions.BeneficiaryCannotTakeOwnShiftSwapRequest): ssr.take(beneficiary) diff --git a/engine/apps/slack/alert_group_slack_service.py b/engine/apps/slack/alert_group_slack_service.py index 362f8948..971166b3 100644 --- a/engine/apps/slack/alert_group_slack_service.py +++ b/engine/apps/slack/alert_group_slack_service.py @@ -1,4 +1,5 @@ import logging +import typing from apps.slack.constants import SLACK_RATE_LIMIT_DELAY from apps.slack.slack_client import SlackClientWithErrorHandling @@ -9,18 +10,28 @@ from apps.slack.slack_client.exceptions import ( SlackAPITokenException, ) +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroup + from apps.slack.models import SlackTeamIdentity + logger = logging.getLogger(__name__) class AlertGroupSlackService: - def __init__(self, slack_team_identity, slack_client=None): + _slack_client: SlackClientWithErrorHandling + + def __init__( + self, + slack_team_identity: "SlackTeamIdentity", + slack_client: typing.Optional[SlackClientWithErrorHandling] = None, + ): self.slack_team_identity = slack_team_identity if slack_client is not None: self._slack_client = slack_client else: self._slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token) - def update_alert_group_slack_message(self, alert_group): + def update_alert_group_slack_message(self, alert_group: "AlertGroup") -> None: logger.info(f"Started _update_slack_message for alert_group {alert_group.pk}") from apps.alerts.models import AlertReceiveChannel from apps.slack.models import SlackMessage @@ -77,8 +88,8 @@ class AlertGroupSlackService: logger.info(f"Finished _update_slack_message for alert_group {alert_group.pk}") def publish_message_to_alert_group_thread( - self, alert_group, attachments=[], mrkdwn=True, unfurl_links=True, text=None - ): + self, alert_group: "AlertGroup", attachments=[], mrkdwn=True, unfurl_links=True, text=None + ) -> None: # TODO: refactor checking the possibility of sending message to slack # do not try to post message to slack if integration is rate limited if alert_group.channel.is_rate_limited_in_slack: diff --git a/engine/apps/slack/constants.py b/engine/apps/slack/constants.py index 398acaf8..4dd73bb1 100644 --- a/engine/apps/slack/constants.py +++ b/engine/apps/slack/constants.py @@ -1,5 +1,7 @@ import datetime +from apps.slack.types import Block + SLACK_BOT_ID = "USLACKBOT" SLACK_INVALID_AUTH_RESPONSE = "no_enough_permissions_to_retrieve" PLACEHOLDER = "Placeholder" @@ -11,3 +13,5 @@ SLACK_RATE_LIMIT_DELAY = 10 CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME = 60 * 10 PRIVATE_METADATA_MAX_LENGTH = 3000 + +DIVIDER: Block.Divider = {"type": "divider"} diff --git a/engine/apps/slack/models/slack_message.py b/engine/apps/slack/models/slack_message.py index 785e8319..9af2157d 100644 --- a/engine/apps/slack/models/slack_message.py +++ b/engine/apps/slack/models/slack_message.py @@ -1,5 +1,6 @@ import logging import time +import typing import uuid from django.db import models @@ -11,11 +12,16 @@ from apps.slack.slack_client.exceptions import ( SlackAPITokenException, ) +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroup + logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) class SlackMessage(models.Model): + alert_group: typing.Optional["AlertGroup"] + id = models.CharField(primary_key=True, default=uuid.uuid4, editable=False, max_length=36) slack_id = models.CharField(max_length=100) @@ -71,7 +77,7 @@ class SlackMessage(models.Model): self.save() return self._slack_team_identity - def get_alert_group(self): + def get_alert_group(self) -> "AlertGroup": try: return self._alert_group except SlackMessage._alert_group.RelatedObjectDoesNotExist: diff --git a/engine/apps/slack/models/slack_team_identity.py b/engine/apps/slack/models/slack_team_identity.py index ca0ecd9e..faaf6331 100644 --- a/engine/apps/slack/models/slack_team_identity.py +++ b/engine/apps/slack/models/slack_team_identity.py @@ -1,4 +1,5 @@ import logging +import typing from django.db import models from django.db.models import JSONField @@ -10,10 +11,17 @@ from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenE from apps.user_management.models.user import User from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log +if typing.TYPE_CHECKING: + from django.db.models.manager import RelatedManager + + from apps.user_management.models import Organization + logger = logging.getLogger(__name__) class SlackTeamIdentity(models.Model): + organizations: "RelatedManager['Organization']" + id = models.AutoField(primary_key=True) slack_id = models.CharField(max_length=100) cached_name = models.CharField(max_length=100, null=True, default=None) diff --git a/engine/apps/slack/models/slack_user_identity.py b/engine/apps/slack/models/slack_user_identity.py index 38f01c34..85e6f447 100644 --- a/engine/apps/slack/models/slack_user_identity.py +++ b/engine/apps/slack/models/slack_user_identity.py @@ -1,4 +1,5 @@ import logging +import typing import requests from django.db import models @@ -7,7 +8,10 @@ from apps.slack.constants import SLACK_BOT_ID from apps.slack.scenarios.notified_user_not_in_channel import NotifiedUserNotInChannelStep from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException -from apps.user_management.models import User +from apps.user_management.models import Organization, User + +if typing.TYPE_CHECKING: + from django.db.models.manager import RelatedManager logger = logging.getLogger(__name__) @@ -36,8 +40,10 @@ class SlackUserIdentityManager(models.Manager): class SlackUserIdentity(models.Model): - objects = SlackUserIdentityManager() - all_objects = AllSlackUserIdentityManager() + users: "RelatedManager['User']" + + objects: models.Manager["SlackUserIdentity"] = SlackUserIdentityManager() + all_objects: models.Manager["SlackUserIdentity"] = AllSlackUserIdentityManager() id = models.AutoField(primary_key=True) @@ -255,7 +261,7 @@ class SlackUserIdentity(models.Model): return None return self.slack_verbal or self.cached_slack_email.split("@")[0] or None - def get_user(self, organization): + def get_user(self, organization: Organization) -> User | None: try: user = organization.users.get(slack_user_identity=self) except User.DoesNotExist: diff --git a/engine/apps/slack/scenarios/alertgroup_appearance.py b/engine/apps/slack/scenarios/alertgroup_appearance.py index 45381d5d..67e7dcdd 100644 --- a/engine/apps/slack/scenarios/alertgroup_appearance.py +++ b/engine/apps/slack/scenarios/alertgroup_appearance.py @@ -1,28 +1,46 @@ import json +import typing from apps.api.permissions import RBACPermission from apps.slack.scenarios import scenario_step +from apps.slack.types import ( + Block, + BlockActionType, + EventPayload, + InteractiveMessageActionType, + ModalView, + PayloadType, + ScenarioRoute, +) from .step_mixins import AlertGroupActionsMixin +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + class OpenAlertAppearanceDialogStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) return private_metadata = { - "organization_id": self.organization.pk if self.organization else alert_group.organization.pk, + "organization_id": self.organization.pk, "alert_group_pk": alert_group.pk, "message_ts": payload.get("message_ts") or payload["container"]["message_ts"], } alert_receive_channel = alert_group.channel - blocks = [ + blocks: typing.List[Block.Section] = [ { "type": "section", "text": { @@ -33,7 +51,7 @@ class OpenAlertAppearanceDialogStep(AlertGroupActionsMixin, scenario_step.Scenar {"type": "section", "text": {"type": "mrkdwn", "text": "Once changed Refresh the alert group"}}, ] - view = { + view: ModalView = { "callback_id": UpdateAppearanceStep.routing_uid(), "blocks": blocks, "type": "modal", @@ -56,7 +74,12 @@ class OpenAlertAppearanceDialogStep(AlertGroupActionsMixin, scenario_step.Scenar class UpdateAppearanceStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: from apps.alerts.models import AlertGroup private_metadata = json.loads(payload["view"]["private_metadata"]) @@ -76,21 +99,21 @@ class UpdateAppearanceStep(scenario_step.ScenarioStep): ) -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": OpenAlertAppearanceDialogStep.routing_uid(), "step": OpenAlertAppearanceDialogStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": OpenAlertAppearanceDialogStep.routing_uid(), "step": OpenAlertAppearanceDialogStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "payload_type": PayloadType.VIEW_SUBMISSION, "view_callback_id": UpdateAppearanceStep.routing_uid(), "step": UpdateAppearanceStep, }, diff --git a/engine/apps/slack/scenarios/declare_incident.py b/engine/apps/slack/scenarios/declare_incident.py index e97f7134..0f1286aa 100644 --- a/engine/apps/slack/scenarios/declare_incident.py +++ b/engine/apps/slack/scenarios/declare_incident.py @@ -1,18 +1,29 @@ +import typing + from apps.slack.scenarios import scenario_step +from apps.slack.types import BlockActionType, EventPayload, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity class DeclareIncidentStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Slack sends a POST request to the backend upon clicking a button with a redirect link to Incident. This is a dummy step, that is used to prevent raising 'Step is undefined' exception. """ -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": DeclareIncidentStep.routing_uid(), "step": DeclareIncidentStep, }, diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 2cdf6c9f..27ed6636 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -1,5 +1,6 @@ import json import logging +import typing from contextlib import suppress from datetime import datetime @@ -10,7 +11,7 @@ from jinja2 import TemplateError from apps.alerts.constants import ActionSource from apps.alerts.incident_appearance.renderers.constants import DEFAULT_BACKUP_TITLE from apps.alerts.incident_appearance.renderers.slack_renderer import AlertSlackRenderer -from apps.alerts.models import AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, Invitation +from apps.alerts.models import Alert, AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, Invitation from apps.alerts.tasks import custom_button_result from apps.alerts.utils import render_curl_command from apps.api.permissions import RBACPermission @@ -31,11 +32,24 @@ from apps.slack.tasks import ( send_message_to_thread_if_bot_not_in_channel, update_incident_slack_message, ) +from apps.slack.types import ( + Block, + BlockActionType, + CompositionObjects, + EventPayload, + InteractiveMessageActionType, + ModalView, + PayloadType, + ScenarioRoute, +) from apps.slack.utils import get_cache_key_update_incident_slack_message from common.utils import clean_markup, is_string_with_visible_characters from .step_mixins import AlertGroupActionsMixin +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + ATTACH_TO_ALERT_GROUPS_LIMIT = 20 logger = logging.getLogger(__name__) @@ -43,7 +57,7 @@ logger.setLevel(logging.DEBUG) class AlertShootingStep(scenario_step.ScenarioStep): - def process_signal(self, alert): + def process_signal(self, alert: Alert) -> None: # do not try to post alert group message to slack if its channel is rate limited if alert.group.channel.is_rate_limited_in_slack: logger.info("Skip posting or updating alert_group in Slack due to rate limit") @@ -91,7 +105,7 @@ class AlertShootingStep(scenario_step.ScenarioStep): else: logger.info("Skip updating alert_group in Slack due to rate limit") - def _send_first_alert(self, alert, channel_id): + def _send_first_alert(self, alert: Alert, channel_id: str) -> None: attachments = alert.group.render_slack_attachments() blocks = alert.group.render_slack_blocks() self._post_alert_group_to_slack( @@ -103,13 +117,20 @@ class AlertShootingStep(scenario_step.ScenarioStep): blocks=blocks, ) - def _post_alert_group_to_slack(self, slack_team_identity, alert_group, alert, attachments, channel_id, blocks): + def _post_alert_group_to_slack( + self, + slack_team_identity: "SlackTeamIdentity", + alert_group: AlertGroup, + alert: Alert, + attachments, + channel_id: str, + blocks: Block.AnyBlocks, + ) -> None: # channel_id can be None if general log channel for slack_team_identity is not set if channel_id is None: logger.info(f"Failed to post message to Slack for alert_group {alert_group.pk} because channel_id is None") alert_group.reason_to_skip_escalation = AlertGroup.CHANNEL_NOT_SPECIFIED alert_group.save(update_fields=["reason_to_skip_escalation"]) - print("Not delivering alert due to channel_id is None.") return try: @@ -150,11 +171,11 @@ class AlertShootingStep(scenario_step.ScenarioStep): except SlackAPITokenException: alert_group.reason_to_skip_escalation = AlertGroup.ACCOUNT_INACTIVE alert_group.save(update_fields=["reason_to_skip_escalation"]) - print("Not delivering alert due to account_inactive.") + logger.info("Not delivering alert due to account_inactive.") except SlackAPIChannelArchivedException: alert_group.reason_to_skip_escalation = AlertGroup.CHANNEL_ARCHIVED alert_group.save(update_fields=["reason_to_skip_escalation"]) - print("Not delivering alert due to channel is archived.") + logger.info("Not delivering alert due to channel is archived.") except SlackAPIRateLimitException as e: # don't rate limit maintenance alert if alert_group.channel.integration != AlertReceiveChannel.INTEGRATION_MAINTENANCE: @@ -162,7 +183,7 @@ class AlertShootingStep(scenario_step.ScenarioStep): alert_group.save(update_fields=["reason_to_skip_escalation"]) delay = e.response.get("rate_limit_delay") or SLACK_RATE_LIMIT_DELAY alert_group.channel.start_send_rate_limit_message_task(delay) - print("Not delivering alert due to slack rate limit.") + logger.info("Not delivering alert due to slack rate limit.") else: raise e except SlackAPIException as e: @@ -170,19 +191,19 @@ class AlertShootingStep(scenario_step.ScenarioStep): if e.response["error"] == "channel_not_found": alert_group.reason_to_skip_escalation = AlertGroup.CHANNEL_ARCHIVED alert_group.save(update_fields=["reason_to_skip_escalation"]) - print("Not delivering alert due to channel is archived.") + logger.info("Not delivering alert due to channel is archived.") elif e.response["error"] == "restricted_action": # workspace settings prevent bot to post message (eg. bot is not a full member) alert_group.reason_to_skip_escalation = AlertGroup.RESTRICTED_ACTION alert_group.save(update_fields=["reason_to_skip_escalation"]) - print("Not delivering alert due to workspace restricted action.") + logger.info("Not delivering alert due to workspace restricted action.") else: raise e finally: alert.save() - def _send_debug_mode_notice(self, alert_group, channel_id): - blocks = [] + def _send_debug_mode_notice(self, alert_group: AlertGroup, channel_id: str) -> None: + blocks: Block.AnyBlocks = [] text = "Escalations are silenced due to Debug mode" blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}}) self._slack_client.api_call( @@ -195,18 +216,23 @@ class AlertShootingStep(scenario_step.ScenarioStep): blocks=blocks, ) - def _send_log_report_message(self, alert_group, channel_id): + def _send_log_report_message(self, alert_group: AlertGroup, channel_id: str) -> None: post_or_update_log_report_message_task.apply_async( (alert_group.pk, self.slack_team_identity.pk), ) - def _send_message_to_thread_if_bot_not_in_channel(self, alert_group, channel_id): + def _send_message_to_thread_if_bot_not_in_channel(self, alert_group: AlertGroup, channel_id: str) -> None: send_message_to_thread_if_bot_not_in_channel.apply_async( (alert_group.pk, self.slack_team_identity.pk, channel_id), countdown=1, # delay for message so that the log report is published first ) - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: pass @@ -218,7 +244,12 @@ class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.Scenario REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: from apps.user_management.models import User alert_group = self.get_alert_group(slack_team_identity, payload) @@ -242,15 +273,19 @@ class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.Scenario else: self.alert_group_slack_service.update_alert_group_slack_message(alert_group) - def process_signal(self, log_record): - alert_group = log_record.alert_group - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) + def process_signal(self, log_record: AlertGroupLogRecord) -> None: + self.alert_group_slack_service.update_alert_group_slack_message(log_record.alert_group) class SilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) @@ -265,15 +300,19 @@ class SilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): alert_group.silence_by_user(self.user, silence_delay, action_source=ActionSource.SLACK) - def process_signal(self, log_record): - alert_group = log_record.alert_group - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) + def process_signal(self, log_record: AlertGroupLogRecord) -> None: + self.alert_group_slack_service.update_alert_group_slack_message(log_record.alert_group) class UnSilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) @@ -281,22 +320,26 @@ class UnSilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): alert_group.un_silence_by_user(self.user, action_source=ActionSource.SLACK) - def process_signal(self, log_record): - alert_group = log_record.alert_group - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) + def process_signal(self, log_record: AlertGroupLogRecord) -> None: + self.alert_group_slack_service.update_alert_group_slack_message(log_record.alert_group) class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) return - blocks = [] - view = { + blocks: Block.AnyBlocks = [] + view: ModalView = { "callback_id": AttachGroupStep.routing_uid(), "blocks": blocks, "type": "modal", @@ -363,9 +406,9 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): view=view, ) - def get_select_incidents_blocks(self, alert_group): - collected_options = [] - blocks = [] + def get_select_incidents_blocks(self, alert_group: AlertGroup) -> Block.AnyBlocks: + collected_options: typing.List[CompositionObjects.Option] = [] + blocks: Block.AnyBlocks = [] alert_receive_channel_ids = AlertReceiveChannel.objects.filter( organization=alert_group.channel.organization @@ -433,7 +476,7 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [] # Permissions are handled in SelectAttachGroupStep - def process_signal(self, log_record): + def process_signal(self, log_record: AlertGroupLogRecord) -> None: alert_group = log_record.alert_group if log_record.type == AlertGroupLogRecord.TYPE_ATTACHED and log_record.alert_group.is_maintenance_incident: @@ -455,9 +498,14 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self.alert_group_slack_service.update_alert_group_slack_message(alert_group) - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: # submit selection in modal window - if payload["type"] == scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION: + if payload["type"] == PayloadType.VIEW_SUBMISSION: alert_group_pk = json.loads(payload["view"]["private_metadata"])["alert_group_pk"] alert_group = AlertGroup.objects.get(pk=alert_group_pk) root_alert_group_pk = payload["view"]["state"]["values"][SelectAttachGroupStep.routing_uid()][ @@ -480,7 +528,12 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) @@ -488,9 +541,8 @@ class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): alert_group.un_attach_by_user(self.user, action_source=ActionSource.SLACK) - def process_signal(self, log_record): - alert_group = log_record.alert_group - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) + def process_signal(self, log_record: AlertGroupLogRecord) -> None: + self.alert_group_slack_service.update_alert_group_slack_message(log_record.alert_group) class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): @@ -501,7 +553,12 @@ class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) @@ -516,14 +573,19 @@ class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): Invitation.stop_invitation(invitation_id, self.user) - def process_signal(self, log_record): + def process_signal(self, log_record: AlertGroupLogRecord) -> None: self.alert_group_slack_service.update_alert_group_slack_message(log_record.invitation.alert_group) class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: from apps.alerts.models import CustomButton alert_group = self.get_alert_group(slack_team_identity, payload) @@ -548,7 +610,7 @@ class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep kwargs={"user_pk": self.user.pk}, ) - def process_signal(self, log_record): + def process_signal(self, log_record: AlertGroupLogRecord) -> None: alert_group = log_record.alert_group result_message = log_record.reason custom_button = log_record.custom_button @@ -581,7 +643,12 @@ class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: ResolutionNoteModalStep = scenario_step.ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep") alert_group = self.get_alert_group(slack_team_identity, payload) @@ -606,7 +673,7 @@ class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): alert_group.resolve_by_user(self.user, action_source=ActionSource.SLACK) - def process_signal(self, log_record): + def process_signal(self, log_record: AlertGroupLogRecord) -> None: alert_group = log_record.alert_group # Do not rerender alert_groups which happened while maintenance. # They have no slack messages, since they just attached to the maintenance incident. @@ -617,7 +684,12 @@ class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class UnResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) @@ -625,15 +697,19 @@ class UnResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): alert_group.un_resolve_by_user(self.user, action_source=ActionSource.SLACK) - def process_signal(self, log_record): - alert_group = log_record.alert_group - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) + def process_signal(self, log_record: AlertGroupLogRecord) -> None: + self.alert_group_slack_service.update_alert_group_slack_message(log_record.alert_group) class AcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) @@ -641,15 +717,19 @@ class AcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): alert_group.acknowledge_by_user(self.user, action_source=ActionSource.SLACK) - def process_signal(self, log_record): - alert_group = log_record.alert_group - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) + def process_signal(self, log_record: AlertGroupLogRecord) -> None: + self.alert_group_slack_service.update_alert_group_slack_message(log_record.alert_group) class UnAcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) @@ -657,7 +737,7 @@ class UnAcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep) alert_group.un_acknowledge_by_user(self.user, action_source=ActionSource.SLACK) - def process_signal(self, log_record): + def process_signal(self, log_record: AlertGroupLogRecord) -> None: from apps.alerts.models import AlertGroupLogRecord alert_group = log_record.alert_group @@ -711,7 +791,12 @@ class UnAcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep) class AcknowledgeConfirmationStep(AcknowledgeGroupStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: from apps.alerts.models import AlertGroup alert_group_id = payload["actions"][0]["value"].split("_")[1] @@ -763,7 +848,7 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep): text="This Alert Group is already unacknowledged.", ) - def process_signal(self, log_record): + def process_signal(self, log_record: AlertGroupLogRecord) -> None: from apps.slack.models import SlackMessage from apps.user_management.models import Organization @@ -844,7 +929,7 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep): class WipeGroupStep(scenario_step.ScenarioStep): - def process_signal(self, log_record): + def process_signal(self, log_record: AlertGroupLogRecord) -> None: alert_group = log_record.alert_group user_verbal = log_record.author.get_username_with_slack_verbal() text = f"Wiped by {user_verbal}" @@ -853,12 +938,12 @@ class WipeGroupStep(scenario_step.ScenarioStep): class DeleteGroupStep(scenario_step.ScenarioStep): - def process_signal(self, log_record): + def process_signal(self, log_record: AlertGroupLogRecord) -> None: alert_group = log_record.alert_group self.remove_resolution_note_reaction(alert_group) - bot_messages_ts = [] + bot_messages_ts: typing.List[str] = [] bot_messages_ts.extend(alert_group.slack_messages.values_list("slack_id", flat=True)) bot_messages_ts.extend( alert_group.resolution_note_slack_messages.filter(posted_by_bot=True).values_list("ts", flat=True) @@ -912,7 +997,7 @@ class DeleteGroupStep(scenario_step.ScenarioStep): else: raise e - def remove_resolution_note_reaction(self, alert_group): + def remove_resolution_note_reaction(self, alert_group: AlertGroup) -> None: for message in alert_group.resolution_note_slack_messages.filter(added_to_resolution_note=True): message.added_to_resolution_note = False message.save(update_fields=["added_to_resolution_note"]) @@ -934,13 +1019,13 @@ class DeleteGroupStep(scenario_step.ScenarioStep): class UpdateLogReportMessageStep(scenario_step.ScenarioStep): - def process_signal(self, alert_group): + def process_signal(self, alert_group: AlertGroup) -> None: if alert_group.skip_escalation_in_slack or alert_group.channel.is_rate_limited_in_slack: return self.update_log_message(alert_group) - def post_log_message(self, alert_group): + def post_log_message(self, alert_group: AlertGroup) -> None: from apps.slack.models import SlackMessage slack_message = alert_group.get_slack_message() @@ -998,7 +1083,7 @@ class UpdateLogReportMessageStep(scenario_step.ScenarioStep): else: self.update_log_message(alert_group) - def update_log_message(self, alert_group): + def update_log_message(self, alert_group: AlertGroup) -> None: slack_message = alert_group.get_slack_message() if slack_message is None: @@ -1073,135 +1158,135 @@ class UpdateLogReportMessageStep(scenario_step.ScenarioStep): logger.debug(f"Update log message failed for alert_group {alert_group.pk}: " f"log message does not exist.") -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": ResolveGroupStep.routing_uid(), "step": ResolveGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": ResolveGroupStep.routing_uid(), "step": ResolveGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": UnResolveGroupStep.routing_uid(), "step": UnResolveGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": AcknowledgeGroupStep.routing_uid(), "step": AcknowledgeGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": AcknowledgeGroupStep.routing_uid(), "step": AcknowledgeGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": AcknowledgeConfirmationStep.routing_uid(), "step": AcknowledgeConfirmationStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": UnAcknowledgeGroupStep.routing_uid(), "step": UnAcknowledgeGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": UnAcknowledgeGroupStep.routing_uid(), "step": UnAcknowledgeGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_SELECT, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.SELECT, "action_name": SilenceGroupStep.routing_uid(), "step": SilenceGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": SilenceGroupStep.routing_uid(), "step": SilenceGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": UnSilenceGroupStep.routing_uid(), "step": UnSilenceGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": UnSilenceGroupStep.routing_uid(), "step": UnSilenceGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": SelectAttachGroupStep.routing_uid(), "step": SelectAttachGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_SELECT, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.SELECT, "action_name": AttachGroupStep.routing_uid(), "step": AttachGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "payload_type": PayloadType.VIEW_SUBMISSION, "view_callback_id": AttachGroupStep.routing_uid(), "step": AttachGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": AttachGroupStep.routing_uid(), "step": AttachGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": UnAttachGroupStep.routing_uid(), "step": UnAttachGroupStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_SELECT, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.SELECT, "action_name": InviteOtherPersonToIncident.routing_uid(), "step": InviteOtherPersonToIncident, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_USERS_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.USERS_SELECT, "block_action_id": InviteOtherPersonToIncident.routing_uid(), "step": InviteOtherPersonToIncident, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": InviteOtherPersonToIncident.routing_uid(), "step": InviteOtherPersonToIncident, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": StopInvitationProcess.routing_uid(), "step": StopInvitationProcess, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": CustomButtonProcessStep.routing_uid(), "step": CustomButtonProcessStep, }, diff --git a/engine/apps/slack/scenarios/escalation_delivery.py b/engine/apps/slack/scenarios/escalation_delivery.py index 0b221ba9..0d17b503 100644 --- a/engine/apps/slack/scenarios/escalation_delivery.py +++ b/engine/apps/slack/scenarios/escalation_delivery.py @@ -1,14 +1,22 @@ +import typing + import humanize from apps.slack.scenarios import scenario_step +if typing.TYPE_CHECKING: + from apps.base.models import UserNotificationPolicy + from apps.user_management.models import User + class EscalationDeliveryStep(scenario_step.ScenarioStep): """ used for user group and channel notification in slack """ - def get_user_notification_message_for_thread_for_usergroup(self, user, notification_policy): + def get_user_notification_message_for_thread_for_usergroup( + self, user: "User", notification_policy: "UserNotificationPolicy" + ) -> None: from apps.base.models import UserNotificationPolicy notification_channel = notification_policy.notify_by diff --git a/engine/apps/slack/scenarios/invited_to_channel.py b/engine/apps/slack/scenarios/invited_to_channel.py index e2ff83f1..d111beba 100644 --- a/engine/apps/slack/scenarios/invited_to_channel.py +++ b/engine/apps/slack/scenarios/invited_to_channel.py @@ -1,16 +1,26 @@ import logging +import typing from django.utils import timezone from apps.slack.scenarios import scenario_step from apps.slack.slack_client import SlackClientWithErrorHandling +from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) class InvitedToChannelStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: if payload["event"]["user"] == slack_team_identity.bot_user_id: channel_id = payload["event"]["channel"] slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token) @@ -29,10 +39,10 @@ class InvitedToChannelStep(scenario_step.ScenarioStep): logger.info("Other user was invited to a channel with a bot.") -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_MEMBER_JOINED_CHANNEL, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.MEMBER_JOINED_CHANNEL, "step": InvitedToChannelStep, }, ] diff --git a/engine/apps/slack/scenarios/manage_responders.py b/engine/apps/slack/scenarios/manage_responders.py index 32ed1756..fc7187ac 100644 --- a/engine/apps/slack/scenarios/manage_responders.py +++ b/engine/apps/slack/scenarios/manage_responders.py @@ -1,11 +1,12 @@ import json +import typing from apps.alerts.paging import DirectPagingAlertGroupResolvedError, check_user_availability, direct_paging, unpage_user +from apps.slack.constants import DIVIDER from apps.slack.scenarios import scenario_step from apps.slack.scenarios.paging import ( DIRECT_PAGING_SCHEDULE_SELECT_ID, DIRECT_PAGING_USER_SELECT_ID, - DIVIDER_BLOCK, _generate_input_id_prefix, _get_availability_warnings_view, _get_schedules_select, @@ -13,6 +14,13 @@ from apps.slack.scenarios.paging import ( _get_users_select, ) from apps.slack.scenarios.step_mixins import AlertGroupActionsMixin +from apps.slack.types import Block, BlockActionType, EventPayload, ModalView, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroup + from apps.schedules.models import OnCallSchedule + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import User MANAGE_RESPONDERS_USER_SELECT_ID = "responders_user_select" MANAGE_RESPONDERS_SCHEDULE_SELECT_ID = "responders_schedule_select" @@ -26,7 +34,12 @@ ALERT_GROUP_DATA_KEY = "alert_group_pk" class StartManageResponders(AlertGroupActionsMixin, scenario_step.ScenarioStep): """Handle "Responders" button click.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): self.open_unauthorized_warning(payload) @@ -43,7 +56,12 @@ class StartManageResponders(AlertGroupActionsMixin, scenario_step.ScenarioStep): class ManageRespondersUserChange(scenario_step.ScenarioStep): """Handle user selection in responders modal.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = _get_alert_group_from_payload(payload) selected_user = _get_selected_user_from_payload(payload) organization = alert_group.channel.organization @@ -89,7 +107,12 @@ class ManageRespondersUserChange(scenario_step.ScenarioStep): class ManageRespondersConfirmUserChange(scenario_step.ScenarioStep): """Handle user confirmation on availability warnings modal.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = _get_alert_group_from_payload(payload) selected_user = _get_selected_user_from_payload(payload) organization = alert_group.channel.organization @@ -117,7 +140,12 @@ class ManageRespondersConfirmUserChange(scenario_step.ScenarioStep): class ManageRespondersScheduleChange(scenario_step.ScenarioStep): """Handle schedule selection in responders modal.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = _get_alert_group_from_payload(payload) selected_schedule = _get_selected_schedule_from_payload(payload) organization = alert_group.channel.organization @@ -145,7 +173,12 @@ class ManageRespondersScheduleChange(scenario_step.ScenarioStep): class ManageRespondersRemoveUser(scenario_step.ScenarioStep): """Handle user removal in responders modal.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: alert_group = _get_alert_group_from_payload(payload) selected_user = _get_selected_user_from_payload(payload) from_user = slack_user_identity.get_user(alert_group.channel.organization) @@ -163,34 +196,40 @@ class ManageRespondersRemoveUser(scenario_step.ScenarioStep): # slack view/blocks rendering helpers -def render_dialog(alert_group, alert_group_resolved_warning=False): - blocks = [] +def render_dialog(alert_group: "AlertGroup", alert_group_resolved_warning=False) -> ModalView: + blocks: Block.AnyBlocks = [] # Show list of users that are currently paged paged_users = alert_group.get_paged_users() for user in alert_group.get_paged_users(): blocks += [ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f":bust_in_silhouette: *{user.name or user.username}*"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Remove", "emoji": True}, - "action_id": ManageRespondersRemoveUser.routing_uid(), - "value": str(user.pk), + typing.cast( + Block.Section, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f":bust_in_silhouette: *{user.name or user.username}*"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Remove", "emoji": True}, + "action_id": ManageRespondersRemoveUser.routing_uid(), + "value": str(user.pk), + }, }, - } + ), ] if paged_users: - blocks += [DIVIDER_BLOCK] + blocks += [DIVIDER] # Show a warning when trying to add responders for a resolved alert group if alert_group_resolved_warning: blocks += [ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f":no_entry: {DirectPagingAlertGroupResolvedError.DETAIL}"}, - } + typing.cast( + Block.Section, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f":no_entry: {DirectPagingAlertGroupResolvedError.DETAIL}"}, + }, + ), ] # Show user and schedule dropdowns @@ -204,7 +243,7 @@ def render_dialog(alert_group, alert_group_resolved_warning=False): ) ] - view = { + view: ModalView = { "type": "modal", "title": { "type": "plain_text", @@ -216,7 +255,7 @@ def render_dialog(alert_group, alert_group_resolved_warning=False): return view -def _get_selected_user_from_payload(payload): +def _get_selected_user_from_payload(payload: EventPayload.Any) -> "User": from apps.user_management.models import User try: @@ -235,7 +274,7 @@ def _get_selected_user_from_payload(payload): return User.objects.get(pk=selected_user_id) -def _get_selected_schedule_from_payload(payload): +def _get_selected_schedule_from_payload(payload: EventPayload.Any) -> "OnCallSchedule": from apps.schedules.models import OnCallSchedule input_id_prefix = json.loads(payload["view"]["private_metadata"])["input_id_prefix"] @@ -246,40 +285,40 @@ def _get_selected_schedule_from_payload(payload): return OnCallSchedule.objects.get(pk=selected_schedule_id) -def _get_alert_group_from_payload(payload): +def _get_alert_group_from_payload(payload: EventPayload.Any) -> "AlertGroup": from apps.alerts.models import AlertGroup alert_group_pk = json.loads(payload["view"]["private_metadata"])[ALERT_GROUP_DATA_KEY] return AlertGroup.objects.get(pk=alert_group_pk) -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": ManageRespondersUserChange.routing_uid(), "step": ManageRespondersUserChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "payload_type": PayloadType.VIEW_SUBMISSION, "view_callback_id": ManageRespondersConfirmUserChange.routing_uid(), "step": ManageRespondersConfirmUserChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": ManageRespondersScheduleChange.routing_uid(), "step": ManageRespondersScheduleChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": ManageRespondersRemoveUser.routing_uid(), "step": ManageRespondersRemoveUser, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": StartManageResponders.routing_uid(), "step": StartManageResponders, }, diff --git a/engine/apps/slack/scenarios/manual_incident.py b/engine/apps/slack/scenarios/manual_incident.py index d4104c74..c7625bc2 100644 --- a/engine/apps/slack/scenarios/manual_incident.py +++ b/engine/apps/slack/scenarios/manual_incident.py @@ -1,11 +1,26 @@ import json +import typing from uuid import uuid4 from django.conf import settings -from apps.alerts.models import AlertReceiveChannel +from apps.alerts.models import AlertReceiveChannel, ChannelFilter +from apps.slack.constants import DIVIDER from apps.slack.scenarios import scenario_step from apps.slack.slack_client.exceptions import SlackAPIException +from apps.slack.types import ( + Block, + BlockActionType, + CompositionObjects, + EventPayload, + ModalView, + PayloadType, + ScenarioRoute, +) + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization, Team MANUAL_INCIDENT_TEAM_SELECT_ID = "manual_incident_team_select" MANUAL_INCIDENT_ORG_SELECT_ID = "manual_incident_org_select" @@ -26,7 +41,12 @@ class StartCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): TITLE_INPUT_BLOCK_ID = "TITLE_INPUT" MESSAGE_INPUT_BLOCK_ID = "MESSAGE_INPUT" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: input_id_prefix = _generate_input_id_prefix() try: @@ -60,7 +80,12 @@ class FinishCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): FinishCreateIncidentFromSlashCommand creates a manual incident from the slack message via slash message """ - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: from apps.alerts.models import Alert title = _get_title_from_payload(payload) @@ -136,7 +161,12 @@ class FinishCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): class OnOrgChange(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: private_metadata = json.loads(payload["view"]["private_metadata"]) with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False) submit_routing_uid = private_metadata.get("submit_routing_uid") @@ -167,7 +197,7 @@ class OnOrgChange(scenario_step.ScenarioStep): team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) route_select = _get_route_select(manual_integration, selected_route, new_input_id_prefix) - blocks = [organization_select, team_select, route_select] + blocks: Block.AnyBlocks = [organization_select, team_select, route_select] if with_title_and_message_inputs: blocks.extend([_get_title_input(payload), _get_message_input(payload)]) view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) @@ -180,7 +210,12 @@ class OnOrgChange(scenario_step.ScenarioStep): class OnTeamChange(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: private_metadata = json.loads(payload["view"]["private_metadata"]) with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False) submit_routing_uid = private_metadata.get("submit_routing_uid") @@ -210,7 +245,7 @@ class OnTeamChange(scenario_step.ScenarioStep): team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) route_select = _get_route_select(manual_integration, initial_route, new_input_id_prefix) - blocks = [organization_select, team_select, route_select] + blocks: Block.AnyBlocks = [organization_select, team_select, route_select] if with_title_and_message_inputs: blocks.extend([_get_title_input(payload), _get_message_input(payload)]) view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) @@ -227,24 +262,32 @@ class OnRouteChange(scenario_step.ScenarioStep): OnRouteChange is just a plug to handle change of value on route select """ - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: pass -def _get_manual_incident_form_view(routing_uid, blocks, private_metatada): - deprecation_blocks = [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": f":no_entry: This command is deprecated and will be removed soon. Please use {settings.SLACK_DIRECT_PAGING_SLASH_COMMAND} command instead :no_entry:", - "emoji": True, +def _get_manual_incident_form_view(routing_uid: str, blocks: Block.AnyBlocks, private_metatada: str) -> ModalView: + deprecation_blocks: Block.AnyBlocks = [ + typing.cast( + Block.Header, + { + "type": "header", + "text": { + "type": "plain_text", + "text": f":no_entry: This command is deprecated and will be removed soon. Please use {settings.SLACK_DIRECT_PAGING_SLASH_COMMAND} command instead :no_entry:", + "emoji": True, + }, }, - }, - {"type": "divider"}, + ), + DIVIDER, ] - view = { + view: ModalView = { "type": "modal", "callback_id": routing_uid, "title": { @@ -268,8 +311,12 @@ def _get_manual_incident_form_view(routing_uid, blocks, private_metatada): def _get_manual_incident_initial_form_fields( - slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=False -): + slack_team_identity: "SlackTeamIdentity", + slack_user_identity: "SlackUserIdentity", + input_id_prefix: str, + payload: EventPayload.Any, + with_title_and_message_inputs=False, +) -> Block.AnyBlocks: initial_organization = ( slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity) .order_by("pk") @@ -298,7 +345,7 @@ def _get_manual_incident_initial_form_fields( initial_route = manual_integration.default_channel_filter route_select = _get_route_select(manual_integration, initial_route, input_id_prefix) - blocks = [organization_select, team_select, route_select] + blocks: Block.AnyBlocks = [organization_select, team_select, route_select] if with_title_and_message_inputs: title_input = _get_title_input(payload) message_input = _get_message_input(payload) @@ -307,11 +354,16 @@ def _get_manual_incident_initial_form_fields( return blocks -def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix): +def _get_organization_select( + slack_team_identity: "SlackTeamIdentity", + slack_user_identity: "SlackUserIdentity", + value: "Organization", + input_id_prefix: str, +) -> Block.Section: organizations = slack_team_identity.organizations.filter( users__slack_user_identity=slack_user_identity, ).distinct() - organizations_options = [] + organizations_options: typing.List[CompositionObjects.Option] = [] initial_option_idx = 0 for idx, org in enumerate(organizations): if org == value: @@ -327,7 +379,7 @@ def _get_organization_select(slack_team_identity, slack_user_identity, value, in } ) - organization_select = { + organization_select: Block.Section = { "type": "section", "text": {"type": "mrkdwn", "text": "Select an organization"}, "block_id": input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID, @@ -343,21 +395,22 @@ def _get_organization_select(slack_team_identity, slack_user_identity, value, in return organization_select -def _get_selected_org_from_payload(payload, input_id_prefix): +def _get_selected_org_from_payload(payload: EventPayload.Any, input_id_prefix: str) -> typing.Optional["Organization"]: from apps.user_management.models import Organization selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID][ OnOrgChange.routing_uid() ]["selected_option"]["value"] - org = Organization.objects.filter(pk=selected_org_id).first() - return org + return Organization.objects.filter(pk=selected_org_id).first() -def _get_team_select(slack_user_identity, organization, value, input_id_prefix): +def _get_team_select( + slack_user_identity: "SlackUserIdentity", organization: "Organization", value: str, input_id_prefix: str +) -> Block.Section: teams = organization.teams.filter( users__slack_user_identity=slack_user_identity, ).distinct() - team_options = [] + team_options: typing.List[CompositionObjects.Option] = [] # Adding pseudo option for default team initial_option_idx = 0 team_options.append( @@ -385,7 +438,7 @@ def _get_team_select(slack_user_identity, organization, value, input_id_prefix): } ) - team_select = { + team_select: Block.Section = { "type": "section", "text": {"type": "mrkdwn", "text": "Select a team"}, "block_id": input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID, @@ -400,7 +453,7 @@ def _get_team_select(slack_user_identity, organization, value, input_id_prefix): return team_select -def _get_selected_team_from_payload(payload, input_id_prefix): +def _get_selected_team_from_payload(payload: EventPayload.Any, input_id_prefix: str) -> typing.Optional["Team"]: from apps.user_management.models import Team selected_team_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID][ @@ -408,12 +461,11 @@ def _get_selected_team_from_payload(payload, input_id_prefix): ]["selected_option"]["value"] if selected_team_id == DEFAULT_TEAM_VALUE: return None - team = Team.objects.filter(pk=selected_team_id).first() - return team + return Team.objects.filter(pk=selected_team_id).first() -def _get_route_select(integration, value, input_id_prefix): - route_options = [] +def _get_route_select(integration: AlertReceiveChannel, value, input_id_prefix: str) -> Block.Section: + route_options: typing.List[CompositionObjects.Option] = [] initial_option_idx = 0 for idx, route in enumerate(integration.channel_filters.all()): filtering_term = f'"{route.filtering_term}"' @@ -431,7 +483,7 @@ def _get_route_select(integration, value, input_id_prefix): "value": f"{route.pk}", } ) - route_select = { + route_select: Block.Section = { "type": "section", "text": {"type": "mrkdwn", "text": "Select a route"}, "block_id": input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID, @@ -446,25 +498,26 @@ def _get_route_select(integration, value, input_id_prefix): return route_select -def _get_selected_route_from_payload(payload, input_id_prefix): +def _get_selected_route_from_payload(payload: EventPayload.Any, input_id_prefix: str) -> ChannelFilter | None: from apps.alerts.models import ChannelFilter selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID][ OnRouteChange.routing_uid() ]["selected_option"]["value"] - channel_filter = ChannelFilter.objects.filter(pk=selected_org_id).first() - return channel_filter + return ChannelFilter.objects.filter(pk=selected_org_id).first() -def _get_and_change_input_id_prefix_from_metadata(metadata): +def _get_and_change_input_id_prefix_from_metadata( + metadata: typing.Dict[str, str] +) -> typing.Tuple[str, str, typing.Dict[str, str]]: old_input_id_prefix = metadata["input_id_prefix"] new_input_id_prefix = _generate_input_id_prefix() metadata["input_id_prefix"] = new_input_id_prefix return old_input_id_prefix, new_input_id_prefix, metadata -def _get_title_input(payload): - title_input_block = { +def _get_title_input(payload: EventPayload.Any) -> Block.Input: + title_input_block: Block.Input = { "type": "input", "block_id": MANUAL_INCIDENT_TITLE_INPUT_ID, "label": { @@ -485,15 +538,15 @@ def _get_title_input(payload): return title_input_block -def _get_title_from_payload(payload): +def _get_title_from_payload(payload: EventPayload.Any) -> str: title = payload["view"]["state"]["values"][MANUAL_INCIDENT_TITLE_INPUT_ID][ FinishCreateIncidentFromSlashCommand.routing_uid() ]["value"] return title -def _get_message_input(payload): - message_input_block = { +def _get_message_input(payload: EventPayload.Any) -> Block.Input: + message_input_block: Block.Input = { "type": "input", "block_id": MANUAL_INCIDENT_MESSAGE_INPUT_ID, "label": { @@ -516,48 +569,47 @@ def _get_message_input(payload): return message_input_block -def _get_message_from_payload(payload): - message = ( +def _get_message_from_payload(payload: EventPayload.Any) -> str: + return ( payload["view"]["state"]["values"][MANUAL_INCIDENT_MESSAGE_INPUT_ID][ FinishCreateIncidentFromSlashCommand.routing_uid() ]["value"] or "" ) - return message # _generate_input_id_prefix returns uniq str to not to preserve input's values between view update # https://api.slack.com/methods/views.update#markdown -def _generate_input_id_prefix(): +def _generate_input_id_prefix() -> str: return str(uuid4()) -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": OnOrgChange.routing_uid(), "step": OnOrgChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": OnTeamChange.routing_uid(), "step": OnTeamChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": OnRouteChange.routing_uid(), "step": OnRouteChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND, + "payload_type": PayloadType.SLASH_COMMAND, "command_name": StartCreateIncidentFromSlashCommand.command_name, "step": StartCreateIncidentFromSlashCommand, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "payload_type": PayloadType.VIEW_SUBMISSION, "view_callback_id": FinishCreateIncidentFromSlashCommand.routing_uid(), "step": FinishCreateIncidentFromSlashCommand, }, diff --git a/engine/apps/slack/scenarios/notification_delivery.py b/engine/apps/slack/scenarios/notification_delivery.py index d0ceb04c..141651e5 100644 --- a/engine/apps/slack/scenarios/notification_delivery.py +++ b/engine/apps/slack/scenarios/notification_delivery.py @@ -1,9 +1,15 @@ +import typing + from apps.slack.scenarios import scenario_step from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException +from apps.slack.types import Block + +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroupLogRecord class NotificationDeliveryStep(scenario_step.ScenarioStep): - def process_signal(self, log_record): + def process_signal(self, log_record: "AlertGroupLogRecord") -> None: from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord user = log_record.author @@ -53,8 +59,8 @@ class NotificationDeliveryStep(scenario_step.ScenarioStep): alert_group.slack_message.channel_id, ) - def _post_message_to_channel(self, text, channel): - blocks = [ + def _post_message_to_channel(self, text: str, channel: str) -> None: + blocks: Block.AnyBlocks = [ { "type": "section", "block_id": "alert", diff --git a/engine/apps/slack/scenarios/notified_user_not_in_channel.py b/engine/apps/slack/scenarios/notified_user_not_in_channel.py index 9f2cc7cb..ca4a4d6d 100644 --- a/engine/apps/slack/scenarios/notified_user_not_in_channel.py +++ b/engine/apps/slack/scenarios/notified_user_not_in_channel.py @@ -1,6 +1,11 @@ import logging +import typing from apps.slack.scenarios import scenario_step +from apps.slack.types import BlockActionType, EventPayload, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity logger = logging.getLogger(__name__) @@ -11,15 +16,20 @@ class NotifiedUserNotInChannelStep(scenario_step.ScenarioStep): Message, which sends this button is created in SlackUserIdentity.send_link_to_slack_message method. """ - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: logger.info("Gracefully handle NotifiedUserNotInChannelStep. Do nothing.") pass -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": NotifiedUserNotInChannelStep.routing_uid(), "step": NotifiedUserNotInChannelStep, }, diff --git a/engine/apps/slack/scenarios/onboarding.py b/engine/apps/slack/scenarios/onboarding.py index 8d325e65..163ed9d2 100644 --- a/engine/apps/slack/scenarios/onboarding.py +++ b/engine/apps/slack/scenarios/onboarding.py @@ -1,6 +1,11 @@ import logging +import typing from apps.slack.scenarios import scenario_step +from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity logger = logging.getLogger(__name__) @@ -10,24 +15,34 @@ class ImOpenStep(scenario_step.ScenarioStep): Empty step to handle event and avoid 500's. In case we need it in the future. """ - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: logger.info("InOpenStep, doing nothing.") class AppHomeOpenedStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: pass -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_IM_OPEN, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.IM_OPEN, "step": ImOpenStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_APP_HOME_OPENED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.APP_HOME_OPENED, "step": AppHomeOpenedStep, }, ] diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index 13bde725..bf541c81 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -1,18 +1,40 @@ +import enum import json +import typing from uuid import uuid4 from django.conf import settings +from django.db.models import Model from apps.alerts.models import AlertReceiveChannel, EscalationChain from apps.alerts.paging import ( - USER_HAS_NO_NOTIFICATION_POLICY, - USER_IS_NOT_ON_CALL, + AvailabilityWarning, + PagingError, + ScheduleNotifications, + UserNotifications, check_user_availability, direct_paging, ) -from apps.slack.constants import PRIVATE_METADATA_MAX_LENGTH +from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH from apps.slack.scenarios import scenario_step from apps.slack.slack_client.exceptions import SlackAPIException +from apps.slack.types import ( + Block, + BlockActionType, + CompositionObjects, + EventPayload, + ModalView, + PayloadType, + ScenarioRoute, +) + +if typing.TYPE_CHECKING: + from django.db.models.manager import RelatedManager + + from apps.schedules.models import OnCallSchedule + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization, Team, User + DIRECT_PAGING_TEAM_SELECT_ID = "paging_team_select" DIRECT_PAGING_ORG_SELECT_ID = "paging_org_select" @@ -25,28 +47,36 @@ DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID = "paging_additional_responders_inp DEFAULT_TEAM_VALUE = "default_team" -# selected user available actions -DEFAULT_POLICY = "default" -IMPORTANT_POLICY = "important" -REMOVE_ACTION = "remove" +class Policy(enum.StrEnum): + """ + selected user available actions + """ + + DEFAULT = "default" + IMPORTANT = "important" + REMOVE_ACTION = "remove" + ITEM_ACTIONS = ( - (DEFAULT_POLICY, "Set default notification policy"), - (IMPORTANT_POLICY, "Set important notification policy"), - (REMOVE_ACTION, "Remove from escalation"), + (Policy.DEFAULT, "Set default notification policy"), + (Policy.IMPORTANT, "Set important notification policy"), + (Policy.REMOVE_ACTION, "Remove from escalation"), ) # helpers to manage current selected users/schedules state -SCHEDULES_DATA_KEY = "schedules" -USERS_DATA_KEY = "users" + +class DataKey(enum.StrEnum): + SCHEDULES = "schedules" + USERS = "users" + # https://api.slack.com/reference/block-kit/block-elements#static_select MAX_STATIC_SELECT_OPTIONS = 100 -def add_or_update_item(payload, key, item_pk, policy): +def add_or_update_item(payload: EventPayload.Any, key: DataKey, item_pk: str, policy: Policy) -> EventPayload: metadata = json.loads(payload["view"]["private_metadata"]) metadata[key][item_pk] = policy updated_metadata = json.dumps(metadata) @@ -56,7 +86,7 @@ def add_or_update_item(payload, key, item_pk, policy): return payload -def remove_item(payload, key, item_pk): +def remove_item(payload: EventPayload.Any, key: DataKey, item_pk: str) -> EventPayload: metadata = json.loads(payload["view"]["private_metadata"]) if item_pk in metadata[key]: del metadata[key][item_pk] @@ -64,17 +94,22 @@ def remove_item(payload, key, item_pk): return payload -def reset_items(payload): +def reset_items(payload: EventPayload.Any) -> EventPayload: metadata = json.loads(payload["view"]["private_metadata"]) - for key in (USERS_DATA_KEY, SCHEDULES_DATA_KEY): + for key in (DataKey.USERS, DataKey.SCHEDULES): metadata[key] = {} payload["view"]["private_metadata"] = json.dumps(metadata) return payload -def get_current_items(payload, key, qs): +T = typing.TypeVar("T", bound=Model) + + +def get_current_items( + payload: EventPayload.Any, key: DataKey, qs: "RelatedManager['T']" +) -> typing.List[typing.Tuple[T, Policy]]: metadata = json.loads(payload["view"]["private_metadata"]) - items = [] + items: typing.List[T] = [] for u, p in metadata[key].items(): item = qs.filter(pk=u).first() items.append((item, p)) @@ -89,7 +124,12 @@ class StartDirectPaging(scenario_step.ScenarioStep): command_name = [settings.SLACK_DIRECT_PAGING_SLASH_COMMAND] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: input_id_prefix = _generate_input_id_prefix() try: @@ -101,8 +141,8 @@ class StartDirectPaging(scenario_step.ScenarioStep): "channel_id": channel_id, "input_id_prefix": input_id_prefix, "submit_routing_uid": FinishDirectPaging.routing_uid(), - USERS_DATA_KEY: {}, - SCHEDULES_DATA_KEY: {}, + DataKey.USERS: {}, + DataKey.SCHEDULES: {}, } initial_payload = {"view": {"private_metadata": json.dumps(private_metadata)}} view = render_dialog(slack_user_identity, slack_team_identity, initial_payload, initial=True) @@ -116,7 +156,12 @@ class StartDirectPaging(scenario_step.ScenarioStep): class FinishDirectPaging(scenario_step.ScenarioStep): """Handle page command dialog submit.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: title = _get_title_from_payload(payload) message = _get_message_from_payload(payload) private_metadata = json.loads(payload["view"]["private_metadata"]) @@ -129,16 +174,18 @@ class FinishDirectPaging(scenario_step.ScenarioStep): user = slack_user_identity.get_user(selected_organization) # Only pass users/schedules if additional responders checkbox is checked - selected_users, selected_schedules = None, None + selected_users: UserNotifications | None = None + selected_schedules: ScheduleNotifications | None = None + is_additional_responders_checked = _get_additional_responders_checked_from_payload(payload, input_id_prefix) if is_additional_responders_checked: selected_users = [ - (u, p == IMPORTANT_POLICY) - for u, p in get_current_items(payload, USERS_DATA_KEY, selected_organization.users) + (u, p == Policy.IMPORTANT) + for u, p in get_current_items(payload, DataKey.USERS, selected_organization.users) ] selected_schedules = [ - (s, p == IMPORTANT_POLICY) - for s, p in get_current_items(payload, SCHEDULES_DATA_KEY, selected_organization.oncall_schedules) + (s, p == Policy.IMPORTANT) + for s, p in get_current_items(payload, DataKey.SCHEDULES, selected_organization.oncall_schedules) ] # trigger direct paging to selected team + users/schedules @@ -179,7 +226,12 @@ class FinishDirectPaging(scenario_step.ScenarioStep): class OnPagingOrgChange(scenario_step.ScenarioStep): """Reload form with updated organization.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: updated_payload = reset_items(payload) view = render_dialog(slack_user_identity, slack_team_identity, updated_payload) self._slack_client.api_call( @@ -193,7 +245,12 @@ class OnPagingOrgChange(scenario_step.ScenarioStep): class OnPagingTeamChange(scenario_step.ScenarioStep): """Set team.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: view = render_dialog(slack_user_identity, slack_team_identity, payload) self._slack_client.api_call( "views.update", @@ -213,7 +270,12 @@ class OnPagingUserChange(scenario_step.ScenarioStep): It will perform a user availability check, pushing a new modal for additional confirmation if needed. """ - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: private_metadata = json.loads(payload["view"]["private_metadata"]) selected_organization = _get_selected_org_from_payload( payload, private_metadata["input_id_prefix"], slack_team_identity, slack_user_identity @@ -236,7 +298,7 @@ class OnPagingUserChange(scenario_step.ScenarioStep): # user is available to be paged error_msg = None try: - updated_payload = add_or_update_item(payload, USERS_DATA_KEY, selected_user.pk, DEFAULT_POLICY) + updated_payload = add_or_update_item(payload, DataKey.USERS, selected_user.pk, Policy.DEFAULT) except ValueError: updated_payload = payload error_msg = "Cannot add user, maximum responders exceeded" @@ -252,15 +314,20 @@ class OnPagingUserChange(scenario_step.ScenarioStep): class OnPagingItemActionChange(scenario_step.ScenarioStep): """Reload form with updated user details.""" - def _parse_action(self, payload): + def _parse_action(self, payload: EventPayload.Any) -> typing.Tuple[Policy, str, str]: value = payload["actions"][0]["selected_option"]["value"] return value.split("|") - def process_scenario(self, slack_user_identity, slack_team_identity, payload, policy=None): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: policy, key, user_pk = self._parse_action(payload) error_msg = None - if policy == REMOVE_ACTION: + if policy == Policy.REMOVE_ACTION: updated_payload = remove_item(payload, key, user_pk) else: try: @@ -281,7 +348,12 @@ class OnPagingItemActionChange(scenario_step.ScenarioStep): class OnPagingConfirmUserChange(scenario_step.ScenarioStep): """Confirm user selection despite not being available.""" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: metadata = json.loads(payload["view"]["private_metadata"]) # recreate original view state and metadata @@ -289,8 +361,8 @@ class OnPagingConfirmUserChange(scenario_step.ScenarioStep): "channel_id": metadata["channel_id"], "input_id_prefix": metadata["input_id_prefix"], "submit_routing_uid": metadata["submit_routing_uid"], - USERS_DATA_KEY: metadata[USERS_DATA_KEY], - SCHEDULES_DATA_KEY: metadata[SCHEDULES_DATA_KEY], + DataKey.USERS: metadata[DataKey.USERS], + DataKey.SCHEDULES: metadata[DataKey.SCHEDULES], } previous_view_payload = { "view": { @@ -302,9 +374,7 @@ class OnPagingConfirmUserChange(scenario_step.ScenarioStep): selected_user = _get_selected_user_from_payload(previous_view_payload, private_metadata["input_id_prefix"]) error_msg = None try: - updated_payload = add_or_update_item( - previous_view_payload, USERS_DATA_KEY, selected_user.pk, DEFAULT_POLICY - ) + updated_payload = add_or_update_item(previous_view_payload, DataKey.USERS, selected_user.pk, Policy.DEFAULT) except ValueError: updated_payload = payload error_msg = "Cannot add user, maximum responders exceeded" @@ -323,7 +393,12 @@ class OnPagingScheduleChange(scenario_step.ScenarioStep): It will perform a user availability check, pushing a new modal for additional confirmation if needed. """ - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: private_metadata = json.loads(payload["view"]["private_metadata"]) selected_schedule = _get_selected_schedule_from_payload(payload, private_metadata["input_id_prefix"]) if selected_schedule is None: @@ -331,7 +406,7 @@ class OnPagingScheduleChange(scenario_step.ScenarioStep): error_msg = None try: - updated_payload = add_or_update_item(payload, SCHEDULES_DATA_KEY, selected_schedule.pk, DEFAULT_POLICY) + updated_payload = add_or_update_item(payload, DataKey.SCHEDULES, selected_schedule.pk, Policy.DEFAULT) except ValueError: updated_payload = payload error_msg = "Cannot add schedule, maximum responders exceeded" @@ -346,10 +421,14 @@ class OnPagingScheduleChange(scenario_step.ScenarioStep): # slack view/blocks rendering helpers -DIVIDER_BLOCK = {"type": "divider"} - -def render_dialog(slack_user_identity, slack_team_identity, payload, initial=False, error_msg=None): +def render_dialog( + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + initial=False, + error_msg=None, +) -> ModalView: private_metadata = json.loads(payload["view"]["private_metadata"]) submit_routing_uid = private_metadata.get("submit_routing_uid") @@ -384,7 +463,7 @@ def render_dialog(slack_user_identity, slack_team_identity, payload, initial=Fal ) # Add title and message inputs - blocks = [_get_title_input(payload), _get_message_input(payload)] + blocks: Block.AnyBlocks = [_get_title_input(payload), _get_message_input(payload)] # Add organization select if more than one organization available for user if len(available_organizations) > 1: @@ -397,12 +476,11 @@ def render_dialog(slack_user_identity, slack_team_identity, payload, initial=Fal blocks += team_select_blocks blocks += additional_responders_blocks - view = _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) - return view + return _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) -def _get_form_view(routing_uid, blocks, private_metadata): - view = { +def _get_form_view(routing_uid: str, blocks: Block.AnyBlocks, private_metadata: str) -> ModalView: + view: ModalView = { "type": "modal", "callback_id": routing_uid, "title": { @@ -421,12 +499,13 @@ def _get_form_view(routing_uid, blocks, private_metadata): "blocks": blocks, "private_metadata": private_metadata, } - return view -def _get_organization_select(organizations, value, input_id_prefix): - organizations_options = [] +def _get_organization_select( + organizations: "RelatedManager['Organization']", value: "Organization", input_id_prefix: str +) -> Block.Input: + organizations_options: typing.List[CompositionObjects.Option] = [] initial_option_idx = 0 for idx, org in enumerate(organizations): if org == value: @@ -442,7 +521,7 @@ def _get_organization_select(organizations, value, input_id_prefix): } ) - organization_select = { + organization_select: Block.Input = { "type": "input", "block_id": input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID, "label": { @@ -462,17 +541,20 @@ def _get_organization_select(organizations, value, input_id_prefix): return organization_select -def _get_select_field_value(payload, prefix_id, routing_uid, field_id): +def _get_select_field_value(payload: EventPayload.Any, prefix_id: str, routing_uid: str, field_id: str) -> str | None: try: field = payload["view"]["state"]["values"][prefix_id + field_id][routing_uid]["selected_option"] except KeyError: return None - - if field: - return field["value"] + return field["value"] if field else None -def _get_selected_org_from_payload(payload, input_id_prefix, slack_team_identity, slack_user_identity): +def _get_selected_org_from_payload( + payload: EventPayload.Any, + input_id_prefix: str, + slack_team_identity: "SlackTeamIdentity", + slack_user_identity: "SlackUserIdentity", +) -> typing.Optional["Organization"]: from apps.user_management.models import Organization selected_org_id = _get_select_field_value( @@ -480,16 +562,20 @@ def _get_selected_org_from_payload(payload, input_id_prefix, slack_team_identity ) if selected_org_id is None: return _get_available_organizations(slack_team_identity, slack_user_identity).first() - else: - org = Organization.objects.filter(pk=selected_org_id).first() - return org + return Organization.objects.filter(pk=selected_org_id).first() -def _get_team_select_blocks(slack_user_identity, organization, is_selected, value, input_id_prefix): +def _get_team_select_blocks( + slack_user_identity: "SlackUserIdentity", + organization: "Organization", + is_selected: bool, + value: "Team", + input_id_prefix: str, +) -> Block.AnyBlocks: user = slack_user_identity.get_user(organization) # TODO: handle None teams = user.available_teams - team_options = [] + team_options: typing.List[CompositionObjects.Option] = [] # Adding pseudo option for default team initial_option_idx = 0 team_options.append( @@ -516,7 +602,7 @@ def _get_team_select_blocks(slack_user_identity, organization, is_selected, valu } ) - team_select = { + team_select: Block.Input = { "type": "input", "block_id": input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID, "label": { @@ -540,7 +626,7 @@ def _get_team_select_blocks(slack_user_identity, organization, is_selected, valu return [team_select, _get_team_select_context(organization, value)] -def _get_team_select_context(organization, team): +def _get_team_select_context(organization: "Organization", team: "Team") -> Block.Context: team_name = team.name if team else "No team" alert_receive_channel = AlertReceiveChannel.objects.filter( organization=organization, @@ -569,7 +655,7 @@ def _get_team_select_context(organization, team): else: context_text = f"Integration <{alert_receive_channel.web_link}|{alert_receive_channel.verbal_name} ({team_name})> will be used for notification." - context = { + context: Block.Context = { "type": "context", "elements": [ { @@ -582,31 +668,38 @@ def _get_team_select_context(organization, team): def _get_additional_responders_blocks( - payload, organization, input_id_prefix, is_additional_responders_checked, error_msg -): - checkbox_option = { + payload: EventPayload.Any, + organization: "Organization", + input_id_prefix, + is_additional_responders_checked: bool, + error_msg: str | None, +) -> Block.AnyBlocks: + checkbox_option: CompositionObjects.Option = { "text": { "type": "plain_text", "text": "Notify additional responders", }, } - blocks = [ - { - "type": "input", - "block_id": input_id_prefix + DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID, - "label": { - "type": "plain_text", - "text": "Additional responders", + blocks: Block.AnyBlocks = [ + typing.cast( + Block.Input, + { + "type": "input", + "block_id": input_id_prefix + DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID, + "label": { + "type": "plain_text", + "text": "Additional responders", + }, + "element": { + "type": "checkboxes", + "options": [checkbox_option], + "action_id": OnPagingCheckAdditionalResponders.routing_uid(), + }, + "optional": True, + "dispatch_action": True, }, - "element": { - "type": "checkboxes", - "options": [checkbox_option], - "action_id": OnPagingCheckAdditionalResponders.routing_uid(), - }, - "optional": True, - "dispatch_action": True, - } + ), ] if is_additional_responders_checked: @@ -614,14 +707,17 @@ def _get_additional_responders_blocks( if error_msg: blocks += [ - { - "type": "section", - "block_id": "error_message", - "text": { - "type": "mrkdwn", - "text": f":warning: {error_msg}", + typing.cast( + Block.Section, + { + "type": "section", + "block_id": "error_message", + "text": { + "type": "mrkdwn", + "text": f":warning: {error_msg}", + }, }, - } + ), ] if is_additional_responders_checked: @@ -630,22 +726,24 @@ def _get_additional_responders_blocks( blocks += [users_select, schedules_select] # selected items - selected_users = get_current_items(payload, USERS_DATA_KEY, organization.users) - selected_schedules = get_current_items(payload, SCHEDULES_DATA_KEY, organization.oncall_schedules) + selected_users = get_current_items(payload, DataKey.USERS, organization.users) + selected_schedules = get_current_items(payload, DataKey.SCHEDULES, organization.oncall_schedules) if selected_users or selected_schedules: - blocks += [DIVIDER_BLOCK] - blocks += _get_selected_entries_list(input_id_prefix, USERS_DATA_KEY, selected_users) - blocks += _get_selected_entries_list(input_id_prefix, SCHEDULES_DATA_KEY, selected_schedules) - blocks += [DIVIDER_BLOCK] + blocks += [DIVIDER] + blocks += _get_selected_entries_list(input_id_prefix, DataKey.USERS, selected_users) + blocks += _get_selected_entries_list(input_id_prefix, DataKey.SCHEDULES, selected_schedules) + blocks += [DIVIDER] return blocks -def _get_users_select(organization, input_id_prefix, action_id, max_options_per_group=MAX_STATIC_SELECT_OPTIONS): +def _get_users_select( + organization: "Organization", input_id_prefix: str, action_id: str, max_options_per_group=MAX_STATIC_SELECT_OPTIONS +) -> Block.Context | Block.Section: users = organization.users.all() - user_options = [ + user_options: typing.List[CompositionObjects.Option] = [ { "text": { "type": "plain_text", @@ -658,9 +756,10 @@ def _get_users_select(organization, input_id_prefix, action_id, max_options_per_ ] if not user_options: - return {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]} + user_select: Block.Context = {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]} + return user_select - user_select = { + user_select: Block.Section = { "type": "section", "text": {"type": "mrkdwn", "text": "Notify user"}, "block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID, @@ -679,10 +778,12 @@ def _get_users_select(organization, input_id_prefix, action_id, max_options_per_ return user_select -def _get_schedules_select(organization, input_id_prefix, action_id, max_options_per_group=MAX_STATIC_SELECT_OPTIONS): +def _get_schedules_select( + organization: "Organization", input_id_prefix: str, action_id: str, max_options_per_group=MAX_STATIC_SELECT_OPTIONS +) -> Block.Context | Block.Section: schedules = organization.oncall_schedules.all() - schedule_options = [ + schedule_options: typing.List[CompositionObjects.Option] = [ { "text": { "type": "plain_text", @@ -695,9 +796,13 @@ def _get_schedules_select(organization, input_id_prefix, action_id, max_options_ ] if not schedule_options: - return {"type": "context", "elements": [{"type": "mrkdwn", "text": "No schedules available"}]} + schedule_select: Block.Context = { + "type": "context", + "elements": [{"type": "mrkdwn", "text": "No schedules available"}], + } + return schedule_select - schedule_select = { + schedule_select: Block.Section = { "type": "section", "text": {"type": "mrkdwn", "text": "Notify schedule"}, "block_id": input_id_prefix + DIRECT_PAGING_SCHEDULE_SELECT_ID, @@ -716,10 +821,12 @@ def _get_schedules_select(organization, input_id_prefix, action_id, max_options_ return schedule_select -def _get_option_groups(options, max_options_per_group): +def _get_option_groups( + options: typing.List[CompositionObjects.Option], max_options_per_group: int +) -> typing.List[CompositionObjects.OptionGroup]: chunks = [options[x : x + max_options_per_group] for x in range(0, len(options), max_options_per_group)] - option_groups = [] + option_groups: typing.List[CompositionObjects.OptionGroup] = [] for idx, group in enumerate(chunks): start = idx * max_options_per_group + 1 end = idx * max_options_per_group + max_options_per_group @@ -733,10 +840,12 @@ def _get_option_groups(options, max_options_per_group): return option_groups -def _get_selected_entries_list(input_id_prefix, key, entries): - current_entries = [] +def _get_selected_entries_list( + input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]] +) -> typing.List[Block.Section]: + current_entries: typing.List[Block.Section] = [] for entry, policy in entries: - if key == USERS_DATA_KEY: + if key == DataKey.USERS: icon = ":bust_in_silhouette:" name = entry.name or entry.username extra = entry.timezone @@ -766,7 +875,9 @@ def _get_selected_entries_list(input_id_prefix, key, entries): return current_entries -def _display_availability_warnings(payload, warnings, organization, user): +def _display_availability_warnings( + payload: EventPayload.Any, warnings: typing.List[AvailabilityWarning], organization: "Organization", user: "User" +) -> ModalView: metadata = json.loads(payload["view"]["private_metadata"]) return _get_availability_warnings_view( warnings, @@ -779,17 +890,23 @@ def _display_availability_warnings(payload, warnings, organization, user): "input_id_prefix": metadata["input_id_prefix"], "channel_id": metadata["channel_id"], "submit_routing_uid": metadata["submit_routing_uid"], - USERS_DATA_KEY: metadata[USERS_DATA_KEY], - SCHEDULES_DATA_KEY: metadata[SCHEDULES_DATA_KEY], + DataKey.USERS: metadata[DataKey.USERS], + DataKey.SCHEDULES: metadata[DataKey.SCHEDULES], } ), ) -def _get_availability_warnings_view(warnings, organization, user, callback_id, private_metadata): - messages = [] +def _get_availability_warnings_view( + warnings: typing.List[AvailabilityWarning], + organization: "Organization", + user: "User", + callback_id: str, + private_metadata: str, +) -> ModalView: + messages: typing.List[str] = [] for w in warnings: - if w["error"] == USER_IS_NOT_ON_CALL: + if w["error"] == PagingError.USER_IS_NOT_ON_CALL: messages.append( f":warning: User *{user.name or user.username}* is not on-call.\nWe recommend you to select on-call users first." ) @@ -800,10 +917,10 @@ def _get_availability_warnings_view(warnings, organization, user, callback_id, p oncall_users = organization.users.filter(public_primary_key__in=users) usernames = ", ".join(f"*{u.name or u.username}*" for u in oncall_users) messages.append(f":spiral_calendar_pad: {schedule}: {usernames}") - elif w["error"] == USER_HAS_NO_NOTIFICATION_POLICY: + elif w["error"] == PagingError.USER_HAS_NO_NOTIFICATION_POLICY: messages.append(f":warning: User *{user.name or user.username}* has no notification policy setup.") - return { + view: ModalView = { "type": "modal", "callback_id": callback_id, "title": {"type": "plain_text", "text": "Are you sure?"}, @@ -820,9 +937,12 @@ def _get_availability_warnings_view(warnings, organization, user, callback_id, p ], "private_metadata": private_metadata, } + return view -def _get_selected_team_from_payload(payload, input_id_prefix): +def _get_selected_team_from_payload( + payload: EventPayload.Any, input_id_prefix: str +) -> typing.Tuple[str | None, typing.Optional["Team"]]: from apps.user_management.models import Team selected_team_id = _get_select_field_value( @@ -839,7 +959,7 @@ def _get_selected_team_from_payload(payload, input_id_prefix): return selected_team_id, team -def _get_additional_responders_checked_from_payload(payload, input_id_prefix): +def _get_additional_responders_checked_from_payload(payload: EventPayload.Any, input_id_prefix: str) -> bool: try: selected_options = payload["view"]["state"]["values"][ input_id_prefix + DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID @@ -850,7 +970,7 @@ def _get_additional_responders_checked_from_payload(payload, input_id_prefix): return len(selected_options) > 0 -def _get_selected_user_from_payload(payload, input_id_prefix): +def _get_selected_user_from_payload(payload: EventPayload.Any, input_id_prefix: str) -> typing.Optional["User"]: from apps.user_management.models import User selected_user_id = _get_select_field_value( @@ -859,28 +979,33 @@ def _get_selected_user_from_payload(payload, input_id_prefix): if selected_user_id is not None: user = User.objects.filter(pk=selected_user_id).first() return user + return None -def _get_selected_schedule_from_payload(payload, input_id_prefix): +def _get_selected_schedule_from_payload( + payload: EventPayload.Any, input_id_prefix: str +) -> typing.Optional["OnCallSchedule"]: from apps.schedules.models import OnCallSchedule selected_schedule_id = _get_select_field_value( payload, input_id_prefix, OnPagingScheduleChange.routing_uid(), DIRECT_PAGING_SCHEDULE_SELECT_ID ) if selected_schedule_id is not None: - schedule = OnCallSchedule.objects.filter(pk=selected_schedule_id).first() - return schedule + return OnCallSchedule.objects.filter(pk=selected_schedule_id).first() + return None -def _get_and_change_input_id_prefix_from_metadata(metadata): +def _get_and_change_input_id_prefix_from_metadata( + metadata: typing.Dict[str, str] +) -> typing.Tuple[str, str, typing.Dict[str, str]]: old_input_id_prefix = metadata["input_id_prefix"] new_input_id_prefix = _generate_input_id_prefix() metadata["input_id_prefix"] = new_input_id_prefix return old_input_id_prefix, new_input_id_prefix, metadata -def _get_title_input(payload): - title_input_block = { +def _get_title_input(payload: EventPayload.Any) -> Block.Input: + title_input_block: Block.Input = { "type": "input", "block_id": DIRECT_PAGING_TITLE_INPUT_ID, "label": { @@ -901,13 +1026,13 @@ def _get_title_input(payload): return title_input_block -def _get_title_from_payload(payload): +def _get_title_from_payload(payload: EventPayload.Any) -> str: title = payload["view"]["state"]["values"][DIRECT_PAGING_TITLE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"] return title -def _get_message_input(payload): - message_input_block = { +def _get_message_input(payload: EventPayload.Any) -> Block.Input: + message_input_block: Block.Input = { "type": "input", "block_id": DIRECT_PAGING_MESSAGE_INPUT_ID, "label": { @@ -930,15 +1055,16 @@ def _get_message_input(payload): return message_input_block -def _get_message_from_payload(payload): - message = ( +def _get_message_from_payload(payload: EventPayload.Any) -> str: + return ( payload["view"]["state"]["values"][DIRECT_PAGING_MESSAGE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"] or "" ) - return message -def _get_available_organizations(slack_team_identity, slack_user_identity): +def _get_available_organizations( + slack_team_identity: "SlackTeamIdentity", slack_user_identity: "SlackUserIdentity" +) -> "RelatedManager['Organization']": return ( slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity) .order_by("pk") @@ -948,59 +1074,59 @@ def _get_available_organizations(slack_team_identity, slack_user_identity): # _generate_input_id_prefix returns uniq str to not to preserve input's values between view update # https://api.slack.com/methods/views.update#markdown -def _generate_input_id_prefix(): +def _generate_input_id_prefix() -> str: return str(uuid4()) -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": OnPagingOrgChange.routing_uid(), "step": OnPagingOrgChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": OnPagingTeamChange.routing_uid(), "step": OnPagingTeamChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_CHECKBOXES, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.CHECKBOXES, "block_action_id": OnPagingCheckAdditionalResponders.routing_uid(), "step": OnPagingCheckAdditionalResponders, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": OnPagingUserChange.routing_uid(), "step": OnPagingUserChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "payload_type": PayloadType.VIEW_SUBMISSION, "view_callback_id": OnPagingConfirmUserChange.routing_uid(), "step": OnPagingConfirmUserChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": OnPagingScheduleChange.routing_uid(), "step": OnPagingScheduleChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_OVERFLOW, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.OVERFLOW, "block_action_id": OnPagingItemActionChange.routing_uid(), "step": OnPagingItemActionChange, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND, + "payload_type": PayloadType.SLASH_COMMAND, "command_name": StartDirectPaging.command_name, "step": StartDirectPaging, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "payload_type": PayloadType.VIEW_SUBMISSION, "view_callback_id": FinishDirectPaging.routing_uid(), "step": FinishDirectPaging, }, diff --git a/engine/apps/slack/scenarios/profile_update.py b/engine/apps/slack/scenarios/profile_update.py index a967f242..07c7b453 100644 --- a/engine/apps/slack/scenarios/profile_update.py +++ b/engine/apps/slack/scenarios/profile_update.py @@ -1,9 +1,20 @@ +import typing + from apps.slack.constants import SLACK_BOT_ID from apps.slack.scenarios import scenario_step +from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity class ProfileUpdateStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Triggered by action: Any update in Slack Profile. Dangerous because it's often triggered by internal client's company systems. @@ -40,17 +51,17 @@ class ProfileUpdateStep(scenario_step.ScenarioStep): slack_user_identity.save() -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ # Slack event "user_change" is deprecated in favor of "user_profile_changed". # Handler for "user_change" is kept for backward compatibility. { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_USER_CHANGE, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.USER_CHANGE, "step": ProfileUpdateStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_USER_PROFILE_CHANGED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.USER_PROFILE_CHANGED, "step": ProfileUpdateStep, }, ] diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index 07e14828..4e6bd23a 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -1,17 +1,31 @@ import datetime import json import logging +import typing from django.db.models import Q from apps.api.permissions import RBACPermission +from apps.slack.constants import DIVIDER from apps.slack.scenarios import scenario_step from apps.slack.slack_client.exceptions import SlackAPIException +from apps.slack.types import ( + Block, + BlockActionType, + EventPayload, + InteractiveMessageActionType, + PayloadType, + ScenarioRoute, +) from apps.user_management.models import User from common.api_helpers.utils import create_engine_url from .step_mixins import AlertGroupActionsMixin +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroup, ResolutionNote, ResolutionNoteSlackMessage + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -23,7 +37,12 @@ class AddToResolutionNoteStep(scenario_step.ScenarioStep): "add_resolution_note_develop", ] - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: from apps.alerts.models import ResolutionNote, ResolutionNoteSlackMessage from apps.slack.models import SlackMessage, SlackUserIdentity @@ -154,7 +173,7 @@ class AddToResolutionNoteStep(scenario_step.ScenarioStep): class UpdateResolutionNoteStep(scenario_step.ScenarioStep): - def process_signal(self, alert_group, resolution_note): + def process_signal(self, alert_group: "AlertGroup", resolution_note: "ResolutionNote") -> None: if resolution_note.deleted_at: self.remove_resolution_note_slack_message(resolution_note) else: @@ -164,7 +183,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): alert_group=alert_group, ) - def remove_resolution_note_slack_message(self, resolution_note): + def remove_resolution_note_slack_message(self, resolution_note: "ResolutionNote") -> None: resolution_note_slack_message = resolution_note.resolution_note_slack_message if resolution_note_slack_message is not None: resolution_note_slack_message.added_to_resolution_note = False @@ -212,7 +231,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): else: self.remove_resolution_note_reaction(resolution_note_slack_message) - def post_or_update_resolution_note_in_thread(self, resolution_note): + def post_or_update_resolution_note_in_thread(self, resolution_note: "ResolutionNote") -> None: from apps.alerts.models import ResolutionNoteSlackMessage resolution_note_slack_message = resolution_note.resolution_note_slack_message @@ -321,11 +340,11 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): resolution_note_slack_message.text = resolution_note.text resolution_note_slack_message.save(update_fields=["text"]) - def update_alert_group_resolution_note_button(self, alert_group): + def update_alert_group_resolution_note_button(self, alert_group: "AlertGroup") -> None: if alert_group.slack_message is not None: self.alert_group_slack_service.update_alert_group_slack_message(alert_group) - def add_resolution_note_reaction(self, slack_thread_message): + def add_resolution_note_reaction(self, slack_thread_message: "ResolutionNoteSlackMessage"): try: self._slack_client.api_call( "reactions.add", @@ -336,7 +355,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): except SlackAPIException as e: print(e) # TODO:770: log instead of print - def remove_resolution_note_reaction(self, slack_thread_message): + def remove_resolution_note_reaction(self, slack_thread_message: "ResolutionNoteSlackMessage") -> None: try: self._slack_client.api_call( "reactions.remove", @@ -347,8 +366,8 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): except SlackAPIException as e: print(e) - def get_resolution_note_blocks(self, resolution_note): - blocks = [] + def get_resolution_note_blocks(self, resolution_note: "ResolutionNote") -> Block.AnyBlocks: + blocks: Block.AnyBlocks = [] author_verbal = resolution_note.author_verbal(mention=False) resolution_note_text_block = { "type": "section", @@ -373,7 +392,18 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep RESOLUTION_NOTE_TEXT_BLOCK_ID = "resolution_note_text" RESOLUTION_NOTE_MESSAGES_MAX_COUNT = 25 - def process_scenario(self, slack_user_identity, slack_team_identity, payload, data=None): + class ScenarioData(typing.TypedDict): + resolution_note_window_action: str + alert_group_pk: str + action_resolve: bool + + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + data: ScenarioData | None = None, + ) -> None: if data: # Argument "data" is used when step is called from other step, e.g. AddRemoveThreadMessageStep from apps.alerts.models import AlertGroup @@ -392,7 +422,7 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep action_resolve = value.get("action_resolve", False) channel_id = payload["channel"]["id"] if "channel" in payload else None - blocks = [] + blocks: Block.AnyBlocks = [] if channel_id: members = slack_team_identity.get_conversation_members(self._slack_client, channel_id) @@ -447,10 +477,12 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep view=view, ) - def get_resolution_notes_blocks(self, alert_group, resolution_note_window_action, action_resolve): + def get_resolution_notes_blocks( + self, alert_group: "AlertGroup", resolution_note_window_action: str, action_resolve: bool + ) -> Block.AnyBlocks: from apps.alerts.models import ResolutionNote - blocks = [] + blocks: Block.AnyBlocks = [] other_resolution_notes = alert_group.resolution_notes.filter(~Q(source=ResolutionNote.Source.SLACK)) resolution_note_slack_messages = alert_group.resolution_note_slack_messages.filter( @@ -459,62 +491,61 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep if resolution_note_slack_messages.count() > self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT: blocks.extend( [ - { - "type": "divider", - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - ":warning: Listing up to last {} thread messages, " - "you can still add any other message using contextual menu actions." - ).format(self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT), + DIVIDER, + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + ":warning: Listing up to last {} thread messages, " + "you can still add any other message using contextual menu actions." + ).format(self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT), + }, }, - }, + ), ] ) if action_resolve: blocks.extend( [ - { - "type": "divider", - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":warning: You cannot resolve this incident without resolution note.", + DIVIDER, + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":warning: You cannot resolve this incident without resolution note.", + }, }, - }, + ), ] ) if "error" in resolution_note_window_action: blocks.extend( [ - { - "type": "divider", - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":warning: _Oops! You cannot remove this message from resolution notes when incident is " - "resolved. Reason: `resolution note is required` setting. Add another message at first._ ", + DIVIDER, + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":warning: _Oops! You cannot remove this message from resolution notes when incident is " + "resolved. Reason: `resolution note is required` setting. Add another message at first._ ", + }, }, - }, + ), ] ) for message in resolution_note_slack_messages[: self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT]: user_verbal = message.user.get_username_with_slack_verbal(mention=True) - blocks.append( - { - "type": "divider", - } - ) - message_block = { + blocks.append(DIVIDER) + message_block: Block.Section = { "type": "section", "text": { "type": "mrkdwn", @@ -549,29 +580,26 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep if other_resolution_notes: blocks.extend( [ - { - "type": "divider", - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Resolution notes from other sources:*", + DIVIDER, + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Resolution notes from other sources:*", + }, }, - }, + ), ] ) for resolution_note in other_resolution_notes: resolution_note_slack_message = resolution_note.resolution_note_slack_message user_verbal = resolution_note.author_verbal(mention=True) message_timestamp = datetime.datetime.timestamp(resolution_note.created_at) - blocks.append( - { - "type": "divider", - } - ) + blocks.append(DIVIDER) source = "web" if resolution_note.source == ResolutionNote.Source.WEB else "slack" - message_block = { + message_block: Block.Section = { "type": "section", "text": { "type": "mrkdwn", @@ -624,46 +652,51 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep # there aren't any resolution notes yet, display a hint instead link_to_instruction = create_engine_url("static/images/postmortem.gif") blocks = [ - { - "type": "divider", - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":bulb: You can add a message to the resolution notes via context menu:", + DIVIDER, + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":bulb: You can add a message to the resolution notes via context menu:", + }, }, - }, - { - "type": "image", - "title": { - "type": "plain_text", - "text": "Add a resolution note", + ), + typing.cast( + Block.Image, + { + "type": "image", + "title": { + "type": "plain_text", + "text": "Add a resolution note", + }, + "image_url": link_to_instruction, + "alt_text": "Add to postmortem context menu", }, - "image_url": link_to_instruction, - "alt_text": "Add to postmortem context menu", - }, + ), ] return blocks - def get_invite_bot_tip_blocks(self, channel): + def get_invite_bot_tip_blocks(self, channel: str) -> Block.AnyBlocks: link_to_instruction = create_engine_url("static/images/postmortem.gif") - blocks = [ - { - "type": "divider", - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": f":bulb: To include messages from thread to resolution note `/invite` Grafana OnCall to " - f"<#{channel}>. Or you can add a message via " - f"<{link_to_instruction}|context menu>.", - }, - ], - }, + blocks: Block.AnyBlocks = [ + DIVIDER, + typing.cast( + Block.Context, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f":bulb: To include messages from thread to resolution note `/invite` Grafana OnCall to " + f"<#{channel}>. Or you can add a message via " + f"<{link_to_instruction}|context menu>.", + }, + ], + }, + ), ] return blocks @@ -674,7 +707,12 @@ class ReadEditPostmortemStep(ResolutionNoteModalStep): class AddRemoveThreadMessageStep(UpdateResolutionNoteStep, scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: from apps.alerts.models import AlertGroup, ResolutionNote, ResolutionNoteSlackMessage value = json.loads(payload["actions"][0]["value"]) @@ -741,33 +779,33 @@ class AddRemoveThreadMessageStep(UpdateResolutionNoteStep, scenario_step.Scenari ) -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": ReadEditPostmortemStep.routing_uid(), "step": ReadEditPostmortemStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": ResolutionNoteModalStep.routing_uid(), "step": ResolutionNoteModalStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, + "payload_type": PayloadType.INTERACTIVE_MESSAGE, + "action_type": InteractiveMessageActionType.BUTTON, "action_name": ResolutionNoteModalStep.routing_uid(), "step": ResolutionNoteModalStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": AddRemoveThreadMessageStep.routing_uid(), "step": AddRemoveThreadMessageStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION, + "payload_type": PayloadType.MESSAGE_ACTION, "message_action_callback_id": AddToResolutionNoteStep.callback_id, "step": AddToResolutionNoteStep, }, diff --git a/engine/apps/slack/scenarios/scenario_step.py b/engine/apps/slack/scenarios/scenario_step.py index cdeac0e7..02882807 100644 --- a/engine/apps/slack/scenarios/scenario_step.py +++ b/engine/apps/slack/scenarios/scenario_step.py @@ -1,63 +1,25 @@ import importlib import logging +import typing from apps.slack.alert_group_slack_service import AlertGroupSlackService from apps.slack.slack_client import SlackClientWithErrorHandling +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.slack.types import EventPayload + from apps.user_management.models import Organization, User + logger = logging.getLogger(__name__) -PAYLOAD_TYPE_INTERACTIVE_MESSAGE = "interactive_message" -ACTION_TYPE_BUTTON = "button" -ACTION_TYPE_SELECT = "select" - -PAYLOAD_TYPE_SLASH_COMMAND = "slash_command" - -PAYLOAD_TYPE_EVENT_CALLBACK = "event_callback" -EVENT_TYPE_MESSAGE = "message" -EVENT_TYPE_MESSAGE_CHANNEL = "channel" -EVENT_TYPE_MESSAGE_IM = "im" -# Slack event "user_change" is deprecated in favor of "user_profile_changed". -# Handler for "user_change" is kept for backward compatibility. -EVENT_TYPE_USER_CHANGE = "user_change" -EVENT_TYPE_USER_PROFILE_CHANGED = "user_profile_changed" -EVENT_TYPE_APP_MENTION = "app_mention" -EVENT_TYPE_MEMBER_JOINED_CHANNEL = "member_joined_channel" -EVENT_TYPE_IM_OPEN = "im_open" -EVENT_TYPE_APP_HOME_OPENED = "app_home_opened" -EVENT_TYPE_SUBTEAM_CREATED = "subteam_created" -EVENT_TYPE_SUBTEAM_UPDATED = "subteam_updated" -EVENT_TYPE_SUBTEAM_MEMBERS_CHANGED = "subteam_members_changed" -EVENT_SUBTYPE_MESSAGE_CHANGED = "message_changed" -EVENT_SUBTYPE_MESSAGE_DELETED = "message_deleted" -EVENT_SUBTYPE_BOT_MESSAGE = "bot_message" -EVENT_SUBTYPE_THREAD_BROADCAST = "thread_broadcast" -EVENT_TYPE_CHANNEL_DELETED = "channel_deleted" -EVENT_TYPE_CHANNEL_CREATED = "channel_created" -EVENT_TYPE_CHANNEL_RENAMED = "channel_rename" -EVENT_TYPE_CHANNEL_ARCHIVED = "channel_archive" -EVENT_TYPE_CHANNEL_UNARCHIVED = "channel_unarchive" - -PAYLOAD_TYPE_BLOCK_ACTIONS = "block_actions" -BLOCK_ACTION_TYPE_USERS_SELECT = "users_select" -BLOCK_ACTION_TYPE_BUTTON = "button" -BLOCK_ACTION_TYPE_STATIC_SELECT = "static_select" -BLOCK_ACTION_TYPE_CONVERSATIONS_SELECT = "conversations_select" -BLOCK_ACTION_TYPE_CHANNELS_SELECT = "channels_select" -BLOCK_ACTION_TYPE_OVERFLOW = "overflow" -BLOCK_ACTION_TYPE_DATEPICKER = "datepicker" -BLOCK_ACTION_TYPE_CHECKBOXES = "checkboxes" - -PAYLOAD_TYPE_DIALOG_SUBMISSION = "dialog_submission" -PAYLOAD_TYPE_VIEW_SUBMISSION = "view_submission" - -PAYLOAD_TYPE_MESSAGE_ACTION = "message_action" - -THREAD_MESSAGE_SUBTYPE = "bot_message" - - class ScenarioStep(object): - def __init__(self, slack_team_identity, organization=None, user=None): + def __init__( + self, + slack_team_identity: "SlackTeamIdentity", + organization: typing.Optional["Organization"] = None, + user: typing.Optional["User"] = None, + ): self._slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token) self.slack_team_identity = slack_team_identity self.organization = organization @@ -65,15 +27,20 @@ class ScenarioStep(object): self.alert_group_slack_service = AlertGroupSlackService(slack_team_identity, self._slack_client) - def process_scenario(self, user, team, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: "EventPayload", + ) -> None: pass @classmethod - def routing_uid(cls): + def routing_uid(cls) -> str: return cls.__name__ @classmethod - def get_step(cls, scenario, step): + def get_step(cls, scenario: str, step: str) -> "ScenarioStep": """ This is a dynamic Step loader to avoid circular dependencies in scenario files """ @@ -86,7 +53,7 @@ class ScenarioStep(object): except ImportError as e: raise Exception("Check import spelling! Scenario: {}, Step:{}, Error: {}".format(scenario, step, e)) - def open_warning_window(self, payload, warning_text, title=None): + def open_warning_window(self, payload: "EventPayload", warning_text: str, title: str | None = None) -> None: if title is None: title = ":warning: Warning" view = { diff --git a/engine/apps/slack/scenarios/schedules.py b/engine/apps/slack/scenarios/schedules.py index 1c4d47a5..15587a06 100644 --- a/engine/apps/slack/scenarios/schedules.py +++ b/engine/apps/slack/scenarios/schedules.py @@ -1,13 +1,25 @@ -import datetime import json +import typing import pytz from apps.schedules.models import OnCallSchedule from apps.slack.scenarios import scenario_step +from apps.slack.types import ( + Block, + BlockActionType, + CompositionObjects, + EventPayload, + ModalView, + PayloadType, + ScenarioRoute, +) from apps.slack.utils import format_datetime_to_slack from common.insight_log import EntityEvent, write_resource_insight_log +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): notify_empty_oncall_options = {choice[0]: choice[1] for choice in OnCallSchedule.NotifyEmptyOnCall.choices} @@ -15,14 +27,19 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): mention_oncall_start_options = {1: "Mention person in slack", 0: "Inform in channel without mention"} mention_oncall_next_options = {1: "Mention person in slack", 0: "Inform in channel without mention"} - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: if payload["actions"][0].get("value", None) and payload["actions"][0]["value"].startswith("edit"): self.open_settings_modal(payload) elif payload["actions"][0].get("type", None) and payload["actions"][0]["type"] == "static_select": self.set_selected_value(slack_user_identity, payload) - def open_settings_modal(self, payload, schedule_id=None): - schedule_id = payload["actions"][0]["value"].split("_")[1] if schedule_id is None else schedule_id + def open_settings_modal(self, payload: EventPayload.Any) -> None: + schedule_id = payload["actions"][0]["value"].split("_")[1] try: _ = OnCallSchedule.objects.get(pk=schedule_id) # noqa except OnCallSchedule.DoesNotExist: @@ -33,7 +50,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): private_metadata = {} private_metadata["schedule_id"] = schedule_id - view = { + view: ModalView = { "callback_id": EditScheduleShiftNotifyStep.routing_uid(), "blocks": blocks, "type": "modal", @@ -50,7 +67,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): view=view, ) - def set_selected_value(self, slack_user_identity, payload): + def set_selected_value(self, slack_user_identity: "SlackUserIdentity", payload: EventPayload.Any) -> None: action = payload["actions"][0] private_metadata = json.loads(payload["view"]["private_metadata"]) schedule_id = private_metadata["schedule_id"] @@ -67,8 +84,8 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): new_state=new_state, ) - def get_modal_blocks(self, schedule_id): - blocks = [ + def get_modal_blocks(self, schedule_id: str) -> typing.List[Block.Section]: + blocks: typing.List[Block.Section] = [ { "type": "section", "text": {"type": "plain_text", "text": "Notification frequency"}, @@ -121,20 +138,20 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): return blocks - def get_options(self, select_name): + def get_options(self, select_name: str) -> typing.List[CompositionObjects.Option]: select_options = getattr(self, f"{select_name}_options") return [ {"text": {"type": "plain_text", "text": select_options[option]}, "value": str(option)} for option in select_options ] - def get_initial_option(self, schedule_id, select_name): + def get_initial_option(self, schedule_id: str, select_name: str) -> CompositionObjects.Option: schedule = OnCallSchedule.objects.get(pk=schedule_id) current_value = getattr(schedule, select_name) text = getattr(self, f"{select_name}_options")[current_value] - initial_option = { + initial_option: CompositionObjects.Option = { "text": { "type": "plain_text", "text": f"{text}", @@ -145,13 +162,13 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): return initial_option @classmethod - def get_report_blocks_ical(cls, new_shifts, next_shifts, schedule, empty): + def get_report_blocks_ical(cls, new_shifts, next_shifts, schedule: OnCallSchedule, empty: bool) -> Block.AnyBlocks: organization = schedule.organization if empty: if schedule.notify_empty_oncall == schedule.NotifyEmptyOnCall.ALL: now_text = "Inviting . No one on-call now!\n" elif schedule.notify_empty_oncall == schedule.NotifyEmptyOnCall.PREV: - user_ids = [] + user_ids: typing.List[str] = [] for item in json.loads(schedule.current_shifts).values(): user_ids.extend(item.get("users", [])) prev_users = organization.users.filter(id__in=user_ids) @@ -182,106 +199,49 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): next_text = "\n*Next on-call shift:*\n" + next_text text = f"{now_text}{next_text}" - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": text, - "verbatim": True, + blocks: Block.AnyBlocks = [ + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": text, + "verbatim": True, + }, }, - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "action_id": f"{cls.routing_uid()}", - "text": {"type": "plain_text", "text": ":gear:", "emoji": True}, - "value": f"edit_{schedule.pk}", - } - ], - }, - {"type": "context", "elements": [{"type": "mrkdwn", "text": f"On-call schedule *{schedule.name}*"}]}, + ), + typing.cast( + Block.Actions, + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": f"{cls.routing_uid()}", + "text": {"type": "plain_text", "text": ":gear:", "emoji": True}, + "value": f"edit_{schedule.pk}", + }, + ], + }, + ), + typing.cast( + Block.Context, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"On-call schedule *{schedule.name}*", + }, + ], + }, + ), ] return blocks @classmethod - def get_report_blocks_manual(cls, current_shift, next_shift, schedule): - current_piece, current_user = current_shift - - start_day = datetime.datetime.now() - current_hour = datetime.datetime.today().hour - start_hour = current_piece.starts_at.hour - if start_hour > current_hour: - start_day -= datetime.timedelta(days=1) - - shift_start = start_day.replace(hour=start_hour, minute=0, second=0, microsecond=0) - shift_end = shift_start + datetime.timedelta(hours=12) - shift_start_timestamp = int(shift_start.astimezone(pytz.UTC).timestamp()) - shift_end_timestamp = int(shift_end.astimezone(pytz.UTC).timestamp()) - - next_shift_end = shift_end + datetime.timedelta(hours=12) - next_shift_end_timestamp = int(next_shift_end.astimezone(pytz.UTC).timestamp()) - - now_text = "_*Now*_:\n" - if schedule.mention_oncall_start: - user_mention = current_user.get_username_with_slack_verbal( - mention=True, - ) - - else: - user_mention = current_user.get_username_with_slack_verbal( - mention=False, - ) - now_text += f"*{user_mention}*" - - now_text += f" from {format_datetime_to_slack(shift_start_timestamp)}" - now_text += f" to {format_datetime_to_slack(shift_end_timestamp)}" - - next_piece, next_user = next_shift - next_text = "\n_*Next*_:\n" - if schedule.mention_oncall_next: - user_mention = next_user.get_username_with_slack_verbal( - mention=True, - ) - else: - user_mention = next_user.get_username_with_slack_verbal( - mention=False, - ) - next_text += f"*{user_mention}*" - - next_text += f" from {format_datetime_to_slack(shift_end_timestamp)}" - next_text += f" to {format_datetime_to_slack(next_shift_end_timestamp)}" - - text = f"{now_text}{next_text}" - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": text, - "verbatim": True, - }, - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "action_id": f"{cls.routing_uid()}", - "text": {"type": "plain_text", "text": ":gear:", "emoji": True}, - "value": f"edit_{schedule.pk}", - } - ], - }, - {"type": "context", "elements": [{"type": "mrkdwn", "text": f"On-call schedule *{schedule.name}*"}]}, - ] - - return blocks - - @classmethod - def get_ical_shift_notification_text(cls, shift, mention, users): + def get_ical_shift_notification_text(cls, shift, mention, users) -> str: if shift["all_day"]: notification = " ".join([f"{user.get_username_with_slack_verbal(mention=mention)}" for user in users]) user_verbal = shift["users"][0].get_username_with_slack_verbal( @@ -309,16 +269,16 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): return notification -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, "block_action_id": EditScheduleShiftNotifyStep.routing_uid(), "step": EditScheduleShiftNotifyStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.STATIC_SELECT, "block_action_id": EditScheduleShiftNotifyStep.routing_uid(), "step": EditScheduleShiftNotifyStep, }, diff --git a/engine/apps/slack/scenarios/shift_swap_requests.py b/engine/apps/slack/scenarios/shift_swap_requests.py new file mode 100644 index 00000000..1a54e07d --- /dev/null +++ b/engine/apps/slack/scenarios/shift_swap_requests.py @@ -0,0 +1,201 @@ +import json +import logging +import typing + +from apps.slack.constants import DIVIDER +from apps.slack.models import SlackMessage +from apps.slack.scenarios import scenario_step +from apps.slack.types import Block, BlockActionType, EventPayload, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.schedules.models import ShiftSwapRequest + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +SHIFT_SWAP_PK_ACTION_KEY = "shift_swap_request_pk" + + +class BaseShiftSwapRequestStep(scenario_step.ScenarioStep): + def _generate_blocks(self, shift_swap_request: "ShiftSwapRequest") -> Block.AnyBlocks: + pk = shift_swap_request.pk + + # TODO: come up with a better layout for this.. + main_message_text = f"Your teammate {shift_swap_request.beneficiary.get_username_with_slack_verbal()} has submitted a shift swap request." + + blocks: Block.AnyBlocks = [ + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": main_message_text, + }, + }, + ), + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + # TODO: I believe it'll be easier to wait to generate this until we have the schedule override changes in place + # NOTE: use apps.slack.utils.format_datetime_to_slack method to format the datetimes + "text": "*📅 Shift Details*: 9h00 - 17h00 (UTC) daily from Monday July 24, 2023 - July 28, 2023", + }, + }, + ), + ] + + if description := shift_swap_request.description: + blocks.append( + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*📝 Description*: {description}", + }, + }, + ) + ) + + if shift_swap_request.is_deleted: + blocks.append( + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Update*: this shift swap request has been deleted.", + }, + }, + ), + ) + elif shift_swap_request.is_taken: + blocks.append( + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Update*: {shift_swap_request.benefactor.get_username_with_slack_verbal()} has taken the shift swap.", + }, + }, + ), + ) + else: + value = { + SHIFT_SWAP_PK_ACTION_KEY: pk, + "organization_id": shift_swap_request.organization.pk, + } + + blocks.append( + typing.cast( + Block.Actions, + { + "type": "actions", + "elements": [ + { + "type": "button", + "style": "primary", + "text": { + "type": "plain_text", + "text": "✔️ Accept Shift Swap Request", + "emoji": True, + }, + "value": json.dumps(value), + "action_id": AcceptShiftSwapRequestStep.routing_uid(), + }, + ], + }, + ) + ) + + blocks.extend( + [ + DIVIDER, + typing.cast( + Block.Context, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"👀 View the shift swap within Grafana OnCall by clicking <{shift_swap_request.web_link}|here>.", + }, + ], + }, + ), + ] + ) + + return blocks + + def create_message(self, shift_swap_request: "ShiftSwapRequest") -> SlackMessage: + channel_id = shift_swap_request.slack_channel_id + organization = self.organization + + blocks = self._generate_blocks(shift_swap_request) + result = self._slack_client.api_call("chat.postMessage", channel=channel_id, blocks=blocks) + + return SlackMessage.objects.create( + slack_id=result["ts"], + organization=organization, + _slack_team_identity=self.slack_team_identity, + channel_id=channel_id, + ) + + def update_message(self, shift_swap_request: "ShiftSwapRequest") -> None: + # TODO: better error handling here... + self._slack_client.api_call( + "chat.update", + channel=shift_swap_request.slack_channel_id, + ts=shift_swap_request.slack_message.slack_id, + blocks=self._generate_blocks(shift_swap_request), + ) + + +class AcceptShiftSwapRequestStep(BaseShiftSwapRequestStep): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: + from apps.schedules import exceptions + from apps.schedules.models import ShiftSwapRequest + + shift_swap_request_pk = json.loads(payload["actions"][0]["value"])[SHIFT_SWAP_PK_ACTION_KEY] + + try: + shift_swap_request = ShiftSwapRequest.objects.get(pk=shift_swap_request_pk) + except ShiftSwapRequest.DoesNotExist: + logger.info(f"skipping AcceptShiftSwapRequestStep as swap request {shift_swap_request_pk} does not exist") + return + + try: + shift_swap_request.take(self.user) + except exceptions.BeneficiaryCannotTakeOwnShiftSwapRequest: + self.open_warning_window(payload, "A shift swap request cannot be created and taken by the same user") + return + except exceptions.ShiftSwapRequestNotOpenForTaking: + self.open_warning_window(payload, "The shift swap request is not in a state which allows it to be taken") + return + + self.update_message(shift_swap_request) + + +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ + { + "payload_type": PayloadType.BLOCK_ACTIONS, + "block_action_type": BlockActionType.BUTTON, + "block_action_id": AcceptShiftSwapRequestStep.routing_uid(), + "step": AcceptShiftSwapRequestStep, + }, +] diff --git a/engine/apps/slack/scenarios/slack_channel.py b/engine/apps/slack/scenarios/slack_channel.py index a65101d2..cd1e01bf 100644 --- a/engine/apps/slack/scenarios/slack_channel.py +++ b/engine/apps/slack/scenarios/slack_channel.py @@ -1,13 +1,23 @@ +import typing from contextlib import suppress from django.utils import timezone from apps.slack.scenarios import scenario_step from apps.slack.tasks import clean_slack_channel_leftovers +from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity class SlackChannelCreatedOrRenamedEventStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Triggered by action: Create or rename channel """ @@ -27,7 +37,12 @@ class SlackChannelCreatedOrRenamedEventStep(scenario_step.ScenarioStep): class SlackChannelDeletedEventStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Triggered by action: Delete channel """ @@ -44,7 +59,12 @@ class SlackChannelDeletedEventStep(scenario_step.ScenarioStep): class SlackChannelArchivedEventStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Triggered by action: Archive channel """ @@ -60,7 +80,12 @@ class SlackChannelArchivedEventStep(scenario_step.ScenarioStep): class SlackChannelUnArchivedEventStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Triggered by action: UnArchive channel """ @@ -74,30 +99,30 @@ class SlackChannelUnArchivedEventStep(scenario_step.ScenarioStep): ).update(is_archived=False) -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_CHANNEL_RENAMED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.CHANNEL_RENAMED, "step": SlackChannelCreatedOrRenamedEventStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_CHANNEL_CREATED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.CHANNEL_CREATED, "step": SlackChannelCreatedOrRenamedEventStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_CHANNEL_DELETED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.CHANNEL_DELETED, "step": SlackChannelDeletedEventStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_CHANNEL_ARCHIVED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.CHANNEL_ARCHIVED, "step": SlackChannelArchivedEventStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_CHANNEL_UNARCHIVED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.CHANNEL_UNARCHIVED, "step": SlackChannelUnArchivedEventStep, }, ] diff --git a/engine/apps/slack/scenarios/slack_channel_integration.py b/engine/apps/slack/scenarios/slack_channel_integration.py index 133f904f..d89bbb28 100644 --- a/engine/apps/slack/scenarios/slack_channel_integration.py +++ b/engine/apps/slack/scenarios/slack_channel_integration.py @@ -1,13 +1,23 @@ import logging +import typing from apps.slack.scenarios import scenario_step +from apps.slack.types import EventPayload, EventType, MessageEventSubtype, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) class SlackChannelMessageEventStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Triggered by action: Any new message in channel. Dangerous because it's often triggered by internal client's company systems. @@ -16,18 +26,20 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep): # If it is a message from thread - save it for resolution note if ("thread_ts" in payload["event"] and "subtype" not in payload["event"]) or ( - payload["event"].get("subtype") == scenario_step.EVENT_SUBTYPE_MESSAGE_CHANGED + payload["event"].get("subtype") == MessageEventSubtype.MESSAGE_CHANGED and "subtype" not in payload["event"]["message"] and "thread_ts" in payload["event"]["message"] ): self.save_thread_message_for_resolution_note(slack_user_identity, payload) elif ( - payload["event"].get("subtype") == scenario_step.EVENT_SUBTYPE_MESSAGE_DELETED + payload["event"].get("subtype") == MessageEventSubtype.MESSAGE_DELETED and "thread_ts" in payload["event"]["previous_message"] ): self.delete_thread_message_from_resolution_note(slack_user_identity, payload) - def save_thread_message_for_resolution_note(self, slack_user_identity, payload): + def save_thread_message_for_resolution_note( + self, slack_user_identity: "SlackUserIdentity", payload: EventPayload.Any + ) -> None: from apps.alerts.models import ResolutionNoteSlackMessage from apps.slack.models import SlackMessage @@ -78,27 +90,28 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep): ) if len(text) > 2900: if slack_thread_message.added_to_resolution_note: - return self._slack_client.api_call( + self._slack_client.api_call( "chat.postEphemeral", channel=channel, user=slack_user_identity.slack_id, text=":warning: Unable to update the <{}|message> in Resolution Note: the message is too long ({}). " "Max length - 2900 symbols.".format(permalink, len(text)), ) - else: - return + return slack_thread_message.text = text slack_thread_message.save() except ResolutionNoteSlackMessage.DoesNotExist: if len(text) > 2900: - return self._slack_client.api_call( + self._slack_client.api_call( "chat.postEphemeral", channel=channel, user=slack_user_identity.slack_id, text=":warning: The <{}|message> will not be displayed in Resolution Note: " "the message is too long ({}). Max length - 2900 symbols.".format(permalink, len(text)), ) + return + slack_thread_message = ResolutionNoteSlackMessage( alert_group=alert_group, user=self.user, @@ -111,7 +124,9 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep): ) slack_thread_message.save() - def delete_thread_message_from_resolution_note(self, slack_user_identity, payload): + def delete_thread_message_from_resolution_note( + self, slack_user_identity: "SlackUserIdentity", payload: EventPayload.Any + ) -> None: from apps.alerts.models import ResolutionNoteSlackMessage if slack_user_identity is None: @@ -138,11 +153,14 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep): self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -STEPS_ROUTING = [ - { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_MESSAGE, - "message_channel_type": scenario_step.EVENT_TYPE_MESSAGE_CHANNEL, - "step": SlackChannelMessageEventStep, - } +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ + typing.cast( + ScenarioRoute.EventCallbackChannelMessageScenarioRoute, + { + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.MESSAGE, + "message_channel_type": EventType.MESSAGE_CHANNEL, + "step": SlackChannelMessageEventStep, + }, + ), ] diff --git a/engine/apps/slack/scenarios/slack_renderer.py b/engine/apps/slack/scenarios/slack_renderer.py index dc55e82a..853f7372 100644 --- a/engine/apps/slack/scenarios/slack_renderer.py +++ b/engine/apps/slack/scenarios/slack_renderer.py @@ -1,11 +1,16 @@ +import typing + import humanize from apps.alerts.incident_log_builder import IncidentLogBuilder +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroup + class AlertGroupLogSlackRenderer: @staticmethod - def render_incident_log_report_for_slack(alert_group): + def render_incident_log_report_for_slack(alert_group: "AlertGroup"): from apps.alerts.models import AlertGroupLogRecord from apps.base.models import UserNotificationPolicyLogRecord diff --git a/engine/apps/slack/scenarios/slack_usergroup.py b/engine/apps/slack/scenarios/slack_usergroup.py index d71d0d2f..79be82c5 100644 --- a/engine/apps/slack/scenarios/slack_usergroup.py +++ b/engine/apps/slack/scenarios/slack_usergroup.py @@ -1,10 +1,21 @@ +import typing + from django.utils import timezone from apps.slack.scenarios import scenario_step +from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute + +if typing.TYPE_CHECKING: + from apps.slack.models import SlackTeamIdentity, SlackUserIdentity class SlackUserGroupEventStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Triggered by action: creation user groups or changes in user groups except its members. """ @@ -30,7 +41,12 @@ class SlackUserGroupEventStep(scenario_step.ScenarioStep): class SlackUserGroupMembersChangedEventStep(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): + def process_scenario( + self, + slack_user_identity: "SlackUserIdentity", + slack_team_identity: "SlackTeamIdentity", + payload: EventPayload.Any, + ) -> None: """ Triggered by action: changed members in user group. """ @@ -54,20 +70,20 @@ class SlackUserGroupMembersChangedEventStep(scenario_step.ScenarioStep): user_group.save(update_fields=["members"]) -STEPS_ROUTING = [ +STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_SUBTEAM_CREATED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.SUBTEAM_CREATED, "step": SlackUserGroupEventStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_SUBTEAM_UPDATED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.SUBTEAM_UPDATED, "step": SlackUserGroupEventStep, }, { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_SUBTEAM_MEMBERS_CHANGED, + "payload_type": PayloadType.EVENT_CALLBACK, + "event_type": EventType.SUBTEAM_MEMBERS_CHANGED, "step": SlackUserGroupMembersChangedEventStep, }, ] diff --git a/engine/apps/slack/scenarios/step_mixins.py b/engine/apps/slack/scenarios/step_mixins.py index 1ca7eadd..85c8aa9b 100644 --- a/engine/apps/slack/scenarios/step_mixins.py +++ b/engine/apps/slack/scenarios/step_mixins.py @@ -4,6 +4,7 @@ import logging from apps.alerts.models import AlertGroup from apps.api.permissions import user_is_authorized from apps.slack.models import SlackMessage, SlackTeamIdentity +from apps.slack.types import EventPayload from apps.user_management.models import User logger = logging.getLogger(__name__) @@ -18,7 +19,7 @@ class AlertGroupActionsMixin: REQUIRED_PERMISSIONS = [] - def get_alert_group(self, slack_team_identity: SlackTeamIdentity, payload: dict) -> AlertGroup: + def get_alert_group(self, slack_team_identity: SlackTeamIdentity, payload: EventPayload.Any) -> AlertGroup: """ Get AlertGroup instance on Slack message button click or select menu change. """ @@ -46,7 +47,7 @@ class AlertGroupActionsMixin: and user_is_authorized(self.user, self.REQUIRED_PERMISSIONS) ) - def open_unauthorized_warning(self, payload: dict) -> None: + def open_unauthorized_warning(self, payload: EventPayload.Any) -> None: self.open_warning_window( payload, warning_text="You do not have permission to perform this action. Ask an admin to upgrade your permissions.", @@ -54,7 +55,7 @@ class AlertGroupActionsMixin: ) def _repair_alert_group( - self, slack_team_identity: SlackTeamIdentity, alert_group: AlertGroup, payload: dict + self, slack_team_identity: SlackTeamIdentity, alert_group: AlertGroup, payload: EventPayload.Any ) -> None: """ There's a possibility that OnCall failed to create a SlackMessage instance for an AlertGroup, but the message @@ -78,7 +79,7 @@ class AlertGroupActionsMixin: alert_group.slack_message = slack_message alert_group.save(update_fields=["slack_message"]) - def _get_alert_group_from_action(self, payload: dict) -> AlertGroup | None: + def _get_alert_group_from_action(self, payload: EventPayload.Any) -> AlertGroup | None: """ Get AlertGroup instance from action data in payload. Action data is data encoded into buttons and select menus in apps.alerts.incident_appearance.renderers.slack_renderer.AlertGroupSlackRenderer._get_buttons_blocks. @@ -106,7 +107,7 @@ class AlertGroupActionsMixin: return AlertGroup.objects.get(pk=alert_group_pk) - def _get_alert_group_from_message(self, payload: dict) -> AlertGroup | None: + def _get_alert_group_from_message(self, payload: EventPayload.Any) -> AlertGroup | None: """ Get AlertGroup instance from message data in payload. It's similar to _get_alert_group_from_action, but it tries to get alert_group_pk from ANY button in the message, not just the one that was clicked. @@ -138,7 +139,7 @@ class AlertGroupActionsMixin: return None def _get_alert_group_from_slack_message_in_db( - self, slack_team_identity: SlackTeamIdentity, payload: dict + self, slack_team_identity: SlackTeamIdentity, payload: EventPayload.Any ) -> AlertGroup: """ Get AlertGroup instance from SlackMessage instance. diff --git a/engine/apps/slack/slack_formatter.py b/engine/apps/slack/slack_formatter.py index 0ec86719..ec9cd8d7 100644 --- a/engine/apps/slack/slack_formatter.py +++ b/engine/apps/slack/slack_formatter.py @@ -13,9 +13,6 @@ class SlackFormatter(SlackFormatterBase): self.user_mention_format = "@{}" self.hyperlink_mention_format = '{title}' - def find_user(self, message): - raise NotImplementedError() - def format(self, message): """ Overriden original render_text method. diff --git a/engine/apps/slack/templates/admin/slack_teams_summary_change_list.html b/engine/apps/slack/templates/admin/slack_teams_summary_change_list.html deleted file mode 100644 index f9691f8f..00000000 --- a/engine/apps/slack/templates/admin/slack_teams_summary_change_list.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends "admin/change_list.html" %} -{% block content_title %} -

Slack Team Summary

-{% endblock %} -{% block result_list %} -
- -

Daily Active Teams:

-
-
- {% for x in summary_over_time %} -
-
- {{x.total | default:0 }}
- {{x.period | date:"d/m/Y"}} -
-
- {% endfor %} -
-
-
-

Registered Teams:

-
-
- {% for x in registered_teams %} -
-
- {{x.total | default:0 }}
- {{x.period | date:"d/m/Y"}} -
-
- {% endfor %} -
-
- -
-{% endblock %} -{% block pagination %}{% endblock %} \ No newline at end of file diff --git a/engine/apps/slack/tests/test_create_message_blocks.py b/engine/apps/slack/tests/test_create_message_blocks.py deleted file mode 100644 index 638c99da..00000000 --- a/engine/apps/slack/tests/test_create_message_blocks.py +++ /dev/null @@ -1,56 +0,0 @@ -from apps.slack.utils import create_message_blocks - - -def test_long_text(): - original_text = "1" * 3000 + "\n" + "2" * 3000 + "\n" + "3" * 3000 - - message_block_dict = [ - { - "type": "section", - "text": {"type": "mrkdwn", "text": "1" * 3000 + "```"}, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "```" + "2" * 3000 + "```", - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "```" + "3" * 3000 + "```", - }, - }, - ] - assert message_block_dict == create_message_blocks(original_text) - - -def test_truncation_long_text(): - original_text = "t" * 3000 + "\n" + "truncated" - - expected_message_blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "t" * 3000 + "```", - }, - }, - { - "type": "section", - "text": {"type": "mrkdwn", "text": "```truncated```"}, - }, - ] - message_blocks = create_message_blocks(original_text) - assert expected_message_blocks == message_blocks - - -def test_short_text(): - """Any short text test case""" - - original_text = "test" * 100 - - message_block_dict = [{"type": "section", "text": {"type": "mrkdwn", "text": original_text}}] - assert message_block_dict == create_message_blocks(original_text) diff --git a/engine/apps/slack/tests/test_interactive_api_endpoint.py b/engine/apps/slack/tests/test_interactive_api_endpoint.py index 3d18dd85..74bd473e 100644 --- a/engine/apps/slack/tests/test_interactive_api_endpoint.py +++ b/engine/apps/slack/tests/test_interactive_api_endpoint.py @@ -8,7 +8,7 @@ from rest_framework.test import APIClient from apps.slack.scenarios.manage_responders import ManageRespondersUserChange from apps.slack.scenarios.paging import OnPagingTeamChange -from apps.slack.scenarios.scenario_step import PAYLOAD_TYPE_BLOCK_ACTIONS +from apps.slack.types import PayloadType EVENT_TRIGGER_ID = "5333959822612.4122782784722.4734ff484b2ac4d36a185bb242ee9932" WARNING_TEXT = ( @@ -74,7 +74,7 @@ def test_organization_not_found_scenario_properly_handled( ] event_payload = { - "type": PAYLOAD_TYPE_BLOCK_ACTIONS, + "type": PayloadType.BLOCK_ACTIONS, "trigger_id": EVENT_TRIGGER_ID, "user": { "id": SLACK_USER_ID, diff --git a/engine/apps/slack/tests/test_scenario_steps/test_paging.py b/engine/apps/slack/tests/test_scenario_steps/test_paging.py index 6aa0d2fe..6e39e90b 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_paging.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_paging.py @@ -7,7 +7,6 @@ from django.utils import timezone from apps.base.models import UserNotificationPolicy from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb from apps.slack.scenarios.paging import ( - DEFAULT_POLICY, DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID, DIRECT_PAGING_MESSAGE_INPUT_ID, DIRECT_PAGING_ORG_SELECT_ID, @@ -15,10 +14,7 @@ from apps.slack.scenarios.paging import ( DIRECT_PAGING_TEAM_SELECT_ID, DIRECT_PAGING_TITLE_INPUT_ID, DIRECT_PAGING_USER_SELECT_ID, - IMPORTANT_POLICY, - REMOVE_ACTION, - SCHEDULES_DATA_KEY, - USERS_DATA_KEY, + DataKey, FinishDirectPaging, OnPagingCheckAdditionalResponders, OnPagingItemActionChange, @@ -26,6 +22,7 @@ from apps.slack.scenarios.paging import ( OnPagingScheduleChange, OnPagingTeamChange, OnPagingUserChange, + Policy, StartDirectPaging, ) @@ -50,8 +47,8 @@ def make_slack_payload( "input_id_prefix": "", "channel_id": "123", "submit_routing_uid": "FinishStepUID", - USERS_DATA_KEY: current_users or {}, - SCHEDULES_DATA_KEY: current_schedules or {}, + DataKey.USERS: current_users or {}, + DataKey.SCHEDULES: current_schedules or {}, } ), "state": { @@ -99,8 +96,8 @@ def test_initial_state( assert mock_slack_api_call.call_args.args == ("views.open",) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[USERS_DATA_KEY] == {} - assert metadata[SCHEDULES_DATA_KEY] == {} + assert metadata[DataKey.USERS] == {} + assert metadata[DataKey.SCHEDULES] == {} @pytest.mark.django_db @@ -144,7 +141,7 @@ def test_add_user_no_warning( assert mock_slack_api_call.call_args.args == ("views.update",) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[USERS_DATA_KEY] == {str(user.pk): DEFAULT_POLICY} + assert metadata[DataKey.USERS] == {str(user.pk): Policy.DEFAULT} @pytest.mark.django_db @@ -221,7 +218,7 @@ def test_add_user_raise_warning(make_organization_and_user_with_slack_identities ) assert f"*{user.username}* is not on-call" in text_from_blocks metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[USERS_DATA_KEY] == {} + assert metadata[DataKey.USERS] == {} @pytest.mark.django_db @@ -229,7 +226,7 @@ def test_change_user_policy(make_organization_and_user_with_slack_identities): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() payload = make_slack_payload( organization=organization, - actions=[{"selected_option": {"value": f"{IMPORTANT_POLICY}|{USERS_DATA_KEY}|{user.pk}"}}], + actions=[{"selected_option": {"value": f"{Policy.IMPORTANT}|{DataKey.USERS}|{user.pk}"}}], ) step = OnPagingItemActionChange(slack_team_identity) @@ -238,7 +235,7 @@ def test_change_user_policy(make_organization_and_user_with_slack_identities): assert mock_slack_api_call.call_args.args == ("views.update",) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[USERS_DATA_KEY] == {str(user.pk): IMPORTANT_POLICY} + assert metadata[DataKey.USERS] == {str(user.pk): Policy.IMPORTANT} @pytest.mark.django_db @@ -246,7 +243,7 @@ def test_remove_user(make_organization_and_user_with_slack_identities): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() payload = make_slack_payload( organization=organization, - actions=[{"selected_option": {"value": f"{REMOVE_ACTION}|{USERS_DATA_KEY}|{user.pk}"}}], + actions=[{"selected_option": {"value": f"{Policy.REMOVE_ACTION}|{DataKey.USERS}|{user.pk}"}}], ) step = OnPagingItemActionChange(slack_team_identity) @@ -255,7 +252,7 @@ def test_remove_user(make_organization_and_user_with_slack_identities): assert mock_slack_api_call.call_args.args == ("views.update",) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[USERS_DATA_KEY] == {} + assert metadata[DataKey.USERS] == {} @pytest.mark.django_db @@ -300,8 +297,8 @@ def test_trigger_paging_additional_responders( organization=organization, team=team, additional_responders=True, - current_users={str(user.pk): IMPORTANT_POLICY}, - current_schedules={str(schedule.pk): DEFAULT_POLICY}, + current_users={str(user.pk): Policy.IMPORTANT}, + current_schedules={str(schedule.pk): Policy.DEFAULT}, ) step = FinishDirectPaging(slack_team_identity) @@ -321,7 +318,7 @@ def test_add_schedule(make_organization_and_user_with_slack_identities, make_sch payload = make_slack_payload( organization=organization, schedule=schedule, - current_users={str(user.pk): IMPORTANT_POLICY}, + current_users={str(user.pk): Policy.IMPORTANT}, ) step = OnPagingScheduleChange(slack_team_identity) @@ -330,8 +327,8 @@ def test_add_schedule(make_organization_and_user_with_slack_identities, make_sch assert mock_slack_api_call.call_args.args == ("views.update",) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[SCHEDULES_DATA_KEY] == {str(schedule.pk): DEFAULT_POLICY} - assert metadata[USERS_DATA_KEY] == {str(user.pk): IMPORTANT_POLICY} + assert metadata[DataKey.SCHEDULES] == {str(schedule.pk): Policy.DEFAULT} + assert metadata[DataKey.USERS] == {str(user.pk): Policy.IMPORTANT} @pytest.mark.django_db @@ -341,7 +338,7 @@ def test_add_schedule_responders_exceeded(make_organization_and_user_with_slack_ payload = make_slack_payload( organization=organization, schedule=schedule, - current_users={str(user.pk): IMPORTANT_POLICY}, + current_users={str(user.pk): Policy.IMPORTANT}, ) step = OnPagingScheduleChange(slack_team_identity) @@ -372,8 +369,8 @@ def test_change_schedule_policy(make_organization_and_user_with_slack_identities schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) payload = make_slack_payload( organization=organization, - current_users={str(user.pk): DEFAULT_POLICY}, - actions=[{"selected_option": {"value": f"{IMPORTANT_POLICY}|{SCHEDULES_DATA_KEY}|{schedule.pk}"}}], + current_users={str(user.pk): Policy.DEFAULT}, + actions=[{"selected_option": {"value": f"{Policy.IMPORTANT}|{DataKey.SCHEDULES}|{schedule.pk}"}}], ) step = OnPagingItemActionChange(slack_team_identity) @@ -382,8 +379,8 @@ def test_change_schedule_policy(make_organization_and_user_with_slack_identities assert mock_slack_api_call.call_args.args == ("views.update",) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[SCHEDULES_DATA_KEY] == {str(schedule.pk): IMPORTANT_POLICY} - assert metadata[USERS_DATA_KEY] == {str(user.pk): DEFAULT_POLICY} + assert metadata[DataKey.SCHEDULES] == {str(schedule.pk): Policy.IMPORTANT} + assert metadata[DataKey.USERS] == {str(user.pk): Policy.DEFAULT} @pytest.mark.django_db @@ -392,8 +389,8 @@ def test_remove_schedule(make_organization_and_user_with_slack_identities, make_ schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) payload = make_slack_payload( organization=organization, - current_users={str(user.pk): DEFAULT_POLICY}, - actions=[{"selected_option": {"value": f"{REMOVE_ACTION}|{SCHEDULES_DATA_KEY}|{schedule.pk}"}}], + current_users={str(user.pk): Policy.DEFAULT}, + actions=[{"selected_option": {"value": f"{Policy.REMOVE_ACTION}|{DataKey.SCHEDULES}|{schedule.pk}"}}], ) step = OnPagingItemActionChange(slack_team_identity) @@ -402,5 +399,5 @@ def test_remove_schedule(make_organization_and_user_with_slack_identities, make_ assert mock_slack_api_call.call_args.args == ("views.update",) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[SCHEDULES_DATA_KEY] == {} - assert metadata[USERS_DATA_KEY] == {str(user.pk): DEFAULT_POLICY} + assert metadata[DataKey.SCHEDULES] == {} + assert metadata[DataKey.USERS] == {str(user.pk): Policy.DEFAULT} diff --git a/engine/apps/slack/tests/test_scenario_steps/test_shift_swap_requests.py b/engine/apps/slack/tests/test_scenario_steps/test_shift_swap_requests.py new file mode 100644 index 00000000..f9308641 --- /dev/null +++ b/engine/apps/slack/tests/test_scenario_steps/test_shift_swap_requests.py @@ -0,0 +1,236 @@ +import json +from unittest.mock import patch + +import pytest + +from apps.schedules import exceptions +from apps.slack.scenarios import shift_swap_requests as scenarios + + +@pytest.fixture +def setup(make_organization_and_user_with_slack_identities, shift_swap_request_setup): + def _setup(**kwargs): + organization, _, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + ssr, beneficiary, benefactor = shift_swap_request_setup(**kwargs) + + organization = ssr.organization + organization.slack_team_identity = slack_team_identity + organization.save() + + return ssr, beneficiary, benefactor, slack_user_identity + + return _setup + + +@pytest.fixture +def payload(): + def _payload(shift_swap_request_pk): + return {"actions": [{"value": json.dumps({"shift_swap_request_pk": shift_swap_request_pk})}]} + + return _payload + + +class TestBaseShiftSwapRequestStep: + @pytest.mark.django_db + def test_generate_blocks(self, setup) -> None: + ssr, beneficiary, _, _ = setup() + + step = scenarios.BaseShiftSwapRequestStep(ssr.organization.slack_team_identity, ssr.organization) + blocks = step._generate_blocks(ssr) + + assert ( + blocks[0]["text"]["text"] + == f"Your teammate {beneficiary.get_username_with_slack_verbal()} has submitted a shift swap request." + ) + + assert ( + blocks[1]["text"]["text"] + == "*📅 Shift Details*: 9h00 - 17h00 (UTC) daily from Monday July 24, 2023 - July 28, 2023" + ) + + accept_button = blocks[2] + + assert accept_button["elements"][0]["text"]["text"] == "✔️ Accept Shift Swap Request" + assert accept_button["type"] == "actions" + + assert blocks[3]["type"] == "divider" + + context_section = blocks[4] + + assert context_section["type"] == "context" + assert ( + context_section["elements"][0]["text"] + == f"👀 View the shift swap within Grafana OnCall by clicking <{ssr.web_link}|here>." + ) + + @pytest.mark.django_db + def test_generate_blocks_ssr_has_description(self, setup) -> None: + description = "asdlfkjalkjqwelkrjqwlkerj" + ssr, _, _, _ = setup(description=description) + + step = scenarios.BaseShiftSwapRequestStep(ssr.organization.slack_team_identity, ssr.organization) + blocks = step._generate_blocks(ssr) + + assert blocks[2]["text"]["text"] == f"*📝 Description*: {description}" + + @pytest.mark.django_db + def test_generate_blocks_ssr_is_deleted(self, setup) -> None: + ssr, _, _, _ = setup() + ssr.delete() + + step = scenarios.BaseShiftSwapRequestStep(ssr.organization.slack_team_identity, ssr.organization) + blocks = step._generate_blocks(ssr) + + assert blocks[2]["text"]["text"] == "*Update*: this shift swap request has been deleted." + + @pytest.mark.django_db + def test_generate_blocks_ssr_is_taken(self, setup) -> None: + ssr, _, benefactor, _ = setup() + ssr.benefactor = benefactor + ssr.save() + + step = scenarios.BaseShiftSwapRequestStep(ssr.organization.slack_team_identity, ssr.organization) + blocks = step._generate_blocks(ssr) + + assert ( + blocks[2]["text"]["text"] + == f"*Update*: {benefactor.get_username_with_slack_verbal()} has taken the shift swap." + ) + + @patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep._generate_blocks") + @pytest.mark.django_db + def test_create_message(self, mock_generate_blocks, setup) -> None: + ts = "12345.67" + + ssr, _, _, _ = setup() + organization = ssr.organization + slack_team_identity = organization.slack_team_identity + + step = scenarios.BaseShiftSwapRequestStep(slack_team_identity, organization) + + with patch.object(step, "_slack_client") as mock_slack_client: + mock_slack_client.api_call.return_value = {"ts": ts} + + slack_message = step.create_message(ssr) + + mock_generate_blocks.assert_called_once_with(ssr) + mock_slack_client.api_call.assert_called_once_with( + "chat.postMessage", channel=ssr.slack_channel_id, blocks=mock_generate_blocks.return_value + ) + + assert slack_message.slack_id == ts + assert slack_message.organization == organization + assert slack_message.channel_id == ssr.slack_channel_id + assert slack_message._slack_team_identity == slack_team_identity + + @patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep._generate_blocks") + @pytest.mark.django_db + def test_update_message(self, mock_generate_blocks, setup, make_slack_message) -> None: + ts = "12345.67" + + ssr, _, _, _ = setup() + organization = ssr.organization + slack_team_identity = organization.slack_team_identity + + slack_message = make_slack_message(alert_group=None, organization=organization, slack_id=ts) + ssr.slack_message = slack_message + ssr.save() + + step = scenarios.BaseShiftSwapRequestStep(slack_team_identity, organization) + + with patch.object(step, "_slack_client") as mock_slack_client: + step.update_message(ssr) + + mock_generate_blocks.assert_called_once_with(ssr) + mock_slack_client.api_call.assert_called_once_with( + "chat.update", channel=ssr.slack_channel_id, ts=ts, blocks=mock_generate_blocks.return_value + ) + + +class TestAcceptShiftSwapRequestStep: + @pytest.mark.django_db + def test_process_scenario(self, setup, payload) -> None: + ssr, _, benefactor, slack_user_identity = setup() + event_payload = payload(ssr.pk) + + organization = ssr.organization + slack_team_identity = organization.slack_team_identity + + step = scenarios.AcceptShiftSwapRequestStep(slack_team_identity, organization, benefactor) + + with patch.object(step, "update_message") as mock_update_message: + step.process_scenario(slack_user_identity, slack_team_identity, event_payload) + + ssr.refresh_from_db() + assert ssr.benefactor == benefactor + assert ssr.is_taken is True + + mock_update_message.assert_called_once_with(ssr) + + @patch("apps.schedules.models.shift_swap_request.ShiftSwapRequest.take") + @pytest.mark.django_db + def test_process_scenario_ssr_does_not_exist(self, mock_take, setup, payload) -> None: + event_payload = payload("12345") + ssr, _, benefactor, slack_user_identity = setup() + + organization = ssr.organization + slack_team_identity = organization.slack_team_identity + + step = scenarios.AcceptShiftSwapRequestStep(slack_team_identity, organization, benefactor) + + with patch.object(step, "update_message") as mock_update_message: + step.process_scenario(slack_user_identity, slack_team_identity, event_payload) + + assert ssr.is_taken is False + assert ssr.benefactor is None + + mock_take.assert_not_called() + mock_update_message.assert_not_called() + + @patch( + "apps.schedules.models.shift_swap_request.ShiftSwapRequest.take", + side_effect=exceptions.BeneficiaryCannotTakeOwnShiftSwapRequest, + ) + @pytest.mark.django_db + def test_process_scenario_cannot_take_own_ssr(self, mock_take, setup, payload) -> None: + ssr, beneficiary, _, slack_user_identity = setup() + event_payload = payload(ssr.pk) + + organization = ssr.organization + slack_team_identity = organization.slack_team_identity + + step = scenarios.AcceptShiftSwapRequestStep(slack_team_identity, organization, beneficiary) + + with patch.object(step, "update_message") as mock_update_message: + with patch.object(step, "open_warning_window") as mock_open_warning_window: + step.process_scenario(slack_user_identity, slack_team_identity, event_payload) + + mock_take.assert_called_once_with(beneficiary) + mock_open_warning_window.assert_called_once_with( + event_payload, "A shift swap request cannot be created and taken by the same user" + ) + mock_update_message.assert_not_called() + + @patch( + "apps.schedules.models.shift_swap_request.ShiftSwapRequest.take", + side_effect=exceptions.ShiftSwapRequestNotOpenForTaking, + ) + @pytest.mark.django_db + def test_process_scenario_ssr_is_not_open_for_taking(self, mock_take, setup, payload) -> None: + ssr, _, benefactor, slack_user_identity = setup() + event_payload = payload(ssr.pk) + + organization = ssr.organization + slack_team_identity = organization.slack_team_identity + + step = scenarios.AcceptShiftSwapRequestStep(slack_team_identity, organization, benefactor) + + with patch.object(step, "update_message") as mock_update_message: + with patch.object(step, "open_warning_window") as mock_open_warning_window: + step.process_scenario(slack_user_identity, slack_team_identity, event_payload) + + mock_take.assert_called_once_with(benefactor) + mock_open_warning_window.assert_called_once_with( + event_payload, "The shift swap request is not in a state which allows it to be taken" + ) + mock_update_message.assert_not_called() diff --git a/engine/apps/slack/types/__init__.py b/engine/apps/slack/types/__init__.py new file mode 100644 index 00000000..81ebea1d --- /dev/null +++ b/engine/apps/slack/types/__init__.py @@ -0,0 +1,8 @@ +from .blocks import Block # noqa: F401 +from .common import EventType, MessageEventSubtype, PayloadType # noqa: F401 +from .composition_objects import CompositionObjects # noqa: F401 +from .interaction_payloads import EventPayload # noqa: F401 +from .interaction_payloads.block_actions import BlockActionType # noqa: F401 +from .interaction_payloads.interactive_messages import InteractiveMessageActionType # noqa: F401 +from .scenario_routes import ScenarioRoute # noqa: F401 +from .views import ModalView # noqa: F401 diff --git a/engine/apps/slack/types/block_elements.py b/engine/apps/slack/types/block_elements.py new file mode 100644 index 00000000..9505ac2d --- /dev/null +++ b/engine/apps/slack/types/block_elements.py @@ -0,0 +1,256 @@ +""" +[Documentation](https://api.slack.com/reference/block-kit/block-elements) +""" + +import typing + +from .common import Style +from .composition_objects import CompositionObjects + + +class _BaseBlockElement(typing.TypedDict): + action_id: str + """ + An identifier for this action. You can use this when you receive an interaction payload to + [identify the source of the action](https://api.slack.com/interactivity/handling#payloads). Should be unique among + all other `action_id`s in the containing block. Maximum length for this field is 255 characters. + """ + + +class BlockElement: + class Button(_BaseBlockElement): + """ + An interactive component that inserts a button. The button can be a trigger for anything from opening + a simple link to starting a complex workflow. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#button) + """ + + type: typing.Literal["button"] + """ + The type of element. In this case `type` is always `button`. + """ + + text: CompositionObjects.Text + """ + A [text object](https://api.slack.com/reference/block-kit/composition-objects#text) that defines the button's text. + + Can only be of `type: plain_text`. `text` may truncate with ~30 characters. Maximum length for the `text` in this + field is 75 characters. + """ + + style: Style | None + """ + Decorates buttons with alternative visual color schemes. Use this option with restraint. + + `primary` gives buttons a green outline and text, ideal for affirmation or confirmation actions. `primary` should + only be used for one button within a set. + + `danger` gives buttons a red outline and text, and should be used when the action is destructive. Use `danger` even more sparingly than `primary`. + + If you don't include this field, the `default` button style will be used. + """ + + class CheckboxGroup(_BaseBlockElement): + """ + A checkbox group that allows a user to choose multiple items from a list of possible options. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#checkboxes) + """ + + type: typing.Literal["checkboxes"] + """ + The type of element. In this case `type` is always `checkboxes`. + """ + + options: typing.List[CompositionObjects.Option] + """ + An array of [option objects](https://api.slack.com/reference/block-kit/composition-objects#option). + A maximum of 10 options are allowed. + """ + + initial_options: typing.Optional[typing.List[CompositionObjects.Option]] + """ + An array of [option objects](https://api.slack.com/reference/block-kit/composition-objects#option) that exactly + matches one or more of the options within `options`. These options will be selected when the checkbox group + initially loads. + """ + + confirm: typing.Optional[CompositionObjects.Confirm] + """ + A [confirm object](https://api.slack.com/reference/block-kit/composition-objects#confirm) that defines an optional + confirmation dialog that appears after clicking one of the checkboxes in this element. + """ + + focus_on_load: typing.Optional[bool] + """ + Indicates whether the element will be set to auto focus within the + [view object](https://api.slack.com/reference/surfaces/views). Only one element can be set to `true`. Defaults to + `false`. + """ + + class DatePicker(_BaseBlockElement): + """ + An element which lets users easily select a date from a calendar style UI. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#datepicker) + """ + + type: typing.Literal["datepicker"] + """ + The type of element. In this case `type` is always `datepicker`. + """ + + initial_date: str + """ + The initial date that is selected when the element is loaded. This should be in the format `YYYY-MM-DD`. + """ + + confirm: CompositionObjects.Confirm + """ + A [confirm object](https://api.slack.com/reference/block-kit/composition-objects#confirm) that defines an + optional confirmation dialog that appears after a menu item is selected. + """ + + focus_on_load: bool + """ + Indicates whether the element will be set to auto focus within the + [view object](https://api.slack.com/reference/surfaces/views). + + Only one element can be set to `true`. Defaults to `false`. + """ + + placeholder: CompositionObjects.PlainText + """ + A [plain_text only text object](https://api.slack.com/reference/block-kit/composition-objects#text) that + defines the placeholder text shown on the datepicker. + + Maximum length for the `text` in this field is 150 characters. + """ + + class Image(typing.TypedDict): + """ + An element to insert an image as part of a larger block of content. + + If you want a block with only an image in it, you're looking for the + [image block](https://api.slack.com/reference/block-kit/blocks#image). + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#image) + """ + + type: typing.Literal["button"] + """ + The type of element. In this case `type` is always `button`. + """ + + image_url: str + """ + The URL of the image to be displayed. + """ + + alt_text: str + """ + A plain-text summary of the image. This should not contain any markup. + """ + + class OverflowMenu(_BaseBlockElement): + """ + This is like a cross between a button and a select menu - when a user clicks on this overflow button, they will + be presented with a list of options to choose from. Unlike the select menu, there is no typeahead field, and + the button always appears with an ellipsis ("…") rather than customizable text. + + As such, it is usually used if you want a more compact layout than a select menu, or to supply a list of less + visually important actions after a row of buttons. You can also specify simple URL links as overflow menu + options, instead of actions. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#overflow) + """ + + type: typing.Literal["overflow"] + """ + The type of element. In this case `type` is always `overflow`. + """ + + options: typing.List[CompositionObjects.Option] + """ + An array of up to five [option objects](https://api.slack.com/reference/block-kit/composition-objects#option) + to display in the menu. + """ + + confirm: CompositionObjects.Confirm + """ + A [confirm object](https://api.slack.com/reference/block-kit/composition-objects#confirm) that defines an + optional confirmation dialog that appears after a menu item is selected. + """ + + class Select: + class Channels(_BaseBlockElement): + """ + This select menu will populate its options with a list of public channels visible to the current user in + the active workspace. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#channels_select) + """ + + type: typing.Literal["channels_select"] + """ + The type of element. In this case `type` is always `channels_select` + """ + + class Conversations(_BaseBlockElement): + """ + This select menu will populate its options with a list of public and private channels, DMs, and MPIMs + visible to the current user in the active workspace. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#conversations_select) + """ + + type: typing.Literal["conversations_select"] + """ + The type of element. In this case `type` is always `conversations_select` + """ + + class External(_BaseBlockElement): + """ + This select menu will load its options from an external data source, allowing for a dynamic list of options. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#external_select) + """ + + type: typing.Literal["external_select"] + """ + The type of element. In this case `type` is always `external_select` + """ + + class Static(_BaseBlockElement): + """ + This is the simplest form of select menu, with a static list of options passed in when defining the element. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#static_select) + """ + + type: typing.Literal["static_select"] + """ + The type of element. In this case `type` is always `static_select` + """ + + class Users(_BaseBlockElement): + """ + This select menu will populate its options with a list of Slack users visible to the current user in the active workspace. + + [Documentation](https://api.slack.com/reference/block-kit/block-elements#users_select) + """ + + type: typing.Literal["users_select"] + """ + The type of element. In this case `type` is always `users_select` + """ + + Any = Channels | Conversations | External | Static | Users + + Any = Button | CheckboxGroup | DatePicker | Image | OverflowMenu | Select.Any + + +__all__ = [ + "BlockElement", +] diff --git a/engine/apps/slack/types/blocks.py b/engine/apps/slack/types/blocks.py new file mode 100644 index 00000000..409b00ae --- /dev/null +++ b/engine/apps/slack/types/blocks.py @@ -0,0 +1,229 @@ +""" +[Documentation](https://api.slack.com/reference/block-kit/blocks) +""" + +import typing + +from .block_elements import BlockElement +from .composition_objects import CompositionObjects + + +class Block: + class _BaseBlock(typing.TypedDict): + block_id: str + """ + A string acting as a unique identifier for a block. If not specified, one will be generated. + + You can use this `block_id` when you receive an interaction payload to + [identify the source of the action](https://api.slack.com/interactivity/handling#payloads). Maximum + length for this field is 255 characters. `block_id` should be unique for each message and each iteration of a + message. If a message is updated, use a new `block_id`. + """ + + class Actions(_BaseBlock): + """ + A block that is used to hold interactive elements. + + [Documentation](https://api.slack.com/reference/block-kit/blocks#actions) + """ + + type: typing.Literal["actions"] + """ + The type of block. For an actions block, `type` is always `actions`. + """ + + elements: typing.List[ + BlockElement.Button | BlockElement.Select.Any | BlockElement.OverflowMenu | BlockElement.DatePicker + ] + """ + An array of interactive [element objects](https://api.slack.com/reference/messaging/block-elements) - + [buttons](https://api.slack.com/reference/messaging/block-elements#button), + [select menus](https://api.slack.com/reference/messaging/block-elements#select), + [overflow menus](https://api.slack.com/reference/messaging/block-elements#overflow), or + [date pickers](https://api.slack.com/reference/messaging/block-elements#datepicker). + + There is a maximum of 25 elements in each action block. + """ + + class Context(_BaseBlock): + """ + Displays message context, which can include both images and text. + + [Documentation](https://api.slack.com/reference/block-kit/blocks#context) + """ + + type: typing.Literal["context"] + """ + The type of block. For a context block, `type` is always `context`. + """ + + elements: typing.List[CompositionObjects.Text | BlockElement.Image] + """ + An array of [image elements](https://api.slack.com/reference/messaging/block-elements#image) and + [text objects](https://api.slack.com/reference/messaging/composition-objects#text). + + Maximum number of items is 10. + """ + + class Divider(_BaseBlock): + """ + A content divider, like an `
`, to split up different blocks inside of a message. The divider block is nice + and neat, requiring only a `type`. + + [Documentation](https://api.slack.com/reference/block-kit/blocks#divider) + """ + + type: typing.Literal["divider"] + """ + The type of block. For a divider block, `type` is always `divider`. + """ + + class Header(_BaseBlock): + """ + A `header` is a plain-text block that displays in a larger, bold font. Use it to delineate between different + groups of content in your app's surfaces. + + [Documentation](https://api.slack.com/reference/block-kit/blocks#header) + """ + + type: typing.Literal["header"] + """ + The type of block. For a header block, `type` is always `header`. + """ + + text: CompositionObjects.Text + """ + The text for the block, in the form of a [text object](https://api.slack.com/reference/block-kit/composition-objects#text). + + Maximum length for the `text` in this field is 150 characters. + """ + + class Image(_BaseBlock): + """ + A simple image block, designed to make those cat photos really pop. + + [Documentation](https://api.slack.com/reference/block-kit/blocks#image) + """ + + type: typing.Literal["image"] + """ + The type of block. For an image block, `type` is always `image`. + """ + + image_url: str + """ + The URL of the image to be displayed. + + Maximum length for this field is 3000 characters. + """ + + alt_text: str + """ + A plain-text summary of the image. This should not contain any markup. + + Maximum length for this field is 2000 characters. + """ + + title: CompositionObjects.PlainText + """ + An optional title for the image in the form of a + [text object](https://api.slack.com/reference/messaging/composition-objects#text) that can only be of + `type: plain_text`. + + Maximum length for the `text` in this field is 2000 characters. + """ + + class Input(_BaseBlock): + """ + A block that collects information from users - it can hold a plain-text input element, a checkbox element, a + radio button element, a select menu element, a multi-select menu element, or a datepicker. + + [Documentation](https://api.slack.com/reference/block-kit/blocks#input) + """ + + type: typing.Literal["input"] + """ + The type of block. For an input block, `type` is always `input`. + """ + + label: CompositionObjects.PlainText + """ + A label that appears above an input element in the form of a + [text object](https://api.slack.com/reference/messaging/composition-objects#text) that must + have type of `plain_text`. + + Maximum length for the text in this field is 2000 characters. + """ + + element: BlockElement.Any + """ + A plain-text input element, a checkbox element, a radio button element, a select menu element, a multi-select + menu element, or a datepicker. + """ + + dispatch_action: bool + """ + A boolean that indicates whether or not the use of elements in this block should dispatch a + [block_actions payload](https://api.slack.com/reference/interaction-payloads/block-actions). + + Defaults to `false`. + """ + + hint: CompositionObjects.PlainText + """ + An optional hint that appears below an input element in a lighter grey. + + It must be a [text object](https://api.slack.com/reference/messaging/composition-objects#text) with a type of + `plain_text`. Maximum length for the `text` in this field is 2000 characters. + """ + + optional: bool + """ + A boolean that indicates whether the input element may be empty when a user submits the modal. + + Defaults to `false`. + """ + + class Section(_BaseBlock): + """ + A `section` can be used as a simple text block, in combination with text fields, or side-by-side with certain + [block elements](https://api.slack.com/reference/messaging/block-elements). + + [Documentation](https://api.slack.com/reference/block-kit/blocks#section) + """ + + type: typing.Literal["section"] + """ + The type of block. For a section block, `type` will always be `section`. + """ + + text: CompositionObjects.Text + """ + The text for the block, in the form of a [text object](https://api.slack.com/reference/block-kit/composition-objects#text). + + Minimum length for the text in this field is 1 and maximum length is 3000 characters. + This field is not required if a valid array of fields objects is provided instead. + """ + + fields: typing.List[CompositionObjects.Text] + """ + Required if no `text` is provided. + + An array of [text objects](https://api.slack.com/reference/messaging/composition-objects#text). Any text objects + included with `fields` will be rendered in a compact format that allows for 2 columns of side-by-side text. Maximum number of items is 10. Maximum length for the `text` in each item is 2000 characters. + """ # noqa: E501 + + accessory: BlockElement.Any + """ + One of the compatible [element objects](https://api.slack.com/reference/messaging/block-elements). + + Be sure to confirm the desired element works with `section`. + """ + + Any = Actions | Context | Divider | Header | Image | Input | Section + AnyBlocks = typing.List[Any] + + +__all__ = [ + "Block", +] diff --git a/engine/apps/slack/types/common.py b/engine/apps/slack/types/common.py new file mode 100644 index 00000000..c59d6164 --- /dev/null +++ b/engine/apps/slack/types/common.py @@ -0,0 +1,215 @@ +import enum +import typing + + +class PayloadType(enum.StrEnum): + INTERACTIVE_MESSAGE = "interactive_message" + SLASH_COMMAND = "slash_command" + EVENT_CALLBACK = "event_callback" + BLOCK_ACTIONS = "block_actions" + DIALOG_SUBMISSION = "dialog_submission" + VIEW_SUBMISSION = "view_submission" + MESSAGE_ACTION = "message_action" + + +class EventType(enum.StrEnum): + """ + [Documentation](https://api.slack.com/events) + """ + + MESSAGE = "message" + """ + A message was sent to a channel + + [Documentation](https://api.slack.com/events/message) + """ + + MESSAGE_CHANNEL = "channel" + """ + NOTE: this event doesn't actually seem to exist? This is here for legacy reasons and should + probably be re-investgated and/or deleted? + """ + + USER_CHANGE = "user_change" + """ + NOTE: This is deprecated in favour of `user_profile_changed`. Kept for legacy reasons. + + A member's data has changed + + [Documentation](https://api.slack.com/events/user_change) + """ + + USER_PROFILE_CHANGED = "user_profile_changed" + """ + A user's profile data has changed + + [Documentation](https://api.slack.com/events/user_profile_changed) + """ + + APP_MENTION = "app_mention" + """ + Subscribe to only the message events that mention your app or bot + + [Documentation](https://api.slack.com/events/app_mention) + """ + + MEMBER_JOINED_CHANNEL = "member_joined_channel" + """ + A user joined a public channel, private channel or MPDM. + + [Documentation](https://api.slack.com/events/member_joined_channel) + """ + + IM_OPEN = "im_open" + """ + You opened a DM + + [Documentation](https://api.slack.com/events/im_open) + """ + + APP_HOME_OPENED = "app_home_opened" + """ + User clicked into your App Home + + [Documentation](https://api.slack.com/events/app_home_opened) + """ + + SUBTEAM_CREATED = "subteam_created" + """ + A User Group has been added to the workspace + + [Documentation](https://api.slack.com/events/subteam_created) + """ + + SUBTEAM_UPDATED = "subteam_updated" + """ + An existing User Group has been updated or its members changed + + [Documentation](https://api.slack.com/events/subteam_updated) + """ + + SUBTEAM_MEMBERS_CHANGED = "subteam_members_changed" + """ + The membership of an existing User Group has changed + + [Documentation](https://api.slack.com/events/subteam_members_changed) + """ + + CHANNEL_DELETED = "channel_deleted" + """ + A channel was deleted + + [Documentation](https://api.slack.com/events/channel_deleted) + """ + + CHANNEL_CREATED = "channel_created" + """ + A channel was created + + [Documentation](https://api.slack.com/events/channel_created) + """ + + CHANNEL_RENAMED = "channel_rename" + """ + A channel was renamed + + [Documentation](https://api.slack.com/events/channel_rename) + """ + + CHANNEL_ARCHIVED = "channel_archive" + """ + A channel was archived + + [Documentation](https://api.slack.com/events/channel_archive) + """ + + CHANNEL_UNARCHIVED = "channel_unarchive" + """ + A channel was unarchived + + [Documentation](https://api.slack.com/events/channel_unarchive) + """ + + +class MessageEventSubtype(enum.StrEnum): + """ + [Documentation](https://api.slack.com/events/message#subtypes) + """ + + MESSAGE_CHANGED = "message_changed" + """ + A message was changed + + [Documentation](https://api.slack.com/events/message/message_changed) + """ + + MESSAGE_DELETED = "message_deleted" + """ + A message was deleted + + [Documentation](https://api.slack.com/events/message/message_deleted) + """ + + BOT_MESSAGE = "bot_message" + """ + A message was posted by an integration + + [Documentation](https://api.slack.com/events/message/bot_message) + """ + + +class Style(enum.StrEnum): + DEFAULT = "default" + PRIMARY = "primary" + DANGER = "danger" + + +class User(typing.TypedDict): + id: str + """user's `public_primary_key`""" + + username: str + name: str + team_id: str + """team's `public_primary_key`""" + + +class Team(typing.TypedDict): + id: str + domain: str + + +class Container(typing.TypedDict): + type: str + + +class Message(typing.TypedDict): + type: typing.Literal["message"] + bot_id: str + text: str + user: str + ts: str + + +class Channel(typing.TypedDict): + id: str + name: str + + +class BaseEvent(typing.TypedDict): + type: PayloadType + + user: User + """ + The user who interacted to trigger this request. + """ + + team: Team | None + """ + The workspace the app is installed on. Null if the app is org-installed. + """ + + api_app_id: str + """ + A string representing the app ID. + """ diff --git a/engine/apps/slack/types/composition_objects.py b/engine/apps/slack/types/composition_objects.py new file mode 100644 index 00000000..a0a8864f --- /dev/null +++ b/engine/apps/slack/types/composition_objects.py @@ -0,0 +1,194 @@ +""" +[Documentation](https://api.slack.com/reference/block-kit/composition-objects) +""" + +import typing + +from .common import Style + + +class _TextBase(typing.TypedDict): + """ + An object containing some text, formatted either as `plain_text` or using `mrkdwn`. + + [Documentation](https://api.slack.com/reference/block-kit/composition-objects#text) + """ + + type: typing.Literal["plain_text"] | typing.Literal["mrkdwn"] + """ + The formatting to use for this text object. Can be one of `plain_text` or `mrkdwn`. + """ + + text: str + """ + The text for the block. This field accepts any of the standard + [text formatting markup](https://api.slack.com/reference/surfaces/formatting) when `type` is `mrkdwn`. + The minimum length is 1 and maximum length is 3000 characters. + """ + + emoji: typing.Optional[bool] + """ + Indicates whether emojis in a text field should be escaped into the colon emoji format. + + This field is only usable when `type` is `plain_text`. + """ + + verbatim: typing.Optional[bool] + """ + When set to `false` (as is default) URLs will be auto-converted into links, conversation names will be link-ified, + and certain mentions will be automatically parsed. + + Using a value of `true` will skip any preprocessing of this nature, although you can still include + [manual parsing strings](https://api.slack.com/reference/surfaces/formatting#advanced). This field is only usable + when `type` is `mrkdwn`. + """ + + +class _PlainText(_TextBase): + type: typing.Literal["plain_text"] + """ + The formatting to use for this text object. + """ + + +class _MrkdwnText(_TextBase): + type: typing.Literal["mrkdwn"] + """ + The formatting to use for this text object. + """ + + +_Text = _PlainText | _MrkdwnText + + +class _Option(typing.TypedDict): + """ + An object that represents a single selectable item in a select menu, multi-select menu, checkbox group, radio button group, or overflow menu. + + [Documentation](https://api.slack.com/reference/block-kit/composition-objects#option) + """ + + text: _Text + """ + A [text object](https://api.slack.com/reference/block-kit/composition-objects#text) that defines the text shown in + the option on the menu. + + Overflow, select, and multi-select menus can only use `plain_text` objects, while radio buttons and checkboxes can + use `mrkdwn` text objects. Maximum length for the text in this field is 75 characters. + """ + + value: str + """ + A unique string value that will be passed to your app when this option is chosen. + + Maximum length for this field is 75 characters. + """ + + description: typing.Optional[_PlainText] + """ + A [plain_text-only text object](https://api.slack.com/reference/block-kit/composition-objects#confirm:~:text=A-,plain_text,%2Donly%20text%20object,-that%20defines%20the) + that defines a line of descriptive text shown below the `text` field beside the radio button. + + Maximum length for the `text` object within this field is 75 characters. + """ + + url: typing.Optional[str] + """ + A URL to load in the user's browser when the option is clicked. + + The `url` attribute is only available in + [overflow menus](https://api.slack.com/reference/block-kit/block-elements#overflow). Maximum length for this field + is 3000 characters. If you're using `url`, you'll still receive an + [interaction payload](https://api.slack.com/interactivity/handling#payloads) and will need to + [send an acknowledgement response](https://api.slack.com/interactivity/handling#acknowledgment_response). + """ + + +class _OptionGroup(typing.TypedDict): + """ + Provides a way to group options in a [select menu](https://api.slack.com/reference/block-kit/block-elements#select) + or [multi-select menu](https://api.slack.com/reference/block-kit/block-elements#multi_select). + + [Documentation](https://api.slack.com/reference/block-kit/composition-objects#option_group) + """ + + label: _PlainText + """ + A [plain_text only text object](https://api.slack.com/reference/block-kit/composition-objects#text) that defines + the label shown above this group of options. + + Maximum length for the `text` in this field is 75 characters. + """ + + options: typing.List[_Option] + """ + An array of [option objects](https://api.slack.com/reference/block-kit/composition-objects#option) that belong to + this specific group. + + Maximum of 100 items. + """ + + +class _Confirm(typing.TypedDict): + """ + An object that defines a dialog that provides a confirmation step to any interactive element. + This dialog will ask the user to confirm their action by offering a confirm and deny buttons. + + [Documentation](https://api.slack.com/reference/block-kit/composition-objects#confirm) + """ + + title: _PlainText + """ + A [plain_text-only text object](https://api.slack.com/reference/block-kit/composition-objects#confirm:~:text=A-,plain_text,%2Donly%20text%20object,-that%20defines%20the) + that defines the dialog's title. + + Maximum length for this field is 100 characters. + """ + + text: _PlainText + """ + A [plain_text-only text object](https://api.slack.com/reference/block-kit/composition-objects#confirm:~:text=A-,plain_text,%2Donly%20text%20object,-that%20defines%20the) + that defines the explanatory text that appears in the confirm dialog. + + Maximum length for the text in this field is 300 characters. + """ + + confirm: _PlainText + """ + A [plain_text-only text object](https://api.slack.com/reference/block-kit/composition-objects#confirm:~:text=A-,plain_text,%2Donly%20text%20object,-that%20defines%20the) + to define the text of the button that confirms the action. + + Maximum length for the text in this field is 30 characters. + """ + + deny: _PlainText + """ + A [plain_text-only text object](https://api.slack.com/reference/block-kit/composition-objects#confirm:~:text=A-,plain_text,%2Donly%20text%20object,-that%20defines%20the) + to define the text of the button that cancels the action. + + Maximum length for the text in this field is 30 characters. + """ + + style: typing.Literal[Style.DANGER, Style.PRIMARY] | None + """ + Defines the color scheme applied to the confirm button. + + A value of `danger` will display the button with a red background on desktop, or red text on mobile. A value of + `primary` will display the button with a green background on desktop, or blue text on mobile. + + If this field is not provided, the default value will be `primary`. + """ + + +class CompositionObjects: + Confirm = _Confirm + MrkdwnText = _MrkdwnText + Option = _Option + OptionGroup = _OptionGroup + PlainText = _PlainText + Text = _Text + + +__all__ = [ + "CompositionObjects", +] diff --git a/engine/apps/slack/types/interaction_payloads/__init__.py b/engine/apps/slack/types/interaction_payloads/__init__.py new file mode 100644 index 00000000..67eed4b4 --- /dev/null +++ b/engine/apps/slack/types/interaction_payloads/__init__.py @@ -0,0 +1,24 @@ +from .block_actions import BlockActionsPayload +from .dialog_submission import DialogSubmissionPayload +from .interactive_messages import InteractiveMessagesPayload +from .shortcuts import MessageActionPayload +from .slash_command import SlashCommandPayload +from .view_submission import ViewSubmissionPayload + + +class EventPayload: + BlockActionsPayload = BlockActionsPayload + DialogSubmissionPayload = DialogSubmissionPayload + InteractiveMessagesPayload = InteractiveMessagesPayload + MessageActionPayload = MessageActionPayload + SlashCommandPayload = SlashCommandPayload + ViewSubmissionPayload = ViewSubmissionPayload + + Any = ( + BlockActionsPayload + | DialogSubmissionPayload + | InteractiveMessagesPayload + | MessageActionPayload + | SlashCommandPayload + | ViewSubmissionPayload + ) diff --git a/engine/apps/slack/types/interaction_payloads/block_actions.py b/engine/apps/slack/types/interaction_payloads/block_actions.py new file mode 100644 index 00000000..e6bf8877 --- /dev/null +++ b/engine/apps/slack/types/interaction_payloads/block_actions.py @@ -0,0 +1,118 @@ +""" +[Documentation](https://api.slack.com/reference/interaction-payloads/block-actions) +""" + +import enum +import typing + +from apps.slack.types.common import BaseEvent, Channel, Container, Message, PayloadType + + +class BlockActionType(enum.StrEnum): + """ + https://api.slack.com/reference/interaction-payloads/block-actions#payload_timing + """ + + USERS_SELECT = "users_select" + BUTTON = "button" + STATIC_SELECT = "static_select" + CONVERSATIONS_SELECT = "conversations_select" + CHANNELS_SELECT = "channels_select" + OVERFLOW = "overflow" + DATEPICKER = "datepicker" + CHECKBOXES = "checkboxes" + + +class BlockAction(typing.TypedDict): + """ + [Documentation](https://api.slack.com/reference/interaction-payloads/block-actions) + """ + + block_id: str + """ + Identifies the block within a surface that contained the interactive component that was used. + + See the [reference guide for the block you're using](https://api.slack.com/reference/block-kit/blocks) + for more info on the `block_id` field. + """ + + action_id: str + """ + Identifies the interactive component itself. + + Some blocks can contain multiple interactive components, so the `block_id` alone may not be specific enough to + identify the source component.See the + [reference guide for the interactive element you're using](https://api.slack.com/reference/block-kit/block-elements) + for more info on the `action_id` field. + """ + + value: str + """ + Set by your app when you composed the blocks, this is the value that was specified in the interactive component + when an interaction happened. + + For example, a select menu will have multiple possible values depending on what the + user picks from the menu, and `value` will identify the chosen option. See the + [reference guide for the interactive element you're using](https://api.slack.com/reference/block-kit/block-elements) + for more info on the `value` field. + """ + + +class BlockActionsPayload(BaseEvent): + """ + [Documentation](https://api.slack.com/reference/interaction-payloads/block-actions) + """ + + type: typing.Literal[PayloadType.BLOCK_ACTIONS] + """ + Helps identify which type of interactive component sent the payload. + + An interactive element in a block will have a type of `block_actions`, whereas an interactive element in a + [message attachment](https://api.slack.com/reference/messaging/attachments) will have a type of + `interactive_message`. + """ + + trigger_id: str + """ + A short-lived ID that can be [used to open modals](https://api.slack.com/interactivity/handling#modal_responses). + + Triggers expire in three seconds. Use them before you lose them. You'll receive a `trigger_expired` error when + using a method with an expired `trigger_id`. + + Triggers may only be used once. You may perform just one operation with a `trigger_id`. Subsequent attempts are presented with a `trigger_exchanged` error. + + For more info see [here](https://api.slack.com/interactivity/handling#modal_responses). + """ + + container: Container + """ + The container where this block action took place. + """ + + actions: typing.Optional[typing.List[BlockAction]] + """ + (Optional) Contains data from the specific + [interactive component](https://api.slack.com/reference/block-kit/interactive-components) that was used. + + [App surfaces](https://api.slack.com/surfaces) can contain + [blocks](https://api.slack.com/reference/block-kit/blocks) with multiple interactive components, and each of those + components can have multiple values selected by users. + """ + + token: str + """ + Represents a deprecated verification token feature. + + You should validate the request payload, however, and the best way to do so is to + [use the signing secret provided to your app](https://api.slack.com/reference/interaction-payloads/block-actions#:~:text=use%20the%20signing%20secret%20provided%20to%20your%20app). + """ # noqa: E501 + + channel: typing.Optional[Channel] + """ + (Optional) The channel where this block action took place. + """ + + message: typing.Optional[Message] + """ + (Optional) The message where this block action took place, if the block was contained in a message. + """ diff --git a/engine/apps/slack/types/interaction_payloads/dialog_submission.py b/engine/apps/slack/types/interaction_payloads/dialog_submission.py new file mode 100644 index 00000000..690b59d5 --- /dev/null +++ b/engine/apps/slack/types/interaction_payloads/dialog_submission.py @@ -0,0 +1,37 @@ +""" +[Documentation](https://api.slack.com/dialogs) +""" + + +import typing + +from apps.slack.types.common import BaseEvent, PayloadType + + +class DialogSubmissionPayload(BaseEvent): + """ + [Documentation](https://api.slack.com/dialogs#:~:text=deeper%20at%20those-,attributes,-%2C%20which%20you%20might) + """ + + type: typing.Literal[PayloadType.DIALOG_SUBMISSION] + """ + to differentiate from other interactive components, look for the string value `dialog_submission` + """ + + submission: typing.Dict[str, str] + """ + A hash of key/value pairs representing the user's submission. Each key is a `name` field your app provided when + composing the form. Each `value` is the user's submitted value, or in the case of a static select menu, the + value you assigned to a specific response. The selection from a dynamic menu, the `value` can be a channel ID, + user ID, etc. + """ + + state: str + """ + this string simply echoes back what your app passed to `dialog.open`. Use it as a pointer that references sensitive data stored elsewhere. + """ + + action_ts: str + """ + this is a unique identifier for this specific action occurrence generated by Slack. It can be evaluated as a timestamp with milliseconds if that is helpful to you + """ diff --git a/engine/apps/slack/types/interaction_payloads/interactive_messages.py b/engine/apps/slack/types/interaction_payloads/interactive_messages.py new file mode 100644 index 00000000..c840b613 --- /dev/null +++ b/engine/apps/slack/types/interaction_payloads/interactive_messages.py @@ -0,0 +1,77 @@ +""" +[Documentation](https://api.slack.com/legacy/interactive-messages#receiving-action-invocations) +""" + +import enum +import typing + +from apps.slack.types.common import BaseEvent, Channel, PayloadType + + +class InteractiveMessageActionType(enum.StrEnum): + SELECT = "select" + BUTTON = "button" + + +class InteractiveMessageAction(typing.TypedDict): + """ + [Documentation](https://api.slack.com/legacy/interactive-messages#checking-action-type) + """ + + name: str + type: InteractiveMessageActionType + + +class OriginalMessage(typing.TypedDict): + """ + [Documentation](https://api.slack.com/legacy/interactive-messages#checking-action-type) + """ + + text: str + username: str + bot_id: str + attachments: typing.List + type: typing.Literal["message"] + subtype: str + ts: str + + +class InteractiveMessagesPayload(BaseEvent): + """ + [Documentation](https://api.slack.com/legacy/interactive-messages#receiving-action-invocations) + """ + + type: typing.Literal[PayloadType.INTERACTIVE_MESSAGE] + """ + Helps identify which type of interactive component sent the payload. + + An interactive element in a block will have a type of `block_actions`, whereas an interactive element in a + [message attachment](https://api.slack.com/reference/messaging/attachments) will have a type of + `interactive_message`. + """ + + trigger_id: str + """ + A short-lived ID that can be [used to open modals](https://api.slack.com/interactivity/handling#modal_responses). + + Triggers expire in three seconds. Use them before you lose them. You'll receive a `trigger_expired` error when + using a method with an expired `trigger_id`. + + Triggers may only be used once. You may perform just one operation with a `trigger_id`. Subsequent attempts are presented with a `trigger_exchanged` error. + + For more info see [here](https://api.slack.com/interactivity/handling#modal_responses). + """ + + actions: typing.List[InteractiveMessageAction] + + token: str + """ + Represents a deprecated verification token feature. + + You should validate the request payload, however, and the best way to do so is to + [use the signing secret provided to your app](https://api.slack.com/reference/interaction-payloads/block-actions#:~:text=use%20the%20signing%20secret%20provided%20to%20your%20app). + """ # noqa: E501 + + channel: Channel + + original_message: OriginalMessage diff --git a/engine/apps/slack/types/interaction_payloads/shortcuts.py b/engine/apps/slack/types/interaction_payloads/shortcuts.py new file mode 100644 index 00000000..95e023cd --- /dev/null +++ b/engine/apps/slack/types/interaction_payloads/shortcuts.py @@ -0,0 +1,20 @@ +""" +[Documentation](https://api.slack.com/reference/interaction-payloads/shortcuts) +""" + +import typing + +from apps.slack.types.common import BaseEvent, PayloadType + + +class MessageActionPayload(BaseEvent): + """ + [Documentation](https://api.slack.com/reference/interaction-payloads/shortcuts) + """ + + type: typing.Literal[PayloadType.MESSAGE_ACTION] + """ + Helps identify which type of interactive component sent the payload. + [Global shortcuts](https://api.slack.com/interactivity/shortcuts#global) will return `shortcut`, + [message shortcuts](https://api.slack.com/interactivity/shortcuts#message) will return `message_action`. + """ diff --git a/engine/apps/slack/types/interaction_payloads/slash_command.py b/engine/apps/slack/types/interaction_payloads/slash_command.py new file mode 100644 index 00000000..1e1081fc --- /dev/null +++ b/engine/apps/slack/types/interaction_payloads/slash_command.py @@ -0,0 +1,50 @@ +""" +[Documentation](https://api.slack.com/interactivity/slash-commands#app_command_handling) +""" + +import typing + + +class SlashCommandPayload(typing.TypedDict): + """ + [Documentation](https://api.slack.com/interactivity/slash-commands#app_command_handling) + """ + + command: str + """ + The command that was typed in to trigger this request. + + This value can be useful if you want to use a single Request URL to service multiple Slash Commands, as it lets you + tell them apart. + """ + + text: str + """ + This is the part of the Slash Command after the command itself, and it can contain absolutely anything that the + user might decide to type. It is common to use this text parameter to provide extra context for the command. + + You can prompt users to adhere to a particular format by showing them in the + [Usage Hint field when creating a command](https://api.slack.com/interactivity/slash-commands#app_command_handling:~:text=tell%20them%20apart.-,text,them%20in%20the%20Usage%20Hint%20field%20when%20creating%20a%20command.,-response_url). + """ # noqa: E501 + + trigger_id: str + """ + A short-lived ID that will let your app open [a modal](https://api.slack.com/surfaces/modals). + """ + + user_id: str + """ + The ID of the user who triggered the command. + """ + + user_name: str + """ + The plain text name of the user who triggered the command. As [above](https://api.slack.com/interactivity/slash-commands#escaping_users_warning), + do not rely on this field as it is being [phased out](https://api.slack.com/interactivity/slash-commands#app_command_handling:~:text=it%20is%20being-,phased%20out,-%2C%20use%20the), use the `user_id` instead. + """ # noqa: E501 + + api_app_id: str + """ + Your Slack app's unique identifier. Use this in conjunction with [request signing](https://api.slack.com/authentication/verifying-requests-from-slack) + to verify context for inbound requests. + """ diff --git a/engine/apps/slack/types/interaction_payloads/view_submission.py b/engine/apps/slack/types/interaction_payloads/view_submission.py new file mode 100644 index 00000000..9058618c --- /dev/null +++ b/engine/apps/slack/types/interaction_payloads/view_submission.py @@ -0,0 +1,24 @@ +""" +[Documentation](https://api.slack.com/reference/interaction-payloads/views#view_submission) +""" + +import typing + +from apps.slack.types.common import BaseEvent, PayloadType +from apps.slack.types.views import ModalView + + +class ViewSubmissionPayload(BaseEvent): + """ + [Documentation](https://api.slack.com/reference/interaction-payloads/views#view_submission) + """ + + type: typing.Literal[PayloadType.VIEW_SUBMISSION] + """ + Identifies the source of the payload. The type for this interaction is `view_submission`. + """ + + view: ModalView + """ + The source [view](https://api.slack.com/surfaces/modals#views) of the modal the user submitted. + """ diff --git a/engine/apps/slack/types/scenario_routes.py b/engine/apps/slack/types/scenario_routes.py new file mode 100644 index 00000000..f9928fe4 --- /dev/null +++ b/engine/apps/slack/types/scenario_routes.py @@ -0,0 +1,60 @@ +import typing + +from .common import EventType, PayloadType + +if typing.TYPE_CHECKING: + from apps.slack.scenarios.scenario_step import ScenarioStep + from apps.slack.types import BlockActionType, InteractiveMessageActionType + + +class ScenarioRoute: + class _Base(typing.TypedDict): + step: "ScenarioStep" + + class BlockActionsScenarioRoute(_Base): + payload_type: typing.Literal[PayloadType.BLOCK_ACTIONS] + block_action_type: "BlockActionType" + block_action_id: str + + class EventCallbackScenarioRoute(_Base): + payload_type: typing.Literal[PayloadType.EVENT_CALLBACK] + event_type: EventType + + class EventCallbackChannelMessageScenarioRoute(EventCallbackScenarioRoute): + """ + NOTE: the reason why we need to subclass `EventCallbackScenarioRoute` is because in Python 3.11 there is currently + no way to specify keys as optional in a `typing.TypedDict`. See [PEP-692](https://peps.python.org/pep-0692/) which + will implement this typing feature in Python 3.12. + + When we upgrade to 3.12 we should update this type. + """ + + message_channel_type: typing.Literal[EventType.MESSAGE_CHANNEL] + + class InteractiveMessageScenarioRoute(_Base): + payload_type: typing.Literal[PayloadType.INTERACTIVE_MESSAGE] + action_type: "InteractiveMessageActionType" + action_name: str + + class MessageActionScenarioRoute(_Base): + payload_type: typing.Literal[PayloadType.SLASH_COMMAND] + message_action_callback_id: typing.List[str] + + class SlashCommandScenarioRoute(_Base): + payload_type: typing.Literal[PayloadType.SLASH_COMMAND] + command_name: typing.List[str] + + class ViewSubmissionScenarioRoute(_Base): + payload_type: typing.Literal[PayloadType.VIEW_SUBMISSION] + view_callback_id: str + + RoutingStep = ( + BlockActionsScenarioRoute + | EventCallbackScenarioRoute + | EventCallbackChannelMessageScenarioRoute + | InteractiveMessageScenarioRoute + | MessageActionScenarioRoute + | SlashCommandScenarioRoute + | ViewSubmissionScenarioRoute + ) + RoutingSteps = typing.List[RoutingStep] diff --git a/engine/apps/slack/types/views.py b/engine/apps/slack/types/views.py new file mode 100644 index 00000000..7f881a22 --- /dev/null +++ b/engine/apps/slack/types/views.py @@ -0,0 +1,90 @@ +import typing + +from .blocks import Block +from .composition_objects import CompositionObjects + + +class ModalView(typing.TypedDict): + """ + [Documentation](https://api.slack.com/surfaces/modals#view-object-fields) + """ + + type: typing.Literal["modal"] + """ + Required. The type of view. Set to `modal` for modals. + """ + + title: CompositionObjects.PlainText + """ + Required. The title that appears in the top-left of the modal. + + Must be a [plain_text text element](https://api.slack.com/reference/block-kit/composition-objects#text) with a max + length of 24 characters. + """ + + blocks: Block.AnyBlocks + """ + Required. An array of [blocks](https://api.slack.com/reference/block-kit/blocks) that defines the content of the + view. + + Max of 100 blocks. + """ + + close: CompositionObjects.PlainText + """ + An optional [plain_text text element](https://api.slack.com/reference/block-kit/composition-objects#text) that + defines the text displayed in the close button at the bottom-right of the view. + + Max length of 24 characters. + """ + + submit: CompositionObjects.PlainText + """ + An optional [plain_text text element](https://api.slack.com/reference/block-kit/composition-objects#text) that + defines the text displayed in the submit button at the bottom-right of the view. + + `submit` is required when an input block is within the blocks array. + + Max length of 24 characters. + """ + + private_metadata: str + """ + An optional string that will be sent to your app in `view_submission` and `block_actions` events. + + Max length of 3000 characters. + """ + + callback_id: str + """ + An identifier to recognize interactions and submissions of this particular view. Don't use this to store sensitive + information (use `private_metadata` instead). + + Max length of 255 characters. + """ + + clear_on_close: bool + """ + When set to `true`, clicking on the `close` button will clear all views in a modal and close it. + + Defaults to `false`. + """ + + notify_on_close: bool + """ + Indicates whether Slack will send your request URL a `view_closed` event when a user clicks the `close` button. + + Defaults to `false`. + """ + + external_id: str + """ + A custom identifier that must be unique for all views on a per-team basis. + """ + + submit_disabled: bool + """ + When set to `true`, disables the `submit` button until the user has completed one or more inputs. + + This property is for [configuration modals](https://api.slack.com/reference/workflows/configuration-view). + """ diff --git a/engine/apps/slack/urls.py b/engine/apps/slack/urls.py index 5584fd13..4a0630c1 100644 --- a/engine/apps/slack/urls.py +++ b/engine/apps/slack/urls.py @@ -6,7 +6,6 @@ from .views import ( ResetSlackView, SignupRedirectView, SlackEventApiEndpointView, - StopAnalyticsReporting, ) urlpatterns = [ @@ -18,7 +17,6 @@ urlpatterns = [ path("install_redirect///", InstallLinkRedirectView.as_view()), path("signup_redirect/", SignupRedirectView.as_view()), path("signup_redirect///", SignupRedirectView.as_view()), - path("stop_analytics_reporting/", StopAnalyticsReporting.as_view()), # Trailing / is missing here on purpose. QA the feature if you want to add it. No idea why doesn't it work with it. path("reset_slack", ResetSlackView.as_view(), name="reset-slack"), ] diff --git a/engine/apps/slack/utils.py b/engine/apps/slack/utils.py index fb76e677..5d0b7ed7 100644 --- a/engine/apps/slack/utils.py +++ b/engine/apps/slack/utils.py @@ -1,51 +1,14 @@ +import typing from datetime import datetime -from textwrap import wrap from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPIException - -def create_message_blocks(text): - """This function checks text and return blocks - - Maximum length for the text in section is 3000 characters and - we can include up to 50 blocks in each message. - https://api.slack.com/reference/block-kit/blocks#section - - :param str text: Text for message blocks - :return list blocks: Blocks list - """ - - if len(text) <= 3000: - blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] - else: - splitted_text_list = text.split("```\n") - - if len(splitted_text_list) > 1: - splitted_text_list.pop() - - blocks = [] - - for splitted_text in splitted_text_list: - if len(splitted_text) > 2996: - # too long text case - text_list = wrap( - splitted_text, 2994, expand_tabs=False, replace_whitespace=False, break_long_words=False - ) - - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"{text_list[0]}```"}}) - - for text_item in text_list[1:]: - blocks.append( - {"type": "section", "text": {"type": "mrkdwn", "text": f'```{text_item.strip("```")}```'}} - ) - else: - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": splitted_text + "```\n"}}) - - return blocks +if typing.TYPE_CHECKING: + from apps.user_management.models import Organization -def post_message_to_channel(organization, channel_id, text): +def post_message_to_channel(organization: "Organization", channel_id: str, text: str) -> None: if organization.slack_team_identity: slack_client = SlackClientWithErrorHandling(organization.slack_team_identity.bot_access_token) try: @@ -57,15 +20,15 @@ def post_message_to_channel(organization, channel_id, text): raise e -def format_datetime_to_slack(timestamp, format="date_short"): +def format_datetime_to_slack(timestamp: float, format="date_short") -> str: fallback = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M (UTC)") return f"" -def get_cache_key_update_incident_slack_message(alert_group_pk): +def get_cache_key_update_incident_slack_message(alert_group_pk: str) -> str: CACHE_KEY_PREFIX = "update_incident_slack_message" return f"{CACHE_KEY_PREFIX}_{alert_group_pk}" -def get_populate_slack_channel_task_id_key(slack_team_identity_id): +def get_populate_slack_channel_task_id_key(slack_team_identity_id: str) -> str: return f"SLACK_CHANNELS_TASK_ID_TEAM_{slack_team_identity_id}" diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 404b04c8..8d722254 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -2,7 +2,6 @@ import hashlib import hmac import json import logging -import typing from contextlib import suppress from django.conf import settings @@ -29,45 +28,28 @@ from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_RO from apps.slack.scenarios.paging import STEPS_ROUTING as DIRECT_PAGE_ROUTING from apps.slack.scenarios.profile_update import STEPS_ROUTING as PROFILE_UPDATE_ROUTING from apps.slack.scenarios.resolution_note import STEPS_ROUTING as RESOLUTION_NOTE_ROUTING -from apps.slack.scenarios.scenario_step import ( - EVENT_SUBTYPE_BOT_MESSAGE, - EVENT_SUBTYPE_MESSAGE_CHANGED, - EVENT_SUBTYPE_MESSAGE_DELETED, - EVENT_TYPE_APP_MENTION, - EVENT_TYPE_MESSAGE, - EVENT_TYPE_MESSAGE_CHANNEL, - EVENT_TYPE_SUBTEAM_CREATED, - EVENT_TYPE_SUBTEAM_MEMBERS_CHANGED, - EVENT_TYPE_SUBTEAM_UPDATED, - EVENT_TYPE_USER_CHANGE, - EVENT_TYPE_USER_PROFILE_CHANGED, - PAYLOAD_TYPE_BLOCK_ACTIONS, - PAYLOAD_TYPE_DIALOG_SUBMISSION, - PAYLOAD_TYPE_EVENT_CALLBACK, - PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - PAYLOAD_TYPE_MESSAGE_ACTION, - PAYLOAD_TYPE_SLASH_COMMAND, - PAYLOAD_TYPE_VIEW_SUBMISSION, - ScenarioStep, -) +from apps.slack.scenarios.scenario_step import ScenarioStep from apps.slack.scenarios.schedules import STEPS_ROUTING as SCHEDULES_ROUTING +from apps.slack.scenarios.shift_swap_requests import STEPS_ROUTING as SHIFT_SWAP_REQUESTS_ROUTING from apps.slack.scenarios.slack_channel import STEPS_ROUTING as CHANNEL_ROUTING from apps.slack.scenarios.slack_channel_integration import STEPS_ROUTING as SLACK_CHANNEL_INTEGRATION_ROUTING from apps.slack.scenarios.slack_usergroup import STEPS_ROUTING as SLACK_USERGROUP_UPDATE_ROUTING from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException from apps.slack.tasks import clean_slack_integration_leftovers, unpopulate_slack_user_identities +from apps.slack.types import EventPayload, EventType, MessageEventSubtype, PayloadType, ScenarioRoute from apps.user_management.models import Organization from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log from common.oncall_gateway import delete_slack_connector from .models import SlackMessage, SlackTeamIdentity, SlackUserIdentity -SCENARIOS_ROUTES = [] # Add all other routes here +SCENARIOS_ROUTES: ScenarioRoute.RoutingSteps = [] SCENARIOS_ROUTES.extend(ONBOARDING_STEPS_ROUTING) SCENARIOS_ROUTES.extend(DISTRIBUTION_STEPS_ROUTING) SCENARIOS_ROUTES.extend(INVITED_TO_CHANNEL_ROUTING) SCENARIOS_ROUTES.extend(SCHEDULES_ROUTING) +SCENARIOS_ROUTES.extend(SHIFT_SWAP_REQUESTS_ROUTING) SCENARIOS_ROUTES.extend(SLACK_CHANNEL_INTEGRATION_ROUTING) SCENARIOS_ROUTES.extend(ALERTGROUP_APPEARANCE_ROUTING) SCENARIOS_ROUTES.extend(RESOLUTION_NOTE_ROUTING) @@ -83,16 +65,6 @@ SCENARIOS_ROUTES.extend(NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING) logger = logging.getLogger(__name__) -class StopAnalyticsReporting(APIView): - def get(self, request): - response = HttpResponse( - "Your app installation would not be tracked by analytics from backend, " - "use browser plugin to disable from a frontend side. " - ) - response.set_cookie("no_track", True, max_age=10 * 360 * 24 * 60 * 60) - return response - - class InstallLinkRedirectView(APIView): def get(self, request, subscription="free", utm="not_specified"): return HttpResponse(("Sign up is not allowed"), status=status.HTTP_400_BAD_REQUEST) @@ -164,7 +136,7 @@ class SlackEventApiEndpointView(APIView): payload["amixr_slack_retries"] = request.META["HTTP_X_SLACK_RETRY_NUM"] payload_type = payload.get("type") - payload_type_is_block_actions = payload_type == PAYLOAD_TYPE_BLOCK_ACTIONS + payload_type_is_block_actions = payload_type == PayloadType.BLOCK_ACTIONS payload_command = payload.get("command") payload_callback_id = payload.get("callback_id") payload_actions = payload.get("actions", []) @@ -244,9 +216,7 @@ class SlackEventApiEndpointView(APIView): raise Exception("Failed Linking user identity") elif ( - payload_event_bot_id - and slack_team_identity - and payload_event_channel_type == EVENT_TYPE_MESSAGE_CHANNEL + payload_event_bot_id and slack_team_identity and payload_event_channel_type == EventType.MESSAGE_CHANNEL ): response = sc.api_call("bots.info", bot=payload_event_bot_id) bot_user_id = response.get("bot", {}).get("user_id", "") @@ -278,14 +248,14 @@ class SlackEventApiEndpointView(APIView): logger.info("SlackUserIdentity detected: " + str(slack_user_identity)) if not slack_user_identity: - if payload_type == PAYLOAD_TYPE_EVENT_CALLBACK: + if payload_type == PayloadType.EVENT_CALLBACK: if payload_event_type in [ - EVENT_TYPE_SUBTEAM_CREATED, - EVENT_TYPE_SUBTEAM_UPDATED, - EVENT_TYPE_SUBTEAM_MEMBERS_CHANGED, + EventType.SUBTEAM_CREATED, + EventType.SUBTEAM_UPDATED, + EventType.SUBTEAM_MEMBERS_CHANGED, ]: logger.info("Slack event without user slack_id.") - elif payload_event_type in (EVENT_TYPE_USER_CHANGE, EVENT_TYPE_USER_PROFILE_CHANGED): + elif payload_event_type in (EventType.USER_CHANGE, EventType.USER_PROFILE_CHANGED): logger.info( f"Event {payload_event_type}. Dropping request because it does not have SlackUserIdentity." ) @@ -323,20 +293,20 @@ class SlackEventApiEndpointView(APIView): return Response(status=200) # Capture cases when we expect stateful message from user - if payload_type == PAYLOAD_TYPE_EVENT_CALLBACK: + if payload_type == PayloadType.EVENT_CALLBACK: event_type = payload_event_type # Message event is from channel if ( - event_type == EVENT_TYPE_MESSAGE - and payload_event_channel_type == EVENT_TYPE_MESSAGE_CHANNEL + event_type == EventType.MESSAGE + and payload_event_channel_type == EventType.MESSAGE_CHANNEL and ( not payload_event_subtype or payload_event_subtype in [ - EVENT_SUBTYPE_BOT_MESSAGE, - EVENT_SUBTYPE_MESSAGE_CHANGED, - EVENT_SUBTYPE_MESSAGE_DELETED, + MessageEventSubtype.BOT_MESSAGE, + MessageEventSubtype.MESSAGE_CHANGED, + MessageEventSubtype.MESSAGE_DELETED, ] ) ): @@ -348,8 +318,8 @@ class SlackEventApiEndpointView(APIView): step.process_scenario(slack_user_identity, slack_team_identity, payload) step_was_found = True # We don't do anything on app mention, but we doesn't want to unsubscribe from this event yet. - if event_type == EVENT_TYPE_APP_MENTION: - logger.info(f"Received event of type {EVENT_TYPE_APP_MENTION} from slack. Skipping.") + if event_type == EventType.APP_MENTION: + logger.info(f"Received event of type {EventType.APP_MENTION} from slack. Skipping.") return Response(status=200) # Routing to Steps based on routing rules @@ -358,7 +328,7 @@ class SlackEventApiEndpointView(APIView): route_payload_type = route["payload_type"] # Slash commands have to "type" - if payload_command and route_payload_type == PAYLOAD_TYPE_SLASH_COMMAND: + if payload_command and route_payload_type == PayloadType.SLASH_COMMAND: if payload_command in route["command_name"]: Step = route["step"] logger.info("Routing to {}".format(Step)) @@ -367,7 +337,7 @@ class SlackEventApiEndpointView(APIView): step_was_found = True if payload_type == route_payload_type: - if payload_type == PAYLOAD_TYPE_EVENT_CALLBACK: + if payload_type == PayloadType.EVENT_CALLBACK: if payload_event_type == route["event_type"]: # event_name is used for stateful if "event_name" not in route: @@ -377,7 +347,7 @@ class SlackEventApiEndpointView(APIView): step.process_scenario(slack_user_identity, slack_team_identity, payload) step_was_found = True - if payload_type == PAYLOAD_TYPE_INTERACTIVE_MESSAGE: + if payload_type == PayloadType.INTERACTIVE_MESSAGE: for action in payload_actions: if action["type"] == route["action_type"]: # Action name may also contain action arguments. @@ -401,7 +371,7 @@ class SlackEventApiEndpointView(APIView): step.process_scenario(slack_user_identity, slack_team_identity, payload) step_was_found = True - if payload_type == PAYLOAD_TYPE_DIALOG_SUBMISSION: + if payload_type == PayloadType.DIALOG_SUBMISSION: if payload_callback_id == route["dialog_callback_id"]: Step = route["step"] logger.info("Routing to {}".format(Step)) @@ -411,7 +381,7 @@ class SlackEventApiEndpointView(APIView): return result step_was_found = True - if payload_type == PAYLOAD_TYPE_VIEW_SUBMISSION: + if payload_type == PayloadType.VIEW_SUBMISSION: if payload["view"]["callback_id"].startswith(route["view_callback_id"]): Step = route["step"] logger.info("Routing to {}".format(Step)) @@ -421,7 +391,7 @@ class SlackEventApiEndpointView(APIView): return result step_was_found = True - if payload_type == PAYLOAD_TYPE_MESSAGE_ACTION: + if payload_type == PayloadType.MESSAGE_ACTION: if payload_callback_id in route["message_action_callback_id"]: Step = route["step"] logger.info("Routing to {}".format(Step)) @@ -435,7 +405,7 @@ class SlackEventApiEndpointView(APIView): return Response(status=200) @staticmethod - def _get_slack_team_identity_from_payload(payload: dict[str, typing.Any]) -> SlackTeamIdentity | None: + def _get_slack_team_identity_from_payload(payload: EventPayload.Any) -> SlackTeamIdentity | None: def _slack_team_id() -> str | None: with suppress(KeyError): return payload["team"]["id"] @@ -452,7 +422,7 @@ class SlackEventApiEndpointView(APIView): @staticmethod def _get_organization_from_payload( - payload: dict[str, typing.Any], slack_team_identity: SlackTeamIdentity + payload: EventPayload.Any, slack_team_identity: SlackTeamIdentity ) -> Organization | None: """ Extract organization from Slack payload. @@ -521,7 +491,9 @@ class SlackEventApiEndpointView(APIView): return None - def _open_warning_window_if_needed(self, payload, slack_team_identity, warning_text) -> None: + def _open_warning_window_if_needed( + self, payload: EventPayload.Any, slack_team_identity: SlackTeamIdentity, warning_text: str + ) -> None: if payload.get("trigger_id") is not None: step = ScenarioStep(slack_team_identity) try: @@ -531,7 +503,9 @@ class SlackEventApiEndpointView(APIView): f"Failed to open pop-up for unpopulated SlackTeamIdentity {slack_team_identity.pk}\n" f"Error: {e}" ) - def _open_warning_for_unconnected_user(self, slack_client, payload): + def _open_warning_for_unconnected_user( + self, slack_client: SlackClientWithErrorHandling, payload: EventPayload.Any + ) -> None: if payload.get("trigger_id") is None: return diff --git a/engine/apps/user_management/migrations/0014_auto_20230728_0802.py b/engine/apps/user_management/migrations/0014_auto_20230728_0802.py new file mode 100644 index 00000000..967c7962 --- /dev/null +++ b/engine/apps/user_management/migrations/0014_auto_20230728_0802.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.20 on 2023-07-28 08:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0029_auto_20230728_0802'), + ('user_management', '0013_alter_organization_acknowledge_remind_timeout'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='current_team', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_team_users', to='user_management.team'), + ), + migrations.AlterField( + model_name='user', + name='notification', + field=models.ManyToManyField(related_name='users', through='alerts.UserHasNotification', to='alerts.AlertGroup'), + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 94d3f2a5..f64476d2 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -26,7 +26,8 @@ if typing.TYPE_CHECKING: ) from apps.mobile_app.models import MobileAppAuthToken from apps.schedules.models import OnCallSchedule - from apps.user_management.models import User + from apps.slack.models import SlackTeamIdentity + from apps.user_management.models import Region, Team, User logger = logging.getLogger(__name__) @@ -77,14 +78,17 @@ class OrganizationManager(models.Manager): # class Organization(models.Model): class Organization(MaintainableObject): auth_tokens: "RelatedManager['ApiAuthToken']" + migration_destination: typing.Optional["Region"] mobile_app_auth_tokens: "RelatedManager['MobileAppAuthToken']" oncall_schedules: "RelatedManager['OnCallSchedule']" plugin_auth_tokens: "RelatedManager['PluginAuthToken']" schedule_export_token: "RelatedManager['ScheduleExportAuthToken']" + slack_team_identity: typing.Optional["SlackTeamIdentity"] + teams: "RelatedManager['Team']" user_schedule_export_token: "RelatedManager['UserScheduleExportAuthToken']" users: "RelatedManager['User']" - objects = OrganizationManager() + objects: models.Manager["Organization"] = OrganizationManager() objects_with_deleted = models.Manager() def __init__(self, *args, **kwargs): diff --git a/engine/apps/user_management/models/team.py b/engine/apps/user_management/models/team.py index 3393bfdc..4768e1b1 100644 --- a/engine/apps/user_management/models/team.py +++ b/engine/apps/user_management/models/team.py @@ -12,6 +12,7 @@ if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager from apps.alerts.models import AlertGroupLogRecord + from apps.user_management.models import User def generate_public_primary_key_for_team(): @@ -83,7 +84,9 @@ class TeamManager(models.Manager): class Team(models.Model): + current_team_users: "RelatedManager['User']" oncall_schedules: "RelatedManager['AlertGroupLogRecord']" + users: "RelatedManager['User']" public_primary_key = models.CharField( max_length=20, @@ -92,7 +95,7 @@ class Team(models.Model): default=generate_public_primary_key_for_team, ) - objects = TeamManager() + objects: models.Manager["Team"] = TeamManager() team_id = models.PositiveIntegerField() organization = models.ForeignKey( diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index cafeb6d9..e6579a9f 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -23,8 +23,11 @@ from common.public_primary_keys import generate_public_primary_key, increase_pub if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager - from apps.alerts.models import EscalationPolicy + from apps.alerts.models import AlertGroup, EscalationPolicy from apps.auth_token.models import ApiAuthToken, ScheduleExportAuthToken, UserScheduleExportAuthToken + from apps.base.models import UserNotificationPolicy + from apps.slack.models import SlackUserIdentity + from apps.user_management.models import Organization, Team logger = logging.getLogger(__name__) @@ -142,13 +145,21 @@ class UserQuerySet(models.QuerySet): class User(models.Model): + acknowledged_alert_groups: "RelatedManager['AlertGroup']" auth_tokens: "RelatedManager['ApiAuthToken']" + current_team: typing.Optional["Team"] escalation_policy_notify_queues: "RelatedManager['EscalationPolicy']" last_notified_in_escalation_policies: "RelatedManager['EscalationPolicy']" + notification_policies: "RelatedManager['UserNotificationPolicy']" + organization: "Organization" + resolved_alert_groups: "RelatedManager['AlertGroup']" schedule_export_token: "RelatedManager['ScheduleExportAuthToken']" + silenced_alert_groups: "RelatedManager['AlertGroup']" + slack_user_identity: typing.Optional["SlackUserIdentity"] user_schedule_export_token: "RelatedManager['UserScheduleExportAuthToken']" + wiped_alert_groups: "RelatedManager['AlertGroup']" - objects = UserManager.from_queryset(UserQuerySet)() + objects: models.Manager["User"] = UserManager.from_queryset(UserQuerySet)() class Meta: # For some reason there are cases when Grafana user gets deleted, @@ -166,7 +177,9 @@ class User(models.Model): user_id = models.PositiveIntegerField() organization = models.ForeignKey(to="user_management.Organization", on_delete=models.CASCADE, related_name="users") - current_team = models.ForeignKey(to="user_management.Team", null=True, default=None, on_delete=models.SET_NULL) + current_team = models.ForeignKey( + to="user_management.Team", null=True, default=None, on_delete=models.SET_NULL, related_name="current_team_users" + ) email = models.EmailField() name = models.CharField(max_length=300) @@ -178,7 +191,9 @@ class User(models.Model): _timezone = models.CharField(max_length=50, null=True, default=None) working_hours = models.JSONField(null=True, default=default_working_hours) - notification = models.ManyToManyField("alerts.AlertGroup", through="alerts.UserHasNotification") + notification = models.ManyToManyField( + "alerts.AlertGroup", through="alerts.UserHasNotification", related_name="users" + ) unverified_phone_number = models.CharField(max_length=20, null=True, default=None) _verified_phone_number = models.CharField(max_length=20, null=True, default=None) diff --git a/engine/conftest.py b/engine/conftest.py index f9a2d530..82cd197b 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -1,3 +1,4 @@ +import datetime import json import os import sys @@ -9,6 +10,7 @@ import pytest from celery import Task from django.db.models.signals import post_save from django.urls import clear_url_caches +from django.utils import timezone from pytest_factoryboy import register from rest_framework.test import APIClient from telegram import Bot @@ -59,6 +61,7 @@ from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToke from apps.phone_notifications.phone_backend import PhoneBackend from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider +from apps.schedules.models import OnCallScheduleWeb from apps.schedules.tests.factories import ( CustomOnCallShiftFactory, OnCallScheduleCalendarFactory, @@ -391,8 +394,8 @@ def make_slack_user_identity(): @pytest.fixture def make_slack_message(): - def _make_slack_message(alert_group, **kwargs): - organization = alert_group.channel.organization + def _make_slack_message(alert_group=None, organization=None, **kwargs): + organization = organization or alert_group.channel.organization slack_message = SlackMessageFactory( alert_group=alert_group, organization=organization, @@ -885,3 +888,22 @@ def make_shift_swap_request(): return ShiftSwapRequestFactory(schedule=schedule, beneficiary=beneficiary, **kwargs) return _make_shift_swap_request + + +@pytest.fixture +def shift_swap_request_setup( + make_schedule, make_organization_and_user, make_user_for_organization, make_shift_swap_request +): + def _shift_swap_request_setup(**kwargs): + organization, beneficiary = make_organization_and_user() + benefactor = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + tomorrow = timezone.now() + datetime.timedelta(days=1) + two_days_from_now = tomorrow + datetime.timedelta(days=1) + + ssr = make_shift_swap_request(schedule, beneficiary, swap_start=tomorrow, swap_end=two_days_from_now, **kwargs) + + return ssr, beneficiary, benefactor + + return _shift_swap_request_setup diff --git a/engine/settings/prod_without_db.py b/engine/settings/prod_without_db.py index da96f94f..64c5896f 100644 --- a/engine/settings/prod_without_db.py +++ b/engine/settings/prod_without_db.py @@ -74,6 +74,8 @@ CELERY_TASK_ROUTES = { "apps.schedules.tasks.notify_about_empty_shifts_in_schedule.start_notify_about_empty_shifts_in_schedule": { "queue": "default" }, + "apps.schedules.tasks.shift_swaps.slack_messages.post_shift_swap_request_creation_message": {"queue": "default"}, + "apps.schedules.tasks.shift_swaps.slack_messages.update_shift_swap_request_message": {"queue": "default"}, # CRITICAL "apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"}, "apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"}, From 97757f570e4ff2cf1662038f6e77a10f70965037 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Fri, 28 Jul 2023 18:29:00 +0100 Subject: [PATCH 03/13] Fix `alerts.0028` migration for SQLite (#2680) # What this PR does Fixes an issue with [alerts.0028](https://github.com/grafana/oncall/blob/f77a54b518ab8f1f42e33ca43328b75fde1903b9/engine/apps/alerts/migrations/0028_drop_alertreceivechannel_restricted_at.py) migration failing on SQLite with the following error: `sqlite3.OperationalError: near "DROP": syntax error`. The issue is fixed by updating the SQLite version from `3.27.2` to `3.40.1` (SQLite `3.35.0` introduced native support for dropping columns as per this [SO answer](https://stackoverflow.com/a/66399224)). However, I couldn't find an easy way to independently update SQLite, since it's bundled into Python's standard library. Updating the Docker image to use the latest Debian stable release fixes the issue as it already comes with SQLite `3.40.1` out of the box. So this PR effectively bumps the Debian version from 10 to 12, and bumps the Python version from `3.11.3` to `3.11.4`. ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 4 ++++ engine/Dockerfile | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea6c4ad..8c6adfad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- Fix one of the latest migrations failing on SQLite by @vadimkerr ([#2680](https://github.com/grafana/oncall/pull/2680)) + ## v1.3.18 (2023-07-28) ### Changed diff --git a/engine/Dockerfile b/engine/Dockerfile index fb4566f0..9ba6b4e0 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.3-slim-buster AS base +FROM python:3.11.4-slim-bookworm AS base # Create a group and user to run an app ENV APP_USER=appuser @@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y \ gcc \ libmariadb-dev \ libpq-dev \ - netcat \ + netcat-traditional \ curl \ bash \ git \ From c5ec4093b25419e35908819d8b05499dbd10c17d Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Fri, 28 Jul 2023 18:34:21 +0100 Subject: [PATCH 04/13] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6adfad..54c9d785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## Unreleased +## v1.3.19 (2023-07-28) ### Fixed From ddd98e0c3fb43e4469a41a6dacf6655d1436002e Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 28 Jul 2023 15:53:27 -0300 Subject: [PATCH 05/13] Apply shift swap requests to schedule events (#2677) Reflect swap requests details in schedule events. --- CHANGELOG.md | 4 + engine/apps/schedules/ical_utils.py | 58 +-- .../apps/schedules/models/on_call_schedule.py | 107 ++++- .../schedules/models/shift_swap_request.py | 8 +- .../tests/test_custom_on_call_shift.py | 5 +- .../schedules/tests/test_on_call_schedule.py | 411 ++++++++++++++++++ .../tests/test_shift_swap_request.py | 14 +- 7 files changed, 546 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c9d785..c970cb98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix one of the latest migrations failing on SQLite by @vadimkerr ([#2680](https://github.com/grafana/oncall/pull/2680)) +### Added + +- Apply swap requests details to schedule events ([#2677](https://github.com/grafana/oncall/pull/2677)) + ## v1.3.18 (2023-07-28) ### Changed diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 72285e71..0bedb986 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -61,8 +61,7 @@ IcalEvents = typing.List[IcalEvent] def users_in_ical( usernames_from_ical: typing.List[str], organization: "Organization", - users_to_filter: typing.Optional["UserQuerySet"] = None, -) -> typing.Sequence["User"]: +) -> "UserQuerySet": """ This method returns a sequence of `User` objects, filtered by users whose username, or case-insensitive e-mail, is present in `usernames_from_ical`. If `include_viewers` is set to `True`, users are further filtered down @@ -74,25 +73,11 @@ def users_in_ical( A list of usernames present in the ical feed organization : apps.user_management.models.organization.Organization The organization in question - include_viewers : bool - Whether or not the list should be further filtered to exclude users based on granted permissions - users_to_filter : typing.Optional[UserQuerySet] - Filter users without making SQL queries if users_to_filter arg is provided - users_to_filter is passed in `apps.schedules.ical_utils.get_oncall_users_for_multiple_schedules` """ from apps.user_management.models import User emails_from_ical = [username.lower() for username in usernames_from_ical] - if users_to_filter is not None: - return list( - { - user - for user in users_to_filter - if user.username in usernames_from_ical or user.email.lower() in emails_from_ical - } - ) - # users_found_in_ical = organization.users users_found_in_ical = organization.users.filter( **User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization) @@ -106,9 +91,7 @@ def users_in_ical( @timed_lru_cache(timeout=100) -def memoized_users_in_ical( - usernames_from_ical: typing.List[str], organization: "Organization" -) -> typing.Sequence["User"]: +def memoized_users_in_ical(usernames_from_ical: typing.List[str], organization: "Organization") -> UserQuerySet: # using in-memory cache instead of redis to avoid pickling python objects return users_in_ical(usernames_from_ical, organization) @@ -336,7 +319,6 @@ def list_of_empty_shifts_in_schedule( def list_users_to_notify_from_ical( schedule: "OnCallSchedule", events_datetime: typing.Optional[datetime.datetime] = None, - users_to_filter: typing.Optional["UserQuerySet"] = None, ) -> typing.Sequence["User"]: """ Retrieve on-call users for the current time @@ -346,7 +328,6 @@ def list_users_to_notify_from_ical( schedule, events_datetime, events_datetime, - users_to_filter=users_to_filter, ) @@ -354,23 +335,20 @@ def list_users_to_notify_from_ical_for_period( schedule: "OnCallSchedule", start_datetime: datetime.datetime, end_datetime: datetime.datetime, - users_to_filter=None, -) -> typing.Sequence["User"]: +) -> UserQuerySet: users_found_in_ical: typing.Sequence["User"] = [] events = schedule.final_events(start_datetime, end_datetime) usernames = [] for event in events: usernames += [u["email"] for u in event.get("users", [])] - users_found_in_ical = users_in_ical(usernames, schedule.organization, users_to_filter=users_to_filter) + users_found_in_ical = users_in_ical(usernames, schedule.organization) return users_found_in_ical def get_oncall_users_for_multiple_schedules( schedules: "OnCallScheduleQuerySet", events_datetime=None -) -> typing.Dict["OnCallSchedule", typing.List[User]]: - from apps.user_management.models import User - +) -> typing.Dict["OnCallSchedule", UserQuerySet]: if events_datetime is None: events_datetime = datetime.datetime.now(timezone.utc) @@ -378,35 +356,11 @@ def get_oncall_users_for_multiple_schedules( if not schedules.exists(): return {} - # Assume all schedules from the queryset belong to the same organization - organization = schedules[0].organization - - # Gather usernames from all events from all schedules - usernames = set() - for schedule in schedules.all(): - calendars = schedule.get_icalendars() - for calendar in calendars: - if calendar is None: - continue - events = ical_events.get_events_from_ical_between(calendar, events_datetime, events_datetime) - for event in events: - current_usernames, _ = get_usernames_from_ical_event(event) - usernames.update(current_usernames) - - # Fetch relevant users from the db - emails = [username.lower() for username in usernames] - users = organization.users.filter( - Q(**User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization)) - & (Q(username__in=usernames) | Q(email__lower__in=emails)) - ) - # Get on-call users oncall_users = {} for schedule in schedules.all(): # pass user list to list_users_to_notify_from_ical - schedule_oncall_users = list_users_to_notify_from_ical( - schedule, events_datetime=events_datetime, users_to_filter=users - ) + schedule_oncall_users = list_users_to_notify_from_ical(schedule, events_datetime=events_datetime) oncall_users.update({schedule.pk: schedule_oncall_users}) return oncall_users diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index ade4da18..f06d3996 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -1,3 +1,4 @@ +import copy import datetime import itertools import re @@ -92,6 +93,15 @@ class ScheduleEventUser(typing.TypedDict): avatar_full: str +class SwapRequest(typing.TypedDict): + pk: str + user: typing.Optional[ScheduleEventUser] + + +class MaybeSwappedScheduleEventUser(ScheduleEventUser): + swap_request: typing.Optional[SwapRequest] + + class ScheduleEventShift(typing.TypedDict): pk: str @@ -100,7 +110,7 @@ class ScheduleEvent(typing.TypedDict): all_day: bool start: datetime.datetime end: datetime.datetime - users: typing.List[ScheduleEventUser] + users: typing.List[MaybeSwappedScheduleEventUser] missing_users: typing.List[str] priority_level: typing.Optional[int] source: typing.Optional[str] @@ -379,7 +389,12 @@ class OnCallSchedule(PolymorphicModel): events.append(shift_json) # combine multiple-users same-shift events into one - return self._merge_events(events) + events = self._merge_events(events) + + # annotate events with swap request details swapping users as needed + events = self._apply_swap_requests(events, datetime_start, datetime_end) + + return events def final_events(self, datetime_start, datetime_end): """Return schedule final events, after resolving shifts and overrides.""" @@ -601,6 +616,94 @@ class OnCallSchedule(PolymorphicModel): "overloaded_users": overloaded_users, } + def _apply_swap_requests(self, events, datetime_start, datetime_end) -> ScheduleEvents: + """Apply swap requests details to schedule events.""" + # get swaps requests affecting this schedule / time range + swaps = self.shift_swap_requests.filter( # starting before but ongoing + swap_start__lt=datetime_start, swap_end__gte=datetime_start + ).union( + self.shift_swap_requests.filter( # starting after but before end + swap_start__gte=datetime_start, swap_start__lte=datetime_end + ) + ) + swaps = swaps.order_by("created_at") + + def _insert_event(index, event): + # add event, if any, to events list in the specified index + # return incremented index if the event was added + if event is None: + return index + events.insert(index, event) + return index + 1 + + # apply swaps sequentially + for swap in swaps: + i = 0 + while i < len(events): + event = events.pop(i) + + if event["start"] > swap.swap_end or event["end"] < swap.swap_start: + # event outside the swap period, keep as it is and continue + i = _insert_event(i, event) + continue + + users = set(u["pk"] for u in event["users"]) + if swap.beneficiary.public_primary_key in users: + # swap request affects current event + + split_before = None + if event["start"] < swap.swap_start: + # partially included start -> split + split_before = copy.deepcopy(event) + split_before["end"] = swap.swap_start + # update event to swap + event["start"] = swap.swap_start + + split_after = None + if event["end"] > swap.swap_end: + # partially included end -> split + split_after = copy.deepcopy(event) + split_after["start"] = swap.swap_end + # update event to swap + event["end"] = swap.swap_end + + # identify user to swap + user_to_swap = None + for u in event["users"]: + if u["pk"] == swap.beneficiary.public_primary_key: + user_to_swap = u + break + + # apply swap changes to event user + swap_details = {"pk": swap.public_primary_key} + if swap.benefactor: + # swap is taken, update user in shift + user_to_swap["pk"] = swap.benefactor.public_primary_key + user_to_swap["display_name"] = swap.benefactor.username + user_to_swap["email"] = swap.benefactor.email + user_to_swap["avatar_full"] = swap.benefactor.avatar_full_url + # add beneficiary user to details + swap_details["user"] = { + "display_name": swap.beneficiary.username, + "email": swap.beneficiary.email, + "pk": swap.beneficiary.public_primary_key, + "avatar_full": swap.beneficiary.avatar_full_url, + } + user_to_swap["swap_request"] = swap_details + + # update events list + # keep first split event in its original index + i = _insert_event(i, split_before) + # insert updated swap-related event + i = _insert_event(i, event) + # keep second split event after swap + i = _insert_event(i, split_after) + else: + # event for different user(s), keep as it is and continue + i = _insert_event(i, event) + + return events + def _resolve_schedule( self, events: ScheduleEvents, datetime_start: datetime.datetime, datetime_end: datetime.datetime ) -> ScheduleEvents: diff --git a/engine/apps/schedules/models/shift_swap_request.py b/engine/apps/schedules/models/shift_swap_request.py index f7ce7997..07ccd247 100644 --- a/engine/apps/schedules/models/shift_swap_request.py +++ b/engine/apps/schedules/models/shift_swap_request.py @@ -7,6 +7,7 @@ from django.db import models from django.utils import timezone from apps.schedules import exceptions +from apps.schedules.tasks import refresh_ical_final_schedule from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: @@ -156,9 +157,13 @@ class ShiftSwapRequest(models.Model): def delete(self): self.deleted_at = timezone.now() self.save() + # make sure final schedule ical representation is updated + refresh_ical_final_schedule.apply_async((self.schedule.pk,)) def hard_delete(self): super().delete() + # make sure final schedule ical representation is updated + refresh_ical_final_schedule.apply_async((self.schedule.pk,)) def take(self, benefactor: "User") -> None: if benefactor == self.beneficiary: @@ -169,7 +174,8 @@ class ShiftSwapRequest(models.Model): self.benefactor = benefactor self.save() - # TODO: implement the actual override logic in https://github.com/grafana/oncall/issues/2590 + # make sure final schedule ical representation is updated + refresh_ical_final_schedule.apply_async((self.schedule.pk,)) # Insight logs @property diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index 4cd436f1..4f872bb7 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -1297,7 +1297,7 @@ def test_get_oncall_users_for_empty_schedule( schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) schedules = OnCallSchedule.objects.filter(pk=schedule.pk) - assert schedules.get_oncall_users()[schedule.pk] == [] + assert list(schedules.get_oncall_users()[schedule.pk]) == [] @pytest.mark.django_db @@ -1412,7 +1412,8 @@ def test_get_oncall_users_for_multiple_schedules_emails_case_insensitive( schedules = OnCallSchedule.objects.filter(pk=schedule.pk) oncall_users = schedules.get_oncall_users(events_datetime=events_datetime) - assert oncall_users == {schedule.pk: [user]} + assert len(oncall_users) == 1 + assert list(oncall_users[schedule.pk]) == [user] @pytest.mark.django_db diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 6b8e61ef..1e56761b 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -1837,3 +1837,414 @@ def test_event_until_non_utc(make_organization, make_schedule): # check this works without raising exception datetime_end = now + timezone.timedelta(days=7) schedule.final_events(now, datetime_end) + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_split_start( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=13), + swap_end=tomorrow + timezone.timedelta(hours=18), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), False), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=3), + True, + ), # second split + ] + returned = [(e["start"], e["end"], bool(e["users"][0].get("swap_request", False))) for e in events] + assert returned == expected + # check swap request details + assert events[2]["users"][0]["swap_request"]["pk"] == swap_request.public_primary_key + if swap_taken: + assert events[2]["users"][0]["pk"] == other_user.public_primary_key + assert events[2]["users"][0]["swap_request"]["user"]["pk"] == user.public_primary_key + else: + assert events[2]["users"][0]["pk"] == user.public_primary_key + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_split_end( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=10), + swap_end=tomorrow + timezone.timedelta(hours=13), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), True), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=3), + False, + ), # second split + ] + returned = [(e["start"], e["end"], bool(e["users"][0].get("swap_request", False))) for e in events] + assert returned == expected + # check swap request details + assert events[1]["users"][0]["swap_request"]["pk"] == swap_request.public_primary_key + if swap_taken: + assert events[1]["users"][0]["pk"] == other_user.public_primary_key + assert events[1]["users"][0]["swap_request"]["user"]["pk"] == user.public_primary_key + else: + assert events[1]["users"][0]["pk"] == user.public_primary_key + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_split_both( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=13), + swap_end=tomorrow + timezone.timedelta(hours=14), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), False), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=2), + True, + ), # second split + ( + start + timezone.timedelta(days=1, hours=2), + start + timezone.timedelta(days=1, hours=3), + False, + ), # third split + ] + returned = [(e["start"], e["end"], bool(e["users"][0].get("swap_request", False))) for e in events] + assert returned == expected + # check swap request details + assert events[2]["users"][0]["swap_request"]["pk"] == swap_request.public_primary_key + if swap_taken: + assert events[2]["users"][0]["pk"] == other_user.public_primary_key + assert events[2]["users"][0]["swap_request"]["user"]["pk"] == user.public_primary_key + else: + assert events[2]["users"][0]["pk"] == user.public_primary_key + + # check cached final schedule reflects swap + # force final schedule export to consider 2 days only + with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_AFTER", 2): + with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_BEFORE", 0): + schedule.refresh_ical_final_schedule() + assert schedule.cached_ical_final_schedule + expected_events = [ + # start, end, user + (start, start + duration, user.username), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), user.username), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=2), + other_user.username if swap_taken else user.username, + ), # second split + ( + start + timezone.timedelta(days=1, hours=2), + start + timezone.timedelta(days=1, hours=3), + user.username, + ), # third split + ] + calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule) + for component in calendar.walk(): + if component.name == ICAL_COMPONENT_VEVENT: + event = ( + component[ICAL_DATETIME_START].dt, + component[ICAL_DATETIME_END].dt, + component[ICAL_SUMMARY], + ) + assert event in expected_events + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_whole_shift( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=12), + swap_end=tomorrow + timezone.timedelta(hours=15), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=3), True), # no splits + ] + returned = [(e["start"], e["end"], bool(e["users"][0].get("swap_request", False))) for e in events] + assert returned == expected + # check swap request details + assert events[1]["users"][0]["swap_request"]["pk"] == swap_request.public_primary_key + if swap_taken: + assert events[1]["users"][0]["pk"] == other_user.public_primary_key + assert events[1]["users"][0]["swap_request"]["user"]["pk"] == user.public_primary_key + else: + assert events[1]["users"][0]["pk"] == user.public_primary_key + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_partial_replace( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + another_user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user, another_user]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=10), + swap_end=tomorrow + timezone.timedelta(hours=13), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), True), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=3), + False, + ), # second split + ] + expected_user = user + if swap_taken: + expected_user = other_user + returned = [ + ( + e["start"], + e["end"], + bool([u for u in e["users"] if u["pk"] == expected_user.public_primary_key and u.get("swap_request")]), + ) + for e in events + ] + assert returned == expected + # check swap request details + user_pks = [u["pk"] for u in events[1]["users"]] + assert expected_user.public_primary_key in user_pks + if swap_taken: + for u in events[1]["users"]: + if u["pk"] == expected_user: + assert u["swap_request"]["pk"] == swap_request.public_primary_key + assert u["swap_request"]["user"]["pk"] == user.public_primary_key + + +@pytest.mark.django_db +def test_swap_request_no_changes( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + events_before = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + # setup swap requests + tomorrow = today + timezone.timedelta(days=1) + make_shift_swap_request(schedule, other_user, swap_start=today, swap_end=tomorrow) + make_shift_swap_request(schedule, user, swap_start=today, swap_end=tomorrow, deleted_at=today) + make_shift_swap_request( + schedule, user, swap_start=today - timezone.timedelta(days=7), swap_end=tomorrow - timezone.timedelta(days=7) + ) + + events_after = schedule.filter_events(today, today + timezone.timedelta(days=2)) + assert events_before == events_after diff --git a/engine/apps/schedules/tests/test_shift_swap_request.py b/engine/apps/schedules/tests/test_shift_swap_request.py index 68c187ad..9c08dbed 100644 --- a/engine/apps/schedules/tests/test_shift_swap_request.py +++ b/engine/apps/schedules/tests/test_shift_swap_request.py @@ -1,4 +1,5 @@ import datetime +from unittest.mock import patch import pytest @@ -10,11 +11,15 @@ from apps.schedules.models import ShiftSwapRequest def test_soft_delete(shift_swap_request_setup): ssr, _, _ = shift_swap_request_setup() assert ssr.deleted_at is None - ssr.delete() + + with patch("apps.schedules.models.shift_swap_request.refresh_ical_final_schedule") as mock_refresh_final: + ssr.delete() ssr.refresh_from_db() assert ssr.deleted_at is not None + assert mock_refresh_final.apply_async.called_with((ssr.schedule.pk,)) + assert ShiftSwapRequest.objects.all().count() == 0 assert ShiftSwapRequest.objects_with_deleted.all().count() == 1 @@ -65,12 +70,13 @@ def test_take(shift_swap_request_setup) -> None: ssr, _, benefactor = shift_swap_request_setup() original_updated_at = ssr.updated_at - ssr.take(benefactor) + with patch("apps.schedules.models.shift_swap_request.refresh_ical_final_schedule") as mock_refresh_final: + ssr.take(benefactor) assert ssr.benefactor == benefactor assert ssr.updated_at != original_updated_at - - # TODO: + # final schedule refresh was triggered + assert mock_refresh_final.apply_async.called_with((ssr.schedule.pk,)) @pytest.mark.django_db From f201fd2be2c155dcf5166b33ad1d751b6a16202a Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Fri, 28 Jul 2023 15:19:27 -0600 Subject: [PATCH 06/13] Paginate calls to get instances from gcom (#2669) # What this PR does GCOM now has many more instances returned than in the past. Paginate these calls instead of getting all at once. ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 1 + engine/apps/grafana_plugin/helpers/client.py | 17 ++++- engine/apps/grafana_plugin/helpers/gcom.py | 7 +- .../tests/test_gcom_api_client.py | 76 +++++++++++++++++++ 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c970cb98..c11bd126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update the direct paging feature to page for acknowledged & silenced alert groups, and show a warning for resolved alert groups by @vadimkerr ([#2639](https://github.com/grafana/oncall/pull/2639)) +- Change calls to get instances from GCOM to paginate by @mderynck ([#2669](https://github.com/grafana/oncall/pull/2669)) - Update checking on-call users to use schedule final events ([#2651](https://github.com/grafana/oncall/pull/2651)) ### Fixed diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 2641821b..064982c2 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -253,6 +253,7 @@ class GcomAPIClient(APIClient): DELETED_INSTANCE_QUERY = "instances?status=deleted&includeDeleted=true" STACK_STATUS_DELETED = "deleted" STACK_STATUS_ACTIVE = "active" + PAGE_SIZE = 1000 def __init__(self, api_token: str) -> None: super().__init__(settings.GRAFANA_COM_API_URL, api_token) @@ -315,8 +316,20 @@ class GcomAPIClient(APIClient): return False return self._feature_toggle_is_enabled(instance_info, "accessControlOnCall") - def get_instances(self, query: str): - return self.api_get(query) + def get_instances(self, query: str, page_size=None): + if not page_size: + page, _ = self.api_get(query) + yield page + else: + cursor = 0 + while cursor is not None: + if query: + page_query = query + f"&cursor={cursor}&pageSize={page_size}" + else: + page_query = f"?cursor={cursor}&pageSize={page_size}" + page, _ = self.api_get(page_query) + yield page + cursor = page["nextCursor"] def is_stack_deleted(self, stack_id: str) -> bool: url = f"instances?includeDeleted=true&id={stack_id}" diff --git a/engine/apps/grafana_plugin/helpers/gcom.py b/engine/apps/grafana_plugin/helpers/gcom.py index 67543906..91838b44 100644 --- a/engine/apps/grafana_plugin/helpers/gcom.py +++ b/engine/apps/grafana_plugin/helpers/gcom.py @@ -101,12 +101,13 @@ def get_instance_ids(query: str) -> Tuple[Optional[set], bool]: return None, False client = GcomAPIClient(settings.GRAFANA_COM_API_TOKEN) - instances, status = client.get_instances(query) + instance_pages = client.get_instances(query, GcomAPIClient.PAGE_SIZE) - if not instances: + if not instance_pages: return None, True - ids = set(i["id"] for i in instances["items"]) + ids = set(i["id"] for page in instance_pages for i in page["items"]) + return ids, True diff --git a/engine/apps/grafana_plugin/tests/test_gcom_api_client.py b/engine/apps/grafana_plugin/tests/test_gcom_api_client.py index 3dfc3605..63a2cd21 100644 --- a/engine/apps/grafana_plugin/tests/test_gcom_api_client.py +++ b/engine/apps/grafana_plugin/tests/test_gcom_api_client.py @@ -1,8 +1,11 @@ +import uuid from unittest.mock import patch import pytest from apps.grafana_plugin.helpers.client import GcomAPIClient +from apps.grafana_plugin.helpers.gcom import get_instance_ids +from settings.base import CLOUD_LICENSE_NAME class TestIsRbacEnabledForStack: @@ -82,3 +85,76 @@ class TestIsRbacEnabledForStack: GcomAPIClient("someFakeApiToken")._feature_toggle_is_enabled(instance_info, self.TEST_FEATURE_TOGGLE) == expected ) + + +def build_paged_responses(page_size, pages, total_items): + response = [] + remaining = total_items + for i in range(pages): + if not page_size: + page_item_count = remaining + else: + page_item_count = min(page_size, remaining) + remaining -= page_size + + items = [] + for j in range(page_item_count): + items.append({"id": str(uuid.uuid4())}) + next_cursor = None if i == pages - 1 else i * page_size + response.append(({"items": items, "nextCursor": next_cursor}, {})) + return response + + +@pytest.mark.parametrize( + "page_size, expected_pages, expected_items", + [ + (None, 1, 0), + (None, 1, 5), + (10, 2, 20), + (10, 4, 33), + ], +) +def test_get_instances_pagination(page_size, expected_pages, expected_items): + response = build_paged_responses(page_size, expected_pages, expected_items) + client = GcomAPIClient("someToken") + + pages = [] + items = 0 + with patch( + "apps.grafana_plugin.helpers.client.APIClient.api_get", + side_effect=response, + ): + instance_pages = client.get_instances("", page_size) + for page in instance_pages: + pages.append(page) + items += len(page.get("items", [])) + + assert len(pages) == expected_pages + assert items == expected_items + + +@pytest.mark.parametrize( + "query, expected_pages, expected_items", + [ + (GcomAPIClient.ACTIVE_INSTANCE_QUERY, 1, 0), + ("", 1, 543), + (GcomAPIClient.DELETED_INSTANCE_QUERY, 2, 2000), + ("", 4, 3333), + ], +) +def test_get_instance_ids_pagination(settings, query, expected_pages, expected_items): + settings.GRAFANA_COM_API_TOKEN = "someToken" + settings.LICENSE = CLOUD_LICENSE_NAME + + response = build_paged_responses(GcomAPIClient.PAGE_SIZE, expected_pages, expected_items) + + with patch( + "apps.grafana_plugin.helpers.client.APIClient.api_get", + side_effect=response, + ): + instance_ids, status = get_instance_ids(query) + item_count = len(instance_ids) + assert status is True + assert item_count == expected_items + if item_count > 0: + assert type(next(iter(instance_ids))) is str From 0f4d32452b6cd84a6a4bdf3fd7383b320fbfe916 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Mon, 31 Jul 2023 09:05:57 +0300 Subject: [PATCH 07/13] Commented permissions required by our Slack bot. (#2668) To reduce confusion, added comments to slack bot permissions. --- docs/sources/notify/slack/index.md | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/sources/notify/slack/index.md b/docs/sources/notify/slack/index.md index 95e3f3e6..b25321c0 100644 --- a/docs/sources/notify/slack/index.md +++ b/docs/sources/notify/slack/index.md @@ -40,6 +40,56 @@ For Open Source Grafana OnCall Slack installation guidance, refer to 1. Provide your Slack workspace URL and sign with your Slack credentials. 1. Click **Allow** to give Grafana OnCall permission to access your Slack workspace. +## Why does OnCall Slack App require so many permissions? +OnCall has an advanced Slack App with dozens of features making it even possible for users to be on-call and work with +alerts completely inside Slack. The drawback is that our Slack bot requires a lot of permissions and +some of those permissions may sound suspicious, so we commented on them to give you more context. +#### Content and info about you +The bot is using those permissions to receive Slack handles and avatars. +Those permissions are supporting account matching between Grafana and Slack. +- **View information about your identity** +- **View profile details about people in your workspace** +#### Content and info about channels & conversations +- **View basic information about public channels in your workspace** +— this permission is supporting channel selectors in the integration settings so the user could choose where to +send Alert Groups. +- **View messages and other content in public channels, private channels, direct messages, and group direct messages +that Grafana OnCall has been added to** — this permission is supporting a feature of adding messages to the resolution +notes in the Alert Group's Slack thread. +- **View basic information about private channels that Grafana OnCall has been added to** — this permission allows to +add a slack bot to the private channel and make it selectable in the list of channels. +So users will be able to route Alert Groups to the private channels. +- **View basic information about direct messages that Grafana OnCall has been added to** +#### Content and info about your workspace +This set of permissions is supporting the ability of Grafana OnCall to match users with Grafana users. +- **View people in your workspace** +- **View email addresses of people in your workspace** +- **View the name, email domain, and icon for workspaces Grafana OnCall is connected to** +- **View user groups in your workspace** +- **View profile details about people in your workspace** +#### Perform actions as you +- **Send messages on your behalf** — this permission may sound suspicious, but it's actually a general ability +to send messages as the bot: https://api.slack.com/scopes/chat:write Grafana OnCall will not impersonate or post +using your handle to slack. It will always post as the bot. +#### Perform actions in channels & conversations +- **View messages that directly mention @grafana_oncall in conversations that the app is in** +- **Join public channels in your workspace** +- **Send messages as @grafana_oncall** +- **Send messages as @grafana_oncall with a customized username and avatar** +- **Send messages to channels @grafana_oncall isn't a member of** — users configure channels to publish +Alert Groups in the OnCall's UI, but the bot is usually not a member of those channels. +- **Upload, edit, and delete files as Grafana OnCall** — the bot is using this permission: +https://api.slack.com/scopes/files:write to be able to send files to the channel. +The bot will not delete or read files sent by other users. +- **Start direct messages with people** +- **Add and edit emoji reactions** +#### Perform actions in your workspace +- **Add shortcuts and/or slash commands that people can use** — the permission is used to add /escalate and /oncall +(deprecated) slack commands. +- **Create and manage user groups** — the permission is used to automatically update user groups linked to on-call +schedules. It will add users once their on-call shift starts and remove them once the on-call shift ends. +- **Set presence for Grafana OnCall** + ## Post-install configuration for Slack integration Configure the following additional settings to ensure Grafana OnCall alerts are routed to the intended Slack channels From c636b8add974dabadffce635a3aec4d8684aa82d Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Mon, 31 Jul 2023 10:03:54 +0200 Subject: [PATCH 08/13] fix docs linting errors --- docs/sources/notify/slack/index.md | 75 ++++++++++++++++++------------ 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/docs/sources/notify/slack/index.md b/docs/sources/notify/slack/index.md index b25321c0..8407620c 100644 --- a/docs/sources/notify/slack/index.md +++ b/docs/sources/notify/slack/index.md @@ -41,54 +41,69 @@ For Open Source Grafana OnCall Slack installation guidance, refer to 1. Click **Allow** to give Grafana OnCall permission to access your Slack workspace. ## Why does OnCall Slack App require so many permissions? -OnCall has an advanced Slack App with dozens of features making it even possible for users to be on-call and work with + +OnCall has an advanced Slack App with dozens of features making it even possible for users to be on-call and work with alerts completely inside Slack. The drawback is that our Slack bot requires a lot of permissions and some of those permissions may sound suspicious, so we commented on them to give you more context. -#### Content and info about you -The bot is using those permissions to receive Slack handles and avatars. -Those permissions are supporting account matching between Grafana and Slack. + +### Content and info about you + +The bot is using those permissions to receive Slack handles and avatars. +Those permissions are supporting account matching between Grafana and Slack. + - **View information about your identity** - **View profile details about people in your workspace** -#### Content and info about channels & conversations -- **View basic information about public channels in your workspace** -— this permission is supporting channel selectors in the integration settings so the user could choose where to -send Alert Groups. -- **View messages and other content in public channels, private channels, direct messages, and group direct messages -that Grafana OnCall has been added to** — this permission is supporting a feature of adding messages to the resolution -notes in the Alert Group's Slack thread. -- **View basic information about private channels that Grafana OnCall has been added to** — this permission allows to -add a slack bot to the private channel and make it selectable in the list of channels. -So users will be able to route Alert Groups to the private channels. + +### Content and info about channels & conversations + +- **View basic information about public channels in your workspace** + — this permission is supporting channel selectors in the integration settings so the user could choose where to + send Alert Groups. +- **View messages and other content in public channels, private channels, direct messages, and group direct messages + that Grafana OnCall has been added to** — this permission is supporting a feature of adding messages to the resolution + notes in the Alert Group's Slack thread. +- **View basic information about private channels that Grafana OnCall has been added to** — this permission allows to + add a slack bot to the private channel and make it selectable in the list of channels. + So users will be able to route Alert Groups to the private channels. - **View basic information about direct messages that Grafana OnCall has been added to** -#### Content and info about your workspace + +### Content and info about your workspace + This set of permissions is supporting the ability of Grafana OnCall to match users with Grafana users. + - **View people in your workspace** - **View email addresses of people in your workspace** - **View the name, email domain, and icon for workspaces Grafana OnCall is connected to** - **View user groups in your workspace** - **View profile details about people in your workspace** -#### Perform actions as you -- **Send messages on your behalf** — this permission may sound suspicious, but it's actually a general ability -to send messages as the bot: https://api.slack.com/scopes/chat:write Grafana OnCall will not impersonate or post -using your handle to slack. It will always post as the bot. -#### Perform actions in channels & conversations + +### Perform actions as you + +- **Send messages on your behalf** — this permission may sound suspicious, but it's actually a general ability + to send messages as the bot: Grafana OnCall will not impersonate or post + using your handle to slack. It will always post as the bot. + +### Perform actions in channels & conversations + - **View messages that directly mention @grafana_oncall in conversations that the app is in** - **Join public channels in your workspace** - **Send messages as @grafana_oncall** - **Send messages as @grafana_oncall with a customized username and avatar** -- **Send messages to channels @grafana_oncall isn't a member of** — users configure channels to publish -Alert Groups in the OnCall's UI, but the bot is usually not a member of those channels. -- **Upload, edit, and delete files as Grafana OnCall** — the bot is using this permission: -https://api.slack.com/scopes/files:write to be able to send files to the channel. -The bot will not delete or read files sent by other users. +- **Send messages to channels @grafana_oncall isn't a member of** — users configure channels to publish + Alert Groups in the OnCall's UI, but the bot is usually not a member of those channels. +- **Upload, edit, and delete files as Grafana OnCall** — the bot is using this permission: + to be able to send files to the channel. + The bot will not delete or read files sent by other users. - **Start direct messages with people** - **Add and edit emoji reactions** -#### Perform actions in your workspace -- **Add shortcuts and/or slash commands that people can use** — the permission is used to add /escalate and /oncall -(deprecated) slack commands. + +### Perform actions in your workspace + +- **Add shortcuts and/or slash commands that people can use** — the permission is used to add /escalate and /oncall + (deprecated) slack commands. - **Create and manage user groups** — the permission is used to automatically update user groups linked to on-call -schedules. It will add users once their on-call shift starts and remove them once the on-call shift ends. -- **Set presence for Grafana OnCall** + schedules. It will add users once their on-call shift starts and remove them once the on-call shift ends. +- **Set presence for Grafana OnCall** ## Post-install configuration for Slack integration From 81937868b4899b616474f2709b4dace65db76f4a Mon Sep 17 00:00:00 2001 From: Nelson <93178586+njohnstone2@users.noreply.github.com> Date: Mon, 31 Jul 2023 18:12:19 +1000 Subject: [PATCH 09/13] Helm - Support topologySpreadConstraints and priorityClassName (#2675) # What this PR does Adds support for `topologySpreadConstraints` and `priorityClassName` on the celerly/engine deployment templates ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/2655 ## Checklist - [X] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) Co-authored-by: Joey Orlando --- helm/oncall/templates/celery/_deployment.tpl | 7 ++++ helm/oncall/templates/engine/deployment.yaml | 7 ++++ .../priority_class_deployments_test.yaml | 21 ++++++++++++ .../tests/topology_deployments_test.yaml | 33 +++++++++++++++++++ helm/oncall/values.yaml | 16 +++++++++ 5 files changed, 84 insertions(+) create mode 100644 helm/oncall/tests/priority_class_deployments_test.yaml create mode 100644 helm/oncall/tests/topology_deployments_test.yaml diff --git a/helm/oncall/templates/celery/_deployment.tpl b/helm/oncall/templates/celery/_deployment.tpl index fd37379b..b18c117f 100644 --- a/helm/oncall/templates/celery/_deployment.tpl +++ b/helm/oncall/templates/celery/_deployment.tpl @@ -46,6 +46,13 @@ spec: tolerations: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.celery.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.celery.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} containers: - name: {{ .Chart.Name }} securityContext: diff --git a/helm/oncall/templates/engine/deployment.yaml b/helm/oncall/templates/engine/deployment.yaml index 6b91cccf..b3a84995 100644 --- a/helm/oncall/templates/engine/deployment.yaml +++ b/helm/oncall/templates/engine/deployment.yaml @@ -93,3 +93,10 @@ spec: tolerations: {{- toYaml . | nindent 8 }} {{- end }} + {{- if .Values.engine.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.engine.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} diff --git a/helm/oncall/tests/priority_class_deployments_test.yaml b/helm/oncall/tests/priority_class_deployments_test.yaml new file mode 100644 index 00000000..89db7501 --- /dev/null +++ b/helm/oncall/tests/priority_class_deployments_test.yaml @@ -0,0 +1,21 @@ +suite: test priorityClassName for deployments +templates: + - celery/deployment-celery.yaml + - engine/deployment.yaml +release: + name: oncall +tests: + - it: priorityClassName="" -> should exclude priorityClassName + asserts: + - notExists: + path: spec.template.spec.priorityClassName + + - it: priorityClassName -> should use the custom priorityClassName + set: + engine: + priorityClassName: very-important + celery: + priorityClassName: kinda-important + asserts: + - exists: + path: spec.template.spec.priorityClassName diff --git a/helm/oncall/tests/topology_deployments_test.yaml b/helm/oncall/tests/topology_deployments_test.yaml new file mode 100644 index 00000000..a19f4159 --- /dev/null +++ b/helm/oncall/tests/topology_deployments_test.yaml @@ -0,0 +1,33 @@ +suite: test topologySpreadConstraints for deployments +templates: + - celery/deployment-celery.yaml + - engine/deployment.yaml +release: + name: oncall +tests: + - it: topologySpreadConstraints=[] -> should exclude topologySpreadConstraints + asserts: + - notExists: + path: spec.template.spec.topologySpreadConstraints + + - it: topologySpreadConstraints -> should use custom topologySpreadConstraints + set: + engine: + topologySpreadConstraints: + - labelSelector: + matchLabels: + app.kubernetes.io/component: engine + maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: DoNotSchedule + celery: + topologySpreadConstraints: + - labelSelector: + matchLabels: + app.kubernetes.io/component: engine + maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: DoNotSchedule + asserts: + - matchSnapshot: + path: spec.template.spec.topologySpreadConstraints diff --git a/helm/oncall/values.yaml b/helm/oncall/values.yaml index dc96be3c..84dd2160 100644 --- a/helm/oncall/values.yaml +++ b/helm/oncall/values.yaml @@ -57,6 +57,14 @@ engine: ## ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ tolerations: [] + ## Topology spread constraints for pod assignment + ## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ + topologySpreadConstraints: [] + + ## Priority class for the pods + ## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + priorityClassName: "" + # Celery workers pods configuration celery: replicaCount: 1 @@ -95,6 +103,14 @@ celery: ## ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ tolerations: [] + ## Topology spread constraints for pod assignment + ## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ + topologySpreadConstraints: [] + + ## Priority class for the pods + ## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + priorityClassName: "" + oncall: # Override default MIRAGE_CIPHER_IV (must be 16 bytes long) # For existing installation, this should not be changed. From fc8191c7dbcbf5424048747b7e8da396f9b30559 Mon Sep 17 00:00:00 2001 From: Nelson <93178586+njohnstone2@users.noreply.github.com> Date: Mon, 31 Jul 2023 18:25:30 +1000 Subject: [PATCH 10/13] Helm - Twilio validation make auth fields optional (#2674) # What this PR does When configuring twilio auth only the provided values are templated. ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/2654 ## Checklist - [X] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Joey Orlando Co-authored-by: Joey Orlando --- CHANGELOG.md | 6 +++ helm/oncall/templates/_env.tpl | 4 ++ helm/oncall/tests/twilio_auth_env_test.yaml | 48 +++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 helm/oncall/tests/twilio_auth_env_test.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index c11bd126..0ab02d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## Unreleased + +### Fixed + +- Fix helm env variable validation logic when specifying Twilio auth related values by @njohnstone2 ([#2674](https://github.com/grafana/oncall/pull/2674)) + ## v1.3.19 (2023-07-28) ### Fixed diff --git a/helm/oncall/templates/_env.tpl b/helm/oncall/templates/_env.tpl index 3632789e..6ee7207a 100644 --- a/helm/oncall/templates/_env.tpl +++ b/helm/oncall/templates/_env.tpl @@ -121,11 +121,13 @@ secretKeyRef: name: {{ .existingSecret }} key: {{ required "oncall.twilio.accountSid is required if oncall.twilio.existingSecret is not empty" .accountSid | quote }} +{{- if .authTokenKey }} - name: TWILIO_AUTH_TOKEN valueFrom: secretKeyRef: name: {{ .existingSecret }} key: {{ required "oncall.twilio.authTokenKey is required if oncall.twilio.existingSecret is not empty" .authTokenKey | quote }} +{{- end }} - name: TWILIO_NUMBER valueFrom: secretKeyRef: @@ -136,6 +138,7 @@ secretKeyRef: name: {{ .existingSecret }} key: {{ required "oncall.twilio.verifySidKey is required if oncall.twilio.existingSecret is not empty" .verifySidKey | quote }} +{{- if and .apiKeySidKey .apiKeySecretKey }} - name: TWILIO_API_KEY_SID valueFrom: secretKeyRef: @@ -146,6 +149,7 @@ secretKeyRef: name: {{ .existingSecret }} key: {{ required "oncall.twilio.apiKeySecretKey is required if oncall.twilio.existingSecret is not empty" .apiKeySecretKey | quote }} +{{- end }} {{- else }} {{- if .accountSid }} - name: TWILIO_ACCOUNT_SID diff --git a/helm/oncall/tests/twilio_auth_env_test.yaml b/helm/oncall/tests/twilio_auth_env_test.yaml new file mode 100644 index 00000000..01ee8be7 --- /dev/null +++ b/helm/oncall/tests/twilio_auth_env_test.yaml @@ -0,0 +1,48 @@ +suite: test Twilio auth envs for deployments +release: + name: oncall +templates: + - engine/deployment.yaml +tests: + - it: snippet.oncall.twilio.env -> should succeed if only apiKeySid and apiKeySecret are set + set: + oncall.twilio.existingSecret: unittest-secret + oncall.twilio.accountSid: "acc-sid" + oncall.twilio.phoneNumberKey: "phone-key" + oncall.twilio.verifySidKey: "verify-sid-key" + oncall.twilio.apiKeySidKey: "api-sid-key" + oncall.twilio.apiKeySecretKey: "api-secret-key" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: TWILIO_API_KEY_SID + valueFrom: + secretKeyRef: + key: api-sid-key + name: unittest-secret + - contains: + path: spec.template.spec.containers[0].env + content: + name: TWILIO_API_KEY_SECRET + valueFrom: + secretKeyRef: + key: api-secret-key + name: unittest-secret + + - it: snippet.oncall.twilio.env -> should succeed if only authToken is set + set: + oncall.twilio.existingSecret: unittest-secret + oncall.twilio.accountSid: "acc-sid" + oncall.twilio.verifySidKey: "verify-sid-key" + oncall.twilio.phoneNumberKey: "phone-key" + oncall.twilio.authTokenKey: "auth-token-key" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: TWILIO_AUTH_TOKEN + valueFrom: + secretKeyRef: + key: auth-token-key + name: unittest-secret From 000372f24aa3aca301e5bfa721fa7a6e494c5c33 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 31 Jul 2023 08:41:37 -0300 Subject: [PATCH 11/13] Add filter_shift_swaps endpoint to schedules API (#2684) Example request/response `GET /api/internal/v1/schedules/SVV8E9JGLLR9T/filter_shift_swaps/?date=2023-07-22&days=9` ``` { "shift_swaps": [ { "id": "SSR1QJ93UNCUPC4", "created_at": "2023-07-27T12:55:32.188232Z", "updated_at": "2023-07-28T13:28:08.027124Z", "status": "taken", "schedule": "SVV8E9JGLLR9T", "swap_start": "2023-07-24T21:00:00.000000Z", "swap_end": "2023-08-04T03:00:00.000000Z", "description": null, "beneficiary": "UWJWIN8MQ1GYL", "benefactor": "UVSKHRA4YU328" } ] } ``` --- CHANGELOG.md | 4 + engine/apps/api/tests/test_schedules.py | 126 +++++++++++++++++- engine/apps/api/tests/test_shift_swaps.py | 37 +++-- engine/apps/api/views/schedule.py | 18 +++ .../apps/schedules/models/on_call_schedule.py | 20 +-- engine/common/api_helpers/utils.py | 4 + 6 files changed, 181 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab02d05..56cabf08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Add filter_shift_swaps endpoint to schedules API ([#2684](https://github.com/grafana/oncall/pull/2684)) + ### Fixed - Fix helm env variable validation logic when specifying Twilio auth related values by @njohnstone2 ([#2674](https://github.com/grafana/oncall/pull/2674)) diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index ad7f64c5..8925d3e7 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -20,7 +20,7 @@ from apps.schedules.models import ( OnCallScheduleICal, OnCallScheduleWeb, ) -from common.api_helpers.utils import create_engine_url +from common.api_helpers.utils import create_engine_url, serialize_datetime_as_utc_timestamp ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" @@ -1247,6 +1247,92 @@ def test_filter_events_final_schedule( assert returned_events == expected_events +@pytest.mark.django_db +def test_filter_swap_requests( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, + make_schedule, + make_shift_swap_request, +): + organization, admin, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + other_schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="other_web_schedule", + ) + user_a, user_b, user_c = (make_user_for_organization(organization, username=i) for i in "ABC") + # clear users pks <-> organization cache (persisting between tests) + memoized_users_in_ical.cache_clear() + + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = today - timezone.timedelta(days=7) + request_date = start_date + + # swap for other schedule + make_shift_swap_request( + other_schedule, + user_a, + swap_start=start_date + timezone.timedelta(days=1), + swap_end=start_date + timezone.timedelta(days=3), + ) + # swap out of range + make_shift_swap_request( + schedule, + user_a, + swap_start=start_date + timezone.timedelta(days=10), + swap_end=start_date + timezone.timedelta(days=13), + ) + # expected swaps + swap_a = make_shift_swap_request( + schedule, + user_a, + swap_start=start_date + timezone.timedelta(days=1), + swap_end=start_date + timezone.timedelta(days=3), + ) + swap_b = make_shift_swap_request( + schedule, + user_b, + swap_start=start_date, + swap_end=start_date + timezone.timedelta(days=1), + benefactor=user_c, + ) + + url = reverse("api-internal:schedule-filter-shift-swaps", kwargs={"pk": schedule.public_primary_key}) + url += "?date={}&days=1".format(request_date.strftime("%Y-%m-%d")) + response = client.get(url, format="json", **make_user_auth_headers(admin, token)) + assert response.status_code == status.HTTP_200_OK + + expected = [ + { + "pk": swap.public_primary_key, + "swap_start": serialize_datetime_as_utc_timestamp(swap.swap_start), + "swap_end": serialize_datetime_as_utc_timestamp(swap.swap_end), + "beneficiary": swap.beneficiary.public_primary_key, + "benefactor": swap.benefactor.public_primary_key if swap.benefactor else None, + } + for swap in (swap_a, swap_b) + ] + returned = [ + { + "pk": s["id"], + "swap_start": s["swap_start"], + "swap_end": s["swap_end"], + "beneficiary": s["beneficiary"], + "benefactor": s["benefactor"], + } + for s in response.data["shift_swaps"] + ] + assert returned == expected + + @pytest.mark.django_db def test_next_shifts_per_user( make_organization_and_user_with_plugin_token, @@ -1765,6 +1851,44 @@ def test_events_permissions( assert response.status_code == expected_status +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], +) +def test_filter_shift_swaps_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_schedule, + role, + expected_status, +): + organization, user, token = make_organization_and_user_with_plugin_token(role) + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleICal, + name="test_ical_schedule", + ical_url_primary=ICAL_URL, + ) + + client = APIClient() + url = reverse("api-internal:schedule-filter-shift-swaps", kwargs={"pk": schedule.public_primary_key}) + + with patch( + "apps.api.views.schedule.ScheduleView.filter_shift_swaps", + return_value=Response( + status=status.HTTP_200_OK, + ), + ): + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", diff --git a/engine/apps/api/tests/test_shift_swaps.py b/engine/apps/api/tests/test_shift_swaps.py index 094b9f71..90d6af53 100644 --- a/engine/apps/api/tests/test_shift_swaps.py +++ b/engine/apps/api/tests/test_shift_swaps.py @@ -11,6 +11,7 @@ from rest_framework.test import APIClient from apps.api.permissions import LegacyAccessControlRole from apps.schedules.models import OnCallScheduleWeb, ShiftSwapRequest +from common.api_helpers.utils import serialize_datetime_as_utc_timestamp from common.insight_log import EntityEvent description = "my shift swap request" @@ -36,18 +37,14 @@ def ssr_setup( return _ssr_setup -def _convert_dt_to_sr(dt: datetime.datetime) -> str: - return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ") - - def _construct_serialized_object(ssr: ShiftSwapRequest, status="open", description=None, benefactor=None): return { "id": ssr.public_primary_key, - "created_at": _convert_dt_to_sr(ssr.created_at), - "updated_at": _convert_dt_to_sr(ssr.updated_at), + "created_at": serialize_datetime_as_utc_timestamp(ssr.created_at), + "updated_at": serialize_datetime_as_utc_timestamp(ssr.updated_at), "schedule": ssr.schedule.public_primary_key, - "swap_start": _convert_dt_to_sr(ssr.swap_start), - "swap_end": _convert_dt_to_sr(ssr.swap_end), + "swap_start": serialize_datetime_as_utc_timestamp(ssr.swap_start), + "swap_end": serialize_datetime_as_utc_timestamp(ssr.swap_end), "beneficiary": ssr.beneficiary.public_primary_key, "status": status, "benefactor": benefactor, @@ -172,8 +169,8 @@ def test_create( ssr = ShiftSwapRequest.objects.get(public_primary_key=response.json()["id"]) expected_response = _construct_serialized_object(ssr) | { **data, - "swap_start": _convert_dt_to_sr(tomorrow), - "swap_end": _convert_dt_to_sr(two_days_from_now), + "swap_start": serialize_datetime_as_utc_timestamp(tomorrow), + "swap_end": serialize_datetime_as_utc_timestamp(two_days_from_now), } assert response.status_code == status.HTTP_201_CREATED @@ -282,8 +279,8 @@ def test_update( data = { "description": "hellooooo world", "schedule": ssr.schedule.public_primary_key, - "swap_start": _convert_dt_to_sr(ssr.swap_start), - "swap_end": _convert_dt_to_sr(ssr.swap_end), + "swap_start": serialize_datetime_as_utc_timestamp(ssr.swap_start), + "swap_end": serialize_datetime_as_utc_timestamp(ssr.swap_end), } response = client.put(url, data=json.dumps(data), content_type="application/json", **auth_headers) @@ -380,8 +377,8 @@ def test_update_own_ssr_permissions(ssr_setup, make_user_auth_headers, role, exp data = { "description": "hellooooo world", "schedule": ssr.schedule.public_primary_key, - "swap_start": _convert_dt_to_sr(ssr.swap_start), - "swap_end": _convert_dt_to_sr(ssr.swap_end), + "swap_start": serialize_datetime_as_utc_timestamp(ssr.swap_start), + "swap_end": serialize_datetime_as_utc_timestamp(ssr.swap_end), } response = client.put( @@ -449,8 +446,8 @@ def test_partial_update_time_related_fields(ssr_setup, make_user_auth_headers): auth_headers = make_user_auth_headers(beneficiary, token) # but if we do PATCH a time related field, we must specify all the time fields - swap_start = {"swap_start": _convert_dt_to_sr(tomorrow + datetime.timedelta(days=5))} - swap_end = {"swap_end": _convert_dt_to_sr(tomorrow + datetime.timedelta(days=10))} + swap_start = {"swap_start": serialize_datetime_as_utc_timestamp(tomorrow + datetime.timedelta(days=5))} + swap_end = {"swap_end": serialize_datetime_as_utc_timestamp(tomorrow + datetime.timedelta(days=10))} valid = swap_start | swap_end for case in [swap_start, swap_end]: @@ -518,8 +515,8 @@ def test_benefactor_and_beneficiary_are_read_only_fields(ssr_setup, make_user_au base_data = { "description": "hellooooo world", "schedule": ssr.schedule.public_primary_key, - "swap_start": _convert_dt_to_sr(ssr.swap_start), - "swap_end": _convert_dt_to_sr(ssr.swap_end), + "swap_start": serialize_datetime_as_utc_timestamp(ssr.swap_start), + "swap_end": serialize_datetime_as_utc_timestamp(ssr.swap_end), } update_beneficiary = {"beneficiary": benefactor.public_primary_key} @@ -634,7 +631,9 @@ def test_take(ssr_setup, make_user_auth_headers): assert response.status_code == status.HTTP_200_OK assert response_json == expected_response - assert updated_at != _convert_dt_to_sr(ssr.updated_at) # validate that updated_at is auto-updated on take + assert updated_at != serialize_datetime_as_utc_timestamp( + ssr.updated_at + ) # validate that updated_at is auto-updated on take url = reverse("api-internal:shift_swap-detail", kwargs={"pk": ssr.public_primary_key}) response = client.get(url, format="json", **auth_headers) diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 7413920f..3488a839 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -28,6 +28,7 @@ from apps.api.serializers.schedule_polymorphic import ( PolymorphicScheduleSerializer, PolymorphicScheduleUpdateSerializer, ) +from apps.api.serializers.shift_swap import ShiftSwapRequestSerializer from apps.api.serializers.user import ScheduleUserSerializer from apps.auth_token.auth import PluginAuthentication from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME @@ -83,6 +84,7 @@ class ScheduleView( "retrieve": [RBACPermission.Permissions.SCHEDULES_READ], "events": [RBACPermission.Permissions.SCHEDULES_READ], "filter_events": [RBACPermission.Permissions.SCHEDULES_READ], + "filter_shift_swaps": [RBACPermission.Permissions.SCHEDULES_READ], "next_shifts_per_user": [RBACPermission.Permissions.SCHEDULES_READ], "related_users": [RBACPermission.Permissions.SCHEDULES_READ], "quality": [RBACPermission.Permissions.SCHEDULES_READ], @@ -343,6 +345,22 @@ class ScheduleView( } return Response(result, status=status.HTTP_200_OK) + @action(detail=True, methods=["get"]) + def filter_shift_swaps(self, request: Request, pk: str) -> Response: + user_tz, starting_date, days = get_date_range_from_request(self.request) + schedule = self.get_object() + + pytz_tz = pytz.timezone(user_tz) + datetime_start = datetime.datetime.combine(starting_date, datetime.time.min, tzinfo=pytz_tz) + datetime_end = datetime_start + datetime.timedelta(days=days) + + swap_requests = schedule.filter_swap_requests(datetime_start, datetime_end) + + serialized_swap_requests = ShiftSwapRequestSerializer(swap_requests, many=True) + result = {"shift_swaps": serialized_swap_requests.data} + + return Response(result, status=status.HTTP_200_OK) + @action(detail=True, methods=["get"]) def next_shifts_per_user(self, request, pk): """Return next shift for users in schedule.""" diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index f06d3996..7ee10855 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -402,6 +402,17 @@ class OnCallSchedule(PolymorphicModel): events = self._resolve_schedule(events, datetime_start, datetime_end) return events + def filter_swap_requests(self, datetime_start, datetime_end): + swap_requests = self.shift_swap_requests.filter( # starting before but ongoing + swap_start__lt=datetime_start, swap_end__gte=datetime_start + ).union( + self.shift_swap_requests.filter( # starting after but before end + swap_start__gte=datetime_start, swap_start__lte=datetime_end + ) + ) + swap_requests = swap_requests.order_by("created_at") + return swap_requests + def refresh_ical_final_schedule(self): now = timezone.now() # window to consider: from now, -15 days + 6 months @@ -619,14 +630,7 @@ class OnCallSchedule(PolymorphicModel): def _apply_swap_requests(self, events, datetime_start, datetime_end) -> ScheduleEvents: """Apply swap requests details to schedule events.""" # get swaps requests affecting this schedule / time range - swaps = self.shift_swap_requests.filter( # starting before but ongoing - swap_start__lt=datetime_start, swap_end__gte=datetime_start - ).union( - self.shift_swap_requests.filter( # starting after but before end - swap_start__gte=datetime_start, swap_start__lte=datetime_end - ) - ) - swaps = swaps.order_by("created_at") + swaps = self.filter_swap_requests(datetime_start, datetime_end) def _insert_event(index, event): # add event, if any, to events list in the specified index diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index c3be4c58..1233c4b0 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -161,3 +161,7 @@ def get_date_range_from_request(request: Request) -> typing.Tuple[str, datetime. def check_phone_number_is_valid(phone_number): return re.match(r"^\+\d{8,15}$", phone_number) is not None + + +def serialize_datetime_as_utc_timestamp(dt: datetime.datetime) -> str: + return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ") From 71ae1d3ff6a209d3e24154ac835639bc99c08aba Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Mon, 31 Jul 2023 15:48:18 +0300 Subject: [PATCH 12/13] Fix for mobile verification (#2692) # What this PR does ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/2687 ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 1 + .../src/plugin/GrafanaPluginRootPage.tsx | 5 ----- .../src/plugin/PluginSetup/index.tsx | 8 +++++++- .../src/state/rootBaseStore/index.ts | 1 + grafana-plugin/src/utils/loadJs.ts | 19 ++++++++++++++++++- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56cabf08..a42fe127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix helm env variable validation logic when specifying Twilio auth related values by @njohnstone2 ([#2674](https://github.com/grafana/oncall/pull/2674)) +- Fixed mobile app verification not sending SMS to phone number ([#2687](https://github.com/grafana/oncall/issues/2687)) ## v1.3.19 (2023-07-28) diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index d012fec0..a96f090c 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -40,7 +40,6 @@ import { rootStore } from 'state'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { isUserActionAllowed } from 'utils/authorization'; -import loadJs from 'utils/loadJs'; dayjs.extend(utc); dayjs.extend(timezone); @@ -99,10 +98,6 @@ export const Root = observer((props: AppRootProps) => { }; }, []); - useEffect(() => { - loadJs(`https://www.google.com/recaptcha/api.js?render=${rootStore.recaptchaSiteKey}`); - }, []); - const updateBasicData = async () => { await store.updateBasicData(); setBasicDataLoaded(true); diff --git a/grafana-plugin/src/plugin/PluginSetup/index.tsx b/grafana-plugin/src/plugin/PluginSetup/index.tsx index da485b40..5592c0f3 100644 --- a/grafana-plugin/src/plugin/PluginSetup/index.tsx +++ b/grafana-plugin/src/plugin/PluginSetup/index.tsx @@ -9,6 +9,7 @@ import { AppRootProps } from 'types'; import logo from 'assets/img/logo.svg'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; import { useStore } from 'state/useStore'; +import loadJs from 'utils/loadJs'; export type PluginSetupProps = AppRootProps & { InitializedComponent: (props: AppRootProps) => JSX.Element; @@ -35,8 +36,13 @@ const PluginSetupWrapper: FC = ({ text, children }) => const PluginSetup: FC = observer(({ InitializedComponent, ...props }) => { const store = useStore(); const setupPlugin = useCallback(() => store.setupPlugin(props.meta), [props.meta]); + useEffect(() => { - setupPlugin(); + (async function () { + await setupPlugin(); + store.recaptchaSiteKey && + loadJs(`https://www.google.com/recaptcha/api.js?render=${store.recaptchaSiteKey}`, store.recaptchaSiteKey); + })(); }, [setupPlugin]); if (store.initializationError) { diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index 2791acc1..ee81b2d7 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -231,6 +231,7 @@ export class RootBaseStore { this.backendLicense = pluginConnectionStatus.license; this.recaptchaSiteKey = pluginConnectionStatus.recaptcha_site_key; } + if (!this.userStore.currentUser) { try { await this.userStore.loadCurrentUser(); diff --git a/grafana-plugin/src/utils/loadJs.ts b/grafana-plugin/src/utils/loadJs.ts index 83e7d4e6..4580f1e3 100644 --- a/grafana-plugin/src/utils/loadJs.ts +++ b/grafana-plugin/src/utils/loadJs.ts @@ -1,6 +1,23 @@ -export default function loadJs(url: string) { +/** + * Will append a new JS script + * @param {string} url of the script + * @param {string} id optional id. If specified, the script will be loaded only once for that given id + */ +export default function loadJs(url: string, id: string = undefined) { + if (id) { + const existingScript = document.getElementById(url); + if (existingScript) { + return; + } + } + let script = document.createElement('script'); script.src = url; + if (id) { + // optional + script.id = id; + } + document.head.appendChild(script); } From f7a8c54766ded3ff69168e4bf619d949deaaab0a Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Mon, 31 Jul 2023 14:29:00 +0100 Subject: [PATCH 13/13] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a42fe127..5eabff18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## Unreleased +## v1.3.20 (2023-07-31) ### Added