diff --git a/CHANGELOG.md b/CHANGELOG.md index c55a5766..5eabff18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ 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 + +- 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)) +- Fixed mobile app verification not sending SMS to phone number ([#2687](https://github.com/grafana/oncall/issues/2687)) + +## v1.3.19 (2023-07-28) + +### Fixed + +- 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) @@ -13,6 +32,8 @@ 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/docs/sources/notify/slack/index.md b/docs/sources/notify/slack/index.md index 95e3f3e6..8407620c 100644 --- a/docs/sources/notify/slack/index.md +++ b/docs/sources/notify/slack/index.md @@ -40,6 +40,71 @@ 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: 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: + 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 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 \ 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/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_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/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_schedules.py b/engine/apps/api/tests/test_schedules.py index 4cf8eb9c..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" @@ -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 @@ -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 ae492fe5..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, @@ -147,9 +144,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) @@ -167,14 +169,15 @@ 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 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 +264,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 @@ -273,8 +279,8 @@ def test_update(mock_write_resource_insight_log, ssr_setup, make_user_auth_heade 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) @@ -296,6 +302,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( @@ -369,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( @@ -394,8 +402,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 +435,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): @@ -433,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]: @@ -502,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} @@ -547,8 +560,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 +582,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( @@ -613,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/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..3488a839 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 @@ -26,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 @@ -81,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], @@ -274,12 +278,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 +320,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, @@ -334,14 +345,30 @@ 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.""" - 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 +400,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/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/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 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..0bedb986 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -61,9 +61,7 @@ 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"]: +) -> "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 @@ -75,30 +73,15 @@ 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 - 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)) @@ -108,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) @@ -118,11 +99,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 +132,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 +174,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,8 +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, - include_viewers: bool = False, - users_to_filter: typing.Optional["UserQuerySet"] = None, ) -> typing.Sequence["User"]: """ Retrieve on-call users for the current time @@ -359,8 +328,6 @@ def list_users_to_notify_from_ical( schedule, events_datetime, events_datetime, - include_viewers=include_viewers, - users_to_filter=users_to_filter, ) @@ -368,43 +335,20 @@ 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] +) -> UserQuerySet: 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) 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) @@ -412,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/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 a63f46b0..7ee10855 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 @@ -50,6 +51,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]+\] )?{}" @@ -90,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 @@ -98,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] @@ -153,7 +165,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 +245,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 @@ -307,9 +331,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 +343,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, ) @@ -367,28 +389,41 @@ 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) - def final_events(self, user_tz, starting_date, days): - """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) return events + def final_events(self, datetime_start, datetime_end): + """Return schedule final events, after resolving shifts and overrides.""" + 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 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): - 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 +452,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 +476,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 +511,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 +627,94 @@ class OnCallSchedule(PolymorphicModel): "overloaded_users": overloaded_users, } - def _resolve_schedule(self, events: ScheduleEvents) -> ScheduleEvents: - """Calculate final schedule shifts considering rotations and overrides.""" + 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.filter_swap_requests(datetime_start, datetime_end) + + 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: + """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 +800,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 +883,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 +916,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 +1120,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/models/shift_swap_request.py b/engine/apps/schedules/models/shift_swap_request.py index c8453f02..07ccd247 100644 --- a/engine/apps/schedules/models/shift_swap_request.py +++ b/engine/apps/schedules/models/shift_swap_request.py @@ -7,10 +7,13 @@ 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: - 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 +44,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 +95,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,23 +115,56 @@ class ShiftSwapRequest(models.Model): def __str__(self) -> str: return f"{self.schedule.name} {self.beneficiary.username} {self.swap_start} - {self.swap_end}" - def delete(self): - self.deleted_at = timezone.now() - self.save() + @property + def is_deleted(self) -> bool: + return self.deleted_at is not None - def hard_delete(self): - super().delete() + @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.deleted_at is not None: + if self.is_deleted: return self.Statuses.DELETED - elif self.benefactor is not None: + elif self.is_taken: return self.Statuses.TAKEN - elif timezone.now() > self.swap_start: + 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() + # 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: raise exceptions.BeneficiaryCannotTakeOwnShiftSwapRequest() @@ -122,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/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_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_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..1e56761b 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,416 @@ 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) + + +@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_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] ], diff --git a/engine/apps/schedules/tests/test_shift_swap_request.py b/engine/apps/schedules/tests/test_shift_swap_request.py index fbfa02df..9c08dbed 100644 --- a/engine/apps/schedules/tests/test_shift_swap_request.py +++ b/engine/apps/schedules/tests/test_shift_swap_request.py @@ -1,94 +1,88 @@ import datetime +from unittest.mock import patch 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() + + 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 @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) + 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 -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 +92,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 +102,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 +112,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/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") 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"}, 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); } 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/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/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 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.