Merge pull request #2695 from grafana/dev

v1.3.20
This commit is contained in:
Vadim Stepanov 2023-07-31 14:29:50 +01:00 committed by GitHub
commit 09c1b7c665
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
111 changed files with 4978 additions and 1367 deletions

View file

@ -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

View file

@ -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: <https://api.slack.com/scopes/chat:write> Grafana OnCall will not impersonate or post
using your handle to slack. It will always post as the bot.
### Perform actions in channels & conversations
- **View messages that directly mention @grafana_oncall in conversations that the app is in**
- **Join public channels in your workspace**
- **Send messages as @grafana_oncall**
- **Send messages as @grafana_oncall with a customized username and avatar**
- **Send messages to channels @grafana_oncall isn't a member of** — users configure channels to publish
Alert Groups in the OnCall's UI, but the bot is usually not a member of those channels.
- **Upload, edit, and delete files as Grafana OnCall** — the bot is using this permission:
<https://api.slack.com/scopes/files:write> to be able to send files to the channel.
The bot will not delete or read files sent by other users.
- **Start direct messages with people**
- **Add and edit emoji reactions**
### Perform actions in your workspace
- **Add shortcuts and/or slash commands that people can use** — the permission is used to add /escalate and /oncall
(deprecated) slack commands.
- **Create and manage user groups** — the permission is used to automatically update user groups linked to on-call
schedules. It will add users once their on-call shift starts and remove them once the on-call shift ends.
- **Set presence for Grafana OnCall**
## Post-install configuration for Slack integration
Configure the following additional settings to ensure Grafana OnCall alerts are routed to the intended Slack channels

View file

@ -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 \

View file

@ -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,

View file

@ -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()

View file

@ -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 = (

View file

@ -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"
)

View file

@ -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'),
),
]

View file

@ -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)],

View file

@ -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(

View file

@ -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,

View file

@ -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():

View file

@ -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()

View file

@ -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(

View file

@ -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()

View file

@ -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.

View file

@ -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

View file

@ -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,
},
]

View file

@ -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",

View file

@ -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)

View file

@ -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,

View file

@ -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):

View file

@ -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()

View file

@ -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}"

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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."
)

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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):

View file

@ -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

View file

@ -0,0 +1 @@
from .slack_messages import create_shift_swap_request_message, update_shift_swap_request_message # noqa: F401

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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]),

View file

@ -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

View file

@ -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]
],

View file

@ -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)

View file

@ -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:

View file

@ -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"}

View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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

View file

@ -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,
},
]

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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",

View file

@ -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,
},

View file

@ -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,
},
]

View file

@ -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,
},

View file

@ -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,
},
]

View file

@ -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,
},

View file

@ -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 = {

View file

@ -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 <!channel>. 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,
},

View file

@ -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,
},
]

View file

@ -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,
},
]

View file

@ -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,
},
),
]

View file

@ -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

View file

@ -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,
},
]

View file

@ -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.

View file

@ -13,9 +13,6 @@ class SlackFormatter(SlackFormatterBase):
self.user_mention_format = "@{}"
self.hyperlink_mention_format = '<a href="{url}">{title}</a>'
def find_user(self, message):
raise NotImplementedError()
def format(self, message):
"""
Overriden original render_text method.

View file

@ -1,76 +0,0 @@
{% extends "admin/change_list.html" %}
{% block content_title %}
<h1> Slack Team Summary </h1>
{% endblock %}
{% block result_list %}
<div class="results">
<style>
.bar-chart {
display: flex;
justify-content: space-around;
height: 160px;
padding-top: 60px;
overflow: hidden;
}
.bar-chart .bar {
flex: 100%;
align-self: flex-end;
margin-right: 2px;
position: relative;
background-color: #79aec8;
}
.bar-chart .bar:last-child {
margin: 0;
}
.bar-chart .bar:hover {
background-color: #417690;
}
.bar-chart .bar .bar-tooltip {
position: relative;
z-index: 999;
}
.bar-chart .bar .bar-tooltip {
position: absolute;
top: -60px;
left: 50%;
transform: translateX(-50%);
text-align: center;
font-weight: bold;
{% comment %} opacity: 0; {% endcomment %}
}
.bar-chart .bar:hover .bar-tooltip {
opacity: 1;
}
</style>
<h2>Daily Active Teams:</h2>
<div class="results">
<div class="bar-chart">
{% for x in summary_over_time %}
<div class="bar" style="height:{{x.pct}}%">
<div class="bar-tooltip">
{{x.total | default:0 }}<br>
{{x.period | date:"d/m/Y"}}
</div>
</div>
{% endfor %}
</div>
</div>
<br>
<h2>Registered Teams:</h2>
<div class="results">
<div class="bar-chart">
{% for x in registered_teams %}
<div class="bar" style="height:{{x.pct}}%">
<div class="bar-tooltip">
{{x.total | default:0 }}<br>
{{x.period | date:"d/m/Y"}}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block pagination %}{% endblock %}

View file

@ -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)

View file

@ -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,

View file

@ -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}

View file

@ -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()

View file

@ -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

View file

@ -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",
]

View file

@ -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 `<hr>`, 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",
]

View file

@ -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.
"""

View file

@ -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",
]

View file

@ -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
)

View file

@ -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.
"""

View file

@ -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
"""

View file

@ -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

View file

@ -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`.
"""

View file

@ -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.
"""

View file

@ -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.
"""

View file

@ -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]

View file

@ -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).
"""

View file

@ -6,7 +6,6 @@ from .views import (
ResetSlackView,
SignupRedirectView,
SlackEventApiEndpointView,
StopAnalyticsReporting,
)
urlpatterns = [
@ -18,7 +17,6 @@ urlpatterns = [
path("install_redirect/<str:subscription>/<str:utm>/", InstallLinkRedirectView.as_view()),
path("signup_redirect/", SignupRedirectView.as_view()),
path("signup_redirect/<str:subscription>/<str:utm>/", 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"),
]

View file

@ -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"<!date^{timestamp}^{{{format}}} {{time}}|{fallback}>"
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}"

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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):

View file

@ -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(

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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"},

Some files were not shown because too many files have changed in this diff Show more