commit
09c1b7c665
111 changed files with 4978 additions and 1367 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
## v1.3.20 (2023-07-31)
|
||||
|
||||
### Added
|
||||
|
||||
- Add filter_shift_swaps endpoint to schedules API ([#2684](https://github.com/grafana/oncall/pull/2684))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix helm env variable validation logic when specifying Twilio auth related values by @njohnstone2 ([#2674](https://github.com/grafana/oncall/pull/2674))
|
||||
- Fixed mobile app verification not sending SMS to phone number ([#2687](https://github.com/grafana/oncall/issues/2687))
|
||||
|
||||
## v1.3.19 (2023-07-28)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix one of the latest migrations failing on SQLite by @vadimkerr ([#2680](https://github.com/grafana/oncall/pull/2680))
|
||||
|
||||
### Added
|
||||
|
||||
- Apply swap requests details to schedule events ([#2677](https://github.com/grafana/oncall/pull/2677))
|
||||
|
||||
## v1.3.18 (2023-07-28)
|
||||
|
||||
|
|
@ -13,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
- Update the direct paging feature to page for acknowledged & silenced alert groups,
|
||||
and show a warning for resolved alert groups by @vadimkerr ([#2639](https://github.com/grafana/oncall/pull/2639))
|
||||
- Change calls to get instances from GCOM to paginate by @mderynck ([#2669](https://github.com/grafana/oncall/pull/2669))
|
||||
- Update checking on-call users to use schedule final events ([#2651](https://github.com/grafana/oncall/pull/2651))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
25
engine/apps/alerts/migrations/0029_auto_20230728_0802.py
Normal file
25
engine/apps/alerts/migrations/0029_auto_20230728_0802.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
engine/apps/schedules/tasks/shift_swaps/__init__.py
Normal file
1
engine/apps/schedules/tasks/shift_swaps/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .slack_messages import create_shift_swap_request_message, update_shift_swap_request_message # noqa: F401
|
||||
64
engine/apps/schedules/tasks/shift_swaps/slack_messages.py
Normal file
64
engine/apps/schedules/tasks/shift_swaps/slack_messages.py
Normal 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)
|
||||
0
engine/apps/schedules/tests/tasks/__init__.py
Normal file
0
engine/apps/schedules/tests/tasks/__init__.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
201
engine/apps/slack/scenarios/shift_swap_requests.py
Normal file
201
engine/apps/slack/scenarios/shift_swap_requests.py
Normal 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,
|
||||
},
|
||||
]
|
||||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
8
engine/apps/slack/types/__init__.py
Normal file
8
engine/apps/slack/types/__init__.py
Normal 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
|
||||
256
engine/apps/slack/types/block_elements.py
Normal file
256
engine/apps/slack/types/block_elements.py
Normal 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",
|
||||
]
|
||||
229
engine/apps/slack/types/blocks.py
Normal file
229
engine/apps/slack/types/blocks.py
Normal 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",
|
||||
]
|
||||
215
engine/apps/slack/types/common.py
Normal file
215
engine/apps/slack/types/common.py
Normal 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.
|
||||
"""
|
||||
194
engine/apps/slack/types/composition_objects.py
Normal file
194
engine/apps/slack/types/composition_objects.py
Normal 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",
|
||||
]
|
||||
24
engine/apps/slack/types/interaction_payloads/__init__.py
Normal file
24
engine/apps/slack/types/interaction_payloads/__init__.py
Normal 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
|
||||
)
|
||||
118
engine/apps/slack/types/interaction_payloads/block_actions.py
Normal file
118
engine/apps/slack/types/interaction_payloads/block_actions.py
Normal 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.
|
||||
"""
|
||||
|
|
@ -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
|
||||
"""
|
||||
|
|
@ -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
|
||||
20
engine/apps/slack/types/interaction_payloads/shortcuts.py
Normal file
20
engine/apps/slack/types/interaction_payloads/shortcuts.py
Normal 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`.
|
||||
"""
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
|
@ -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.
|
||||
"""
|
||||
60
engine/apps/slack/types/scenario_routes.py
Normal file
60
engine/apps/slack/types/scenario_routes.py
Normal 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]
|
||||
90
engine/apps/slack/types/views.py
Normal file
90
engine/apps/slack/types/views.py
Normal 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).
|
||||
"""
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue