commit
587ef5ae25
81 changed files with 2946 additions and 315 deletions
|
|
@ -27,6 +27,7 @@ steps:
|
|||
- apt-get install zip
|
||||
- cd grafana-plugin
|
||||
- yarn sign
|
||||
- if [ ! -f dist/MANIFEST.txt ]; then echo "Sign failed, MANIFEST.txt not created, aborting." && exit 1; fi
|
||||
- yarn ci-build:finish
|
||||
- yarn ci-package
|
||||
- cd ci/dist
|
||||
|
|
@ -194,6 +195,7 @@ steps:
|
|||
- apt-get install zip
|
||||
- cd grafana-plugin
|
||||
- yarn sign
|
||||
- if [ ! -f dist/MANIFEST.txt ]; then echo "Sign failed, MANIFEST.txt not created, aborting." && exit 1; fi
|
||||
- yarn ci-build:finish
|
||||
- yarn ci-package
|
||||
- cd ci/dist
|
||||
|
|
@ -415,6 +417,6 @@ kind: secret
|
|||
name: drone_token
|
||||
---
|
||||
kind: signature
|
||||
hmac: f77d17560f910f1a99ab8230674dc25c226d2b3c73cb90e63e53fb8ba760d57a
|
||||
hmac: 662c2be2ccdd106ae4f23a557f981ef601d9693b0333e0bcda7189ddf16fb49a
|
||||
|
||||
...
|
||||
|
|
|
|||
23
.github/verify-public-docs-updated.sh
vendored
Executable file
23
.github/verify-public-docs-updated.sh
vendored
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/bin/bash
|
||||
|
||||
ENGINE_DIR="engine"
|
||||
UI_DIR="grafana-plugin"
|
||||
PUBLIC_DOCS_DIR="docs"
|
||||
|
||||
DIRS_CHANGED=$(git diff HEAD~1 --name-only | xargs dirname | sort | uniq) # https://stackoverflow.com/a/73149899/3902555
|
||||
|
||||
if [[ $DIRS_CHANGED =~ $ENGINE_DIR ]] || [[ $DIRS_CHANGED =~ $UI_DIR ]]; then
|
||||
echo "Changes were made to the ${ENGINE_DIR} and/or ${UI_DIR} directories"
|
||||
|
||||
# check if we have any changes to the public docs directory as well. If not,
|
||||
if [[ ! $DIRS_CHANGED =~ $PUBLIC_DOCS_DIR ]]; then
|
||||
echo "Changes were not made to the public documentation (${PUBLIC_DOCS_DIR} directory). Either update the documentation accordingly with your changes, or add the 'no public docs' label if changes to the public docs are not necessary for your PR."
|
||||
exit 1
|
||||
else
|
||||
echo "Changes were also made to the public documentation. Thank you!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
else
|
||||
echo "Changes were not made to either the ${ENGINE_DIR} or ${UI_DIR} directories"
|
||||
fi
|
||||
42
.github/workflows/helm_release_pr.yml
vendored
Normal file
42
.github/workflows/helm_release_pr.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
name: Create PR to release updated oncall Helm chart
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
jobs:
|
||||
update-helm-chart-versions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Define app_version and helm version
|
||||
id: tags
|
||||
run: |
|
||||
# Strip git ref prefix from version
|
||||
APP_VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
# Strip "v" prefix from tag name
|
||||
[[ "${{ github.ref }}" == "refs/tags/"* ]] && HELM_VERSION=$(echo $APP_VERSION | sed -e 's/^v//')
|
||||
echo "::set-output name=app_version::$APP_VERSION"
|
||||
echo "::set-output name=helm_version::$HELM_VERSION"
|
||||
|
||||
- name: Update oncall Helm chart Chart.yaml
|
||||
uses: fjogeleit/yaml-update-action@v0.12.3
|
||||
with:
|
||||
valueFile: 'helm/oncall/Chart.yaml'
|
||||
branch: helm-release/${{ steps.tags.outputs.helm_version }}
|
||||
targetBranch: main
|
||||
masterBranchName: main
|
||||
createPR: 'true'
|
||||
description: "Merge this PR to `main` branch to start another
|
||||
[github actions job](https://github.com/grafana/oncall/blob/dev/.github/workflows/helm_release.yml)
|
||||
that will release the updated version of the chart
|
||||
(version: ${{ steps.tags.outputs.helm_version }}, appVersion: ${{ steps.tags.outputs.app_version }})
|
||||
into `grafana/helm-charts` helm repository. \n\n
|
||||
This PR was created automatically by this
|
||||
[github action](https://github.com/grafana/oncall/blob/dev/.github/workflows/helm_release_pr.yml)."
|
||||
message: 'Release oncall Helm chart ${{ steps.tags.outputs.helm_version }}'
|
||||
changes: |
|
||||
{
|
||||
"version": "${{ steps.tags.outputs.helm_version }}",
|
||||
"appVersion": "${{ steps.tags.outputs.app_version }}"
|
||||
}
|
||||
23
.github/workflows/verify-changelog-updated.yml
vendored
Normal file
23
.github/workflows/verify-changelog-updated.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
name: Verify CHANGELOG updated
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
verfiy-changelog-updated:
|
||||
name: Verify CHANGELOG updated
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Changelog check
|
||||
uses: Zomzog/changelog-checker@v1.3.0
|
||||
with:
|
||||
fileName: CHANGELOG.md
|
||||
noChangelogLabel: no changelog
|
||||
checkNotification: Simple
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
20
.github/workflows/verify-public-docs-updated.yml
vendored
Normal file
20
.github/workflows/verify-public-docs-updated.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: Verify public documentation updated
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
verify-public-docs-updated:
|
||||
name: Verify public documentation updated
|
||||
# Don't run this job if the "no public docs" label is applied to the PR
|
||||
# https://github.com/orgs/community/discussions/26712#discussioncomment-3253012
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'no public docs')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Public documentation checker
|
||||
run: ./.github/verify-public-docs-updated.sh
|
||||
|
|
@ -10,6 +10,10 @@ repos:
|
|||
files: ^tools/pagerduty-migrator
|
||||
args:
|
||||
[--settings-file=tools/pagerduty-migrator/.isort.cfg, --filter-files]
|
||||
- id: isort
|
||||
name: isort - dev/scripts
|
||||
files: ^dev/scripts
|
||||
args: [--settings-file=dev/scripts/.isort.cfg, --filter-files]
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.3.0
|
||||
|
|
@ -20,6 +24,9 @@ repos:
|
|||
- id: black
|
||||
name: black - pd-migrator
|
||||
files: ^tools/pagerduty-migrator
|
||||
- id: black
|
||||
name: black - dev/scripts
|
||||
files: ^dev/scripts
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 3.9.2
|
||||
|
|
@ -40,6 +47,17 @@ repos:
|
|||
"--select=C,E,F,W,B,B950",
|
||||
"--extend-ignore=E203,E501",
|
||||
]
|
||||
- id: flake8
|
||||
name: flake8 - dev/scripts
|
||||
files: ^dev/scripts
|
||||
# Make sure config is compatible with black
|
||||
# https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#line-length
|
||||
args:
|
||||
[
|
||||
--max-line-length=88,
|
||||
"--select=C,E,F,W,B,B950",
|
||||
"--extend-ignore=E203,E501",
|
||||
]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v8.25.0
|
||||
|
|
@ -87,7 +105,7 @@ repos:
|
|||
hooks:
|
||||
- id: markdownlint
|
||||
name: markdownlint
|
||||
entry: markdownlint --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist --ignore docs **/*.md
|
||||
entry: markdownlint --fix --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist --ignore docs **/*.md
|
||||
- id: markdownlint
|
||||
name: markdownlint - docs
|
||||
entry: markdownlint --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist -c ./docs/.markdownlint.json ./docs/**/*.md
|
||||
entry: markdownlint --fix -c ./docs/.markdownlint.json ./docs/**/*.md
|
||||
|
|
|
|||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v1.1.18 (2023-01-25)
|
||||
|
||||
### Added
|
||||
|
||||
- Add Slack slash command allowing to trigger a direct page via a manually created alert group
|
||||
- Remove resolved and acknowledged filters as we switched to status ([#1201](https://github.com/grafana/oncall/pull/1201))
|
||||
- Add sync with grafana on /users and /teams api calls from terraform plugin
|
||||
|
||||
### Changed
|
||||
|
||||
- Allow users with `viewer` role to fetch cloud connection status using the internal API ([#1181](https://github.com/grafana/oncall/pull/1181))
|
||||
- When removing the Slack ChatOps integration, make it more explicit to the user what the implications of doing so are
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed duplicate API call, in the UI on plugin initial load, to `GET /api/internal/v1/alert_receive_channels`
|
||||
- Increased plugin startup speed ([#1200](https://github.com/grafana/oncall/pull/1200))
|
||||
|
||||
## v1.1.18 (2023-01-18)
|
||||
|
||||
### Added
|
||||
|
|
@ -20,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Changed
|
||||
|
||||
- Modified how the `Organization.is_rbac_permissions_enabled` flag is set,
|
||||
based on whether we are dealing with an open-source, or cloud installation
|
||||
based on whether we are dealing with an open-source, or cloud installation
|
||||
- Backend implementation to support direct user/schedule paging
|
||||
- Changed documentation links to open in new window
|
||||
- Remove helm chart signing
|
||||
|
|
|
|||
1
dev/.gitignore
vendored
1
dev/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
.env.dev
|
||||
grafana.dev.ini
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
- [Running the project](#running-the-project)
|
||||
- [`COMPOSE_PROFILES`](#compose_profiles)
|
||||
- [`GRAFANA_VERSION`](#grafana_version)
|
||||
- [Configuring Grafana](#configuring-grafana)
|
||||
- [Django Silk Profiling](#django-silk-profiling)
|
||||
- [Running backend services outside Docker](#running-backend-services-outside-docker)
|
||||
- [Useful `make` commands](#useful-make-commands)
|
||||
- [Setting environment variables](#setting-environment-variables)
|
||||
|
|
@ -14,6 +16,8 @@
|
|||
- [django.db.utils.OperationalError: (1366, "Incorrect string value")](#djangodbutilsoperationalerror-1366-incorrect-string-value)
|
||||
- [/bin/sh: line 0: cd: grafana-plugin: No such file or directory](#binsh-line-0-cd-grafana-plugin-no-such-file-or-directory)
|
||||
- [Encountered error while trying to install package - grpcio](#encountered-error-while-trying-to-install-package---grpcio)
|
||||
- [distutils.errors.CompileError: command '/usr/bin/clang' failed with exit code 1](#distutilserrorscompileerror-command-usrbinclang-failed-with-exit-code-1)
|
||||
- [symbol not found in flat namespace '\_EVP_DigestSignUpdate'](#symbol-not-found-in-flat-namespace-_evp_digestsignupdate)
|
||||
- [IDE Specific Instructions](#ide-specific-instructions)
|
||||
- [PyCharm](#pycharm)
|
||||
|
||||
|
|
@ -80,6 +84,33 @@ If you would like to change the version of Grafana being run, simply pass in a `
|
|||
to `make start` (or alternatively set it in your `.env.dev` file). The value of this environment variable should be a
|
||||
valid `grafana/grafana` published Docker [image tag](https://hub.docker.com/r/grafana/grafana/tags).
|
||||
|
||||
### Configuring Grafana
|
||||
|
||||
This section is applicable for when you are running a Grafana container inside of `docker-compose` and you would like
|
||||
to modify your Grafana instance's provisioning configuration.
|
||||
|
||||
The following commands assume you run them from the root of the project:
|
||||
|
||||
```bash
|
||||
touch ./dev/grafana.dev.ini
|
||||
# make desired changes to ./dev/grafana.dev.ini then run
|
||||
touch .env && ./dev/add_env_var.sh GRAFANA_DEV_PROVISIONING ./dev/grafana.dev.ini .env
|
||||
```
|
||||
|
||||
The next time you start the project via `docker-compose`, the `grafana` container will have `./dev/grafana.dev.ini`
|
||||
volume mounted inside the container.
|
||||
|
||||
### Django Silk Profiling
|
||||
|
||||
In order to setup [`django-silk`](https://github.com/jazzband/django-silk) for local profiling, perform the following
|
||||
steps:
|
||||
|
||||
1. `make engine-manage CMD="createsuperuser"` - follow CLI prompts to create a Django superuser
|
||||
2. Visit <http://localhost:8080/django-admin> and login using the credentials you created in step #2
|
||||
|
||||
You should now be able to visit <http://localhost:8080/silk/> and see the Django Silk UI.
|
||||
See the `django-silk` documentation [here](https://github.com/jazzband/django-silk) for more information.
|
||||
|
||||
### Running backend services outside Docker
|
||||
|
||||
By default everything runs inside Docker. If you would like to run the backend services outside of Docker
|
||||
|
|
@ -306,6 +337,49 @@ Use a `conda` virtualenv, and then run the following when installing the engine
|
|||
GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1 GRPC_PYTHON_BUILD_SYSTEM_ZLIB=1 pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### distutils.errors.CompileError: command '/usr/bin/clang' failed with exit code 1
|
||||
|
||||
See solution for "Encountered error while trying to install package - grpcio" [here](#encountered-error-while-trying-to-install-package---grpcio)
|
||||
|
||||
### symbol not found in flat namespace '\_EVP_DigestSignUpdate'
|
||||
|
||||
**Problem:**
|
||||
|
||||
This problem seems to occur when running the Celery process, outside of `docker-compose`
|
||||
(via `make run-backend-celery`), and using a `conda` virtual environment.
|
||||
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
```bash
|
||||
conda create --name oncall-dev python=3.9.13
|
||||
conda activate oncall-dev
|
||||
make backend-bootstrap
|
||||
make run-backend-celery
|
||||
File "~/oncall/engine/engine/__init__.py", line 5, in <module>
|
||||
from .celery import app as celery_app
|
||||
File "~/oncall/engine/engine/celery.py", line 11, in <module>
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
File "/opt/homebrew/Caskroom/miniconda/base/envs/oncall-dev/lib/python3.9/site-packages/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py", line 20, in <module>
|
||||
from grpc import ChannelCredentials, Compression
|
||||
File "/opt/homebrew/Caskroom/miniconda/base/envs/oncall-dev/lib/python3.9/site-packages/grpc/__init__.py", line 22, in <module>
|
||||
from grpc import _compression
|
||||
File "/opt/homebrew/Caskroom/miniconda/base/envs/oncall-dev/lib/python3.9/site-packages/grpc/_compression.py", line 20, in <module>
|
||||
from grpc._cython import cygrpc
|
||||
ImportError: dlopen(/opt/homebrew/Caskroom/miniconda/base/envs/oncall-dev/lib/python3.9/site-packages/grpc/_cython/cygrpc.cpython-39-darwin.so, 0x0002): symbol not found in flat namespace '_EVP_DigestSignUpdate'
|
||||
```
|
||||
|
||||
<!-- markdownlint-enable MD013 -->
|
||||
|
||||
**Solution:**
|
||||
|
||||
[This solution](https://github.com/grpc/grpc/issues/15510#issuecomment-392012594) posted in a GitHub issue thread for
|
||||
the `grpc/grpc` repository, fixes the issue:
|
||||
|
||||
```bash
|
||||
conda install grpcio
|
||||
make run-backend-celery
|
||||
```
|
||||
|
||||
## IDE Specific Instructions
|
||||
|
||||
### PyCharm
|
||||
|
|
|
|||
2
dev/scripts/.isort.cfg
Normal file
2
dev/scripts/.isort.cfg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[settings]
|
||||
profile=black
|
||||
45
dev/scripts/generate-fake-data/README.md
Normal file
45
dev/scripts/generate-fake-data/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Fake Data Generator Script
|
||||
|
||||
This script can be used to easily populate fake data into your local Grafana/OnCall setup. Currently the script is
|
||||
capable of generating the following objects:
|
||||
|
||||
- teams
|
||||
- users
|
||||
- schedules
|
||||
- schedule on call shifts
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Create/active a Python 3.9 virtual environment
|
||||
2. `pip install -r requirements.txt`
|
||||
3. Must have a local version of Grafana and OnCall up and running
|
||||
4. Generate an API key inside of Grafana OnCall
|
||||
|
||||
## How to run
|
||||
|
||||
**Note**: The below flag values assume you are running a `grafana` container locally via the `docker-compose` setup.
|
||||
The reason why there is a few separate steps involved is that we need to first create teams and users in the Grafana
|
||||
instance. Later on, in order to create OnCall schedules/oncall-shifts, we need the OnCall user ID to do so. There is
|
||||
currently no way to trigger a Grafana -> OnCall data sync via the public API, hence the manual step in the middle
|
||||
to have data synced between Grafana and OnCall.
|
||||
|
||||
1. Create teams and users in Grafana. The `teams` and `users` flags represent the number of teams and users you would
|
||||
like to create respectively:
|
||||
|
||||
```bash
|
||||
# by default this will generate 10 teams and 1000 users
|
||||
python main.py generate_teams_and_users
|
||||
```
|
||||
|
||||
See `python main.py generate_teams_and_users -h` for more information on how to run the command.
|
||||
|
||||
2. Head to your OnCall setup, and trigger a Grafana -> OnCall data sync by visiting the plugin page.
|
||||
3. Create schedules and on call shifts in OnCall. The `schedules` flag represents the number of OnCall schedules you
|
||||
would like to generate. **Note** that one on call shift is created for each schedule:
|
||||
|
||||
```bash
|
||||
# by default this will generate 100 schedules
|
||||
python main.py generate_schedules_and_oncall_shifts --oncall-api-token=<oncall-api-key>
|
||||
```
|
||||
|
||||
See `python main.py generate_schedules_and_oncall_shifts -h` for more information on how to run the command.
|
||||
305
dev/scripts/generate-fake-data/main.py
Normal file
305
dev/scripts/generate-fake-data/main.py
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import argparse
|
||||
import asyncio
|
||||
import math
|
||||
import random
|
||||
import typing
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
from faker import Faker
|
||||
from tqdm.asyncio import tqdm
|
||||
|
||||
fake = Faker()
|
||||
|
||||
TEAMS_USERS_COMMAND = "generate_teams_and_users"
|
||||
SCHEDULES_ONCALL_SHIFTS_COMMAND = "generate_schedules_and_oncall_shifts"
|
||||
|
||||
GRAFANA_API_URL = None
|
||||
ONCALL_API_URL = None
|
||||
ONCALL_API_TOKEN = None
|
||||
|
||||
|
||||
class OnCallApiUser(typing.TypedDict):
|
||||
id: str
|
||||
|
||||
|
||||
class OnCallApiOnCallShift(typing.TypedDict):
|
||||
id: str
|
||||
|
||||
|
||||
class OnCallApiListUsersResponse(typing.TypedDict):
|
||||
results: typing.List[OnCallApiUser]
|
||||
|
||||
|
||||
class GrafanaAPIUser(typing.TypedDict):
|
||||
id: int
|
||||
|
||||
|
||||
def _generate_unique_email() -> str:
|
||||
user = fake.profile()
|
||||
return f'{uuid.uuid4()}-{user["mail"]}'
|
||||
|
||||
|
||||
async def _grafana_api_request(
|
||||
http_session: aiohttp.ClientSession, method: str, url: str, **request_kwargs
|
||||
) -> typing.Awaitable[typing.Dict]:
|
||||
resp = await http_session.request(
|
||||
method, f"{GRAFANA_API_URL}{url}", **request_kwargs
|
||||
)
|
||||
return await resp.json()
|
||||
|
||||
|
||||
async def _oncall_api_request(
|
||||
http_session: aiohttp.ClientSession, method: str, url: str, **request_kwargs
|
||||
) -> typing.Awaitable[typing.Dict]:
|
||||
resp = await http_session.request(
|
||||
method,
|
||||
f"{ONCALL_API_URL}{url}",
|
||||
headers={"Authorization": ONCALL_API_TOKEN},
|
||||
**request_kwargs,
|
||||
)
|
||||
return await resp.json()
|
||||
|
||||
|
||||
def generate_team(
|
||||
http_session: aiohttp.ClientSession, org_id: int
|
||||
) -> typing.Callable[[], typing.Awaitable[typing.Dict]]:
|
||||
"""
|
||||
https://grafana.com/docs/grafana/latest/developers/http_api/team/#add-team
|
||||
"""
|
||||
|
||||
def _generate_team() -> typing.Awaitable[typing.Dict]:
|
||||
return _grafana_api_request(
|
||||
http_session,
|
||||
"POST",
|
||||
"/api/teams",
|
||||
json={
|
||||
"name": str(uuid.uuid4()),
|
||||
"email": _generate_unique_email(),
|
||||
"orgId": org_id,
|
||||
},
|
||||
)
|
||||
|
||||
return _generate_team
|
||||
|
||||
|
||||
def generate_user(
|
||||
http_session: aiohttp.ClientSession, org_id: int
|
||||
) -> typing.Callable[[], typing.Awaitable[typing.Dict]]:
|
||||
"""
|
||||
https://grafana.com/docs/grafana/latest/developers/http_api/admin/#global-users
|
||||
"""
|
||||
|
||||
async def _generate_user() -> typing.Awaitable[typing.Dict]:
|
||||
user = fake.profile()
|
||||
|
||||
# create the user in grafana
|
||||
grafana_user: GrafanaAPIUser = await _grafana_api_request(
|
||||
http_session,
|
||||
"POST",
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"name": user["name"],
|
||||
"email": _generate_unique_email(),
|
||||
"login": str(uuid.uuid4()),
|
||||
"password": fake.password(length=20),
|
||||
"OrgId": org_id,
|
||||
},
|
||||
)
|
||||
|
||||
# update the user's basic role in grafana to Admin
|
||||
# https://grafana.com/docs/grafana/latest/developers/http_api/org/#updates-the-given-user
|
||||
await _grafana_api_request(
|
||||
http_session,
|
||||
"PATCH",
|
||||
f'/api/org/users/{grafana_user["id"]}',
|
||||
json={"role": "Admin"},
|
||||
)
|
||||
|
||||
return grafana_user
|
||||
|
||||
return _generate_user
|
||||
|
||||
|
||||
def generate_schedule(
|
||||
http_session: aiohttp.ClientSession, oncall_shift_ids: typing.List[str]
|
||||
) -> typing.Callable[[], typing.Awaitable[typing.Dict]]:
|
||||
def _generate_schedule() -> typing.Awaitable[typing.Dict]:
|
||||
# Create a schedule
|
||||
# https://grafana.com/docs/oncall/latest/oncall-api-reference/schedules/#create-a-schedule
|
||||
return _oncall_api_request(
|
||||
http_session,
|
||||
"POST",
|
||||
"/api/v1/schedules",
|
||||
json={
|
||||
"name": f"Schedule {uuid.uuid4()}",
|
||||
"type": "calendar",
|
||||
"time_zone": "UTC",
|
||||
"shifts": oncall_shift_ids,
|
||||
},
|
||||
)
|
||||
|
||||
return _generate_schedule
|
||||
|
||||
|
||||
def _bulk_generate_data(
|
||||
iterations: int,
|
||||
data_generator_func: typing.Callable[[], typing.Awaitable[typing.Dict]],
|
||||
) -> typing.Awaitable[typing.List[typing.Dict]]:
|
||||
return tqdm.gather(
|
||||
*[asyncio.ensure_future(data_generator_func()) for _ in range(iterations)]
|
||||
)
|
||||
|
||||
|
||||
async def _generate_grafana_teams_and_users(
|
||||
args: argparse.Namespace, http_session: aiohttp.ClientSession
|
||||
) -> typing.Awaitable[None]:
|
||||
global GRAFANA_API_URL
|
||||
GRAFANA_API_URL = args.grafana_api_url
|
||||
|
||||
org_id = args.grafana_org_id
|
||||
|
||||
print("Generating team(s)")
|
||||
await _bulk_generate_data(args.teams, generate_team(http_session, org_id))
|
||||
|
||||
print("Generating user(s)")
|
||||
await _bulk_generate_data(args.users, generate_user(http_session, org_id))
|
||||
|
||||
print(
|
||||
f"""
|
||||
Grafana teams and users generated
|
||||
Now head to the OnCall plugin and manually visit the plugin to trigger a sync. This will sync grafana
|
||||
teams/users to OnCall. Once completed, you can run the {SCHEDULES_ONCALL_SHIFTS_COMMAND} command.
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def _generate_oncall_schedules_and_oncall_shifts(
|
||||
args: argparse.Namespace, http_session: aiohttp.ClientSession
|
||||
) -> typing.Awaitable[None]:
|
||||
global ONCALL_API_URL, ONCALL_API_TOKEN
|
||||
ONCALL_API_URL = args.oncall_api_url
|
||||
ONCALL_API_TOKEN = args.oncall_api_token
|
||||
|
||||
today = datetime.now()
|
||||
|
||||
print("Fetching users from OnCall API")
|
||||
|
||||
# Fetch users from the OnCall API
|
||||
users: OnCallApiListUsersResponse = await _oncall_api_request(
|
||||
http_session, "GET", "/api/v1/users"
|
||||
)
|
||||
user_ids: typing.List[str] = [u["id"] for u in users["results"]]
|
||||
num_users = len(user_ids)
|
||||
|
||||
print(f"Fetched {num_users} user(s) from the OnCall API")
|
||||
|
||||
async def _create_oncall_shift(shift_start_time: str) -> typing.Awaitable[str]:
|
||||
"""
|
||||
Creates an eight hour shift.
|
||||
|
||||
`shift_start_time` - ex. 09:00:00, 15:00:00
|
||||
|
||||
https://grafana.com/docs/oncall/latest/oncall-api-reference/on_call_shifts/#create-an-oncall-shift
|
||||
"""
|
||||
new_shift: OnCallApiOnCallShift = await _oncall_api_request(
|
||||
http_session,
|
||||
"POST",
|
||||
"/api/v1/on_call_shifts",
|
||||
json={
|
||||
"name": f"On call shift{uuid.uuid4()}",
|
||||
"type": "rolling_users",
|
||||
"start": today.strftime(f"%Y-%m-%dT{shift_start_time}"),
|
||||
"time_zone": "UTC",
|
||||
"duration": 60 * 60 * 8, # 8 hours
|
||||
"frequency": "daily",
|
||||
"week_start": "MO",
|
||||
"rolling_users": [
|
||||
[u] for u in random.choices(user_ids, k=math.floor(num_users / 2))
|
||||
],
|
||||
"start_rotation_from_user_index": 0,
|
||||
"team_id": None,
|
||||
},
|
||||
)
|
||||
|
||||
oncall_shift_id = new_shift["id"]
|
||||
print(f"Generated OnCall shift w/ ID {oncall_shift_id}")
|
||||
return oncall_shift_id
|
||||
|
||||
print("Creating three 8h on-call shifts")
|
||||
morning_shift_id = await _create_oncall_shift("00:00:00")
|
||||
afternoon_shift_id = await _create_oncall_shift("08:00:00")
|
||||
evening_shift_id = await _create_oncall_shift("16:00:00")
|
||||
|
||||
print("Generating schedules(s)")
|
||||
await _bulk_generate_data(
|
||||
args.schedules,
|
||||
generate_schedule(
|
||||
http_session, [morning_shift_id, afternoon_shift_id, evening_shift_id]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def main() -> typing.Awaitable[None]:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Set of commands to help generate fake data in a Grafana OnCall setup."
|
||||
)
|
||||
subparsers = parser.add_subparsers(help="sub-command help")
|
||||
|
||||
grafana_command_parser = subparsers.add_parser(
|
||||
TEAMS_USERS_COMMAND,
|
||||
description="Command to generate teams and users in Grafana",
|
||||
)
|
||||
grafana_command_parser.set_defaults(func=_generate_grafana_teams_and_users)
|
||||
grafana_command_parser.add_argument(
|
||||
"--grafana-api-url",
|
||||
help="Grafana API URL. This should include the basic authentication username/password in the URL. ex. http://oncall:oncall@localhost:3000",
|
||||
default="http://oncall:oncall@localhost:3000",
|
||||
)
|
||||
grafana_command_parser.add_argument(
|
||||
"--grafana-org-id",
|
||||
help="Org ID, in Grafana, of the org that you would like to generate data for",
|
||||
type=int,
|
||||
default=1,
|
||||
)
|
||||
grafana_command_parser.add_argument(
|
||||
"-t", "--teams", help="Number of teams to generate", default=10, type=int
|
||||
)
|
||||
grafana_command_parser.add_argument(
|
||||
"-u", "--users", help="Number of users to generate", default=1_000, type=int
|
||||
)
|
||||
|
||||
oncall_command_parser = subparsers.add_parser(
|
||||
SCHEDULES_ONCALL_SHIFTS_COMMAND,
|
||||
description="Command to generate schedules and on-call shifts in OnCall",
|
||||
)
|
||||
oncall_command_parser.set_defaults(
|
||||
func=_generate_oncall_schedules_and_oncall_shifts
|
||||
)
|
||||
oncall_command_parser.add_argument(
|
||||
"--oncall-api-url",
|
||||
help="OnCall API URL",
|
||||
default="http://localhost:8080",
|
||||
)
|
||||
oncall_command_parser.add_argument(
|
||||
"--oncall-api-token", help="OnCall API token", required=True
|
||||
)
|
||||
oncall_command_parser.add_argument(
|
||||
"-s",
|
||||
"--schedules",
|
||||
help="Number of schedules to generate",
|
||||
default=100,
|
||||
type=int,
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
connector=aiohttp.TCPConnector(limit=5)
|
||||
) as session:
|
||||
await args.func(args, session)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
3
dev/scripts/generate-fake-data/requirements.txt
Normal file
3
dev/scripts/generate-fake-data/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
aiohttp==3.8.3
|
||||
Faker==16.4.0
|
||||
tqdm==4.64.1
|
||||
|
|
@ -26,6 +26,7 @@ x-env-vars: &oncall-env-vars
|
|||
GRAFANA_API_URL: http://localhost:3000
|
||||
GOOGLE_APPLICATION_CREDENTIALS: /etc/app/gcp_service_account.json
|
||||
FCM_PROJECT_ID: oncall-mobile-dev
|
||||
SILK_PROFILER_ENABLED: True
|
||||
|
||||
# basically this is needed because the oncall backend containers have been configured to communicate w/ grafana via
|
||||
# http://localhost:3000 (GRAFANA_API_URL). This URL is used in two scenarios:
|
||||
|
|
@ -296,6 +297,7 @@ services:
|
|||
volumes:
|
||||
- grafanadata_dev:/var/lib/grafana
|
||||
- ./grafana-plugin:/var/lib/grafana/plugins/grafana-plugin
|
||||
- ${GRAFANA_DEV_PROVISIONING:-/dev/null}:/etc/grafana/grafana.ini
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -1,68 +1,64 @@
|
|||
---
|
||||
title: On-call schedules
|
||||
aliases:
|
||||
- /docs/oncall/latest/calendar-schedules/
|
||||
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/
|
||||
description: ""
|
||||
description: "Learn more about on-call schedules"
|
||||
keywords:
|
||||
- Grafana
|
||||
- oncall
|
||||
- on-call
|
||||
- schedule
|
||||
- calendar
|
||||
title: Configure and manage on-call schedules
|
||||
weight: 1100
|
||||
---
|
||||
|
||||
# Configure and manage on-call schedules
|
||||
# On-call schedules
|
||||
|
||||
Grafana OnCall allows you to use any calendar service that uses the iCal format to create customized on-call schedules
|
||||
for team members. Using Grafana OnCall, you can create a primary calendar that acts as a read-only schedule, and an
|
||||
override calendar that allows all team members to modify schedules as they change.
|
||||
Grafana OnCall makes it easier to establish consistent and thoughtful on-call coverage while ensuring that alerts don’t
|
||||
go unnoticed. Use Grafana OnCall to:
|
||||
|
||||
To learn more about creating on-call calendars, see the following topics:
|
||||
- Define coverage needs and avoid gaps in coverage
|
||||
- Automate alert escalation
|
||||
- Configure on-call shift notifications
|
||||
|
||||
## Configure and manage on-call schedules
|
||||
This section provides conceptual information about Grafana OnCall schedule options.
|
||||
|
||||
You can use any calendar with an iCal address to schedule on-call times for users. During these times, notifications
|
||||
configured in escalation chains with the **Notify users from an on-call schedule** setting will be sent to the the person
|
||||
scheduled. You can also schedule multiple users for overlapping times, and assign prioritization labels for the user
|
||||
that you would like to notify.
|
||||
## About on-call schedules
|
||||
|
||||
When you create a schedule, you will be able to select a Slack channel, associated with your OnCall account, that will
|
||||
notify users when there are errors or notifications regarding the assigned on-call shifts.
|
||||
An on-call schedule consist of one or more rotations that contain on-call shifts. A schedule must be referenced in the
|
||||
corresponding escalation chain for alert notifications to be sent to an on-call user.
|
||||
|
||||
## Create an on-call schedule calendar
|
||||
A fully configured on-call schedule consists of three main components:
|
||||
|
||||
Create a primary calendar and an optional override calendar to schedule on-call shifts for team members.
|
||||
- **Rotations**: A recurring schedule containing a set of on-call shifts that users rotate through.
|
||||
- **On-call shifts**: The period of time that an individual user is on-call for a particular rotation
|
||||
- **Escalation Chains**: Automated steps that determine who to notify of an alert group.
|
||||
|
||||
1. In the **Scheduling** section of Grafana OnCall, click **+ Create schedule**.
|
||||
1. Give the schedule a name.
|
||||
1. Create a new calendar in your calendar service and locate the secret iCal URL. For example, in a Google calendar,
|
||||
this URL can be found in **Settings > Settings for my calendars > Integrate calendar**.
|
||||
1. Copy the secret iCal URL. In OnCall, paste it into the **Primary schedule for iCal URL** field.
|
||||
The permissions you set when you create the calendar determine who can modify the calendar.
|
||||
1. Click **Create Schedule**.
|
||||
1. Schedule on-call times for team members.
|
||||
## Types of on-call schedules
|
||||
|
||||
Use the Grafana username of team members as the event name to schedule their on-call times. You can take advantage
|
||||
of all of the features of your calendar service.
|
||||
On-call schedules look different for different organizations and even teams. Grafana OnCall offers three different
|
||||
options for managing your on-call schedules, so you can choose the option that best fits your needs.
|
||||
|
||||
1. Create overlapping schedules (optional).
|
||||
### Web-based schedule
|
||||
|
||||
When you create schedules that overlap, you can prioritize a schedule by adding a level marker. For example, if users
|
||||
AliceGrafana and BobGrafana have overlapping schedules, but BobGrafana is the primary contact, you would name his
|
||||
event `[L1] BobGrafana`, AliceGrafana maintains the default `[L0]` status, and would not receive notifications during
|
||||
the overlapping time. You can prioritize up to and including a level 9 prioritization, or `[L9]`.
|
||||
Configure and manage on-call schedules directly in the Grafana OnCall plugin. Easily configure and preview rotations,
|
||||
see teammates' time zones, and add overrides.
|
||||
|
||||
# Create an override calendar (optional)
|
||||
Learn more about [Web-based schedules]({{< relref "web-schedule" >}})
|
||||
|
||||
You can use an override calendar to allow team members to schedule on-call duties that will override the primary schedule.
|
||||
An override option allows flexibility without modifying the primary schedule. Events scheduled on the override calendar
|
||||
will always override overlapping events on the primary calendar.
|
||||
### iCal import
|
||||
|
||||
1. Create a new calendar using the same calendar service you used to create the primary calendar.
|
||||
Use any calendar service that uses the iCal format to manage and customize on-call schedules - Import rotations and
|
||||
shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imports appear in Grafana
|
||||
OnCall as read-only schedules but can be leveraged similarly to a web-based schedule.
|
||||
|
||||
Be sure to set permissions that allow team members to edit the calendar.
|
||||
Learn more about [iCal import schedules]({{< relref "ical-schedules" >}})
|
||||
|
||||
1. In the scheduling section of Grafana OnCall, select the primary calendar you want to override.
|
||||
1. Click **Edit**.
|
||||
1. Enter the secret iCal URL in the **Overrides schedule iCal URL** field and click **Update**.
|
||||
### Terraform
|
||||
|
||||
Use the Grafana OnCall Terraform provider to manage schedules within your “as-code” workflow. Rotations configured
|
||||
via Terraform are automatically added to your schedules in Grafana OnCall. Similar to the iCal import, these schedules
|
||||
read-only and cannot be edited from the UI.
|
||||
|
||||
To learn more, read our [Get started with Grafana OnCall and Terraform](
|
||||
https://grafana.com/blog/2022/08/29/get-started-with-grafana-oncall-and-terraform/) blog post.
|
||||
|
|
|
|||
97
docs/sources/calendar-schedules/ical-schedules/index.md
Normal file
97
docs/sources/calendar-schedules/ical-schedules/index.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
---
|
||||
title: Import on-call schedules
|
||||
aliases:
|
||||
- /docs/oncall/latest/calendar-schedules/ical-schedules/
|
||||
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/ical-schedules/
|
||||
description: "Learn how to manage on-call schedules with iCal import"
|
||||
keywords:
|
||||
- Grafana
|
||||
- oncall
|
||||
- on-call
|
||||
- calendar
|
||||
weight: 300
|
||||
---
|
||||
|
||||
# Import on-call schedules
|
||||
|
||||
Use your existing calendar app with iCal format to manage and customize on-call schedules — import rotations and shifts
|
||||
from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imported schedules appear in Grafana
|
||||
OnCall as read-only schedules but can be leveraged similarly to a web-based schedule.
|
||||
|
||||
## Before you begin
|
||||
|
||||
- Verify that your calendar app supports iCal format
|
||||
- Ensure you have the proper permissions in Grafana OnCall
|
||||
|
||||
## Configure an on-call schedule from iCal import
|
||||
|
||||
There are three key parts to configuring on-call schedules using iCal import:
|
||||
|
||||
1. Create a primary on-call calendar and an optional override calendar in your calendar app.
|
||||
1. Import the calendars into Grafana OnCall and configure additional schedule settings.
|
||||
1. Link your schedule to corresponding escalation chains for alert notifications to be sent to the proper on-call user.
|
||||
|
||||
### Create your on-call schedule calendar
|
||||
|
||||
Create a dedicated calendar to map out your on-call coverage using calendar events. Be sure to take advantage of the
|
||||
features of your calendar app to configure event recurrence, duplicate events, etc.
|
||||
|
||||
>**Note:** The exact steps in this section will vary based on your calendar.
|
||||
|
||||
To create an on-call schedule calendar:
|
||||
|
||||
1. Create a new calendar in your calendar app, then review and adjust default settings as needed.
|
||||
2. In your new calendar, create events that represent on-call shifts. You must use Grafana usernames as the event title
|
||||
3. to associate users with each shift.
|
||||
4. Once your on-call calendar is complete, go to your calendar settings to locate the secret iCal URL. For example, in
|
||||
5. a Google calendar, this URL can be found in **Settings** > **Settings for my calendars** > **Integrate calendar** >
|
||||
6. **Secret address in iCal format**.
|
||||
|
||||
To learn more about how to configure your calendar events, refer to Calendar events.
|
||||
|
||||
### Import calendar to Grafana On-Call
|
||||
|
||||
Once you’ve configured on-call schedules in your calendar app, you can import them via iCal URL to your Grafana OnCall
|
||||
instance.
|
||||
|
||||
>**Note:** Use the secret iCal URL to avoid making the calendar public. If you use the public iCal URL, the calendar
|
||||
> and event details must be public for Grafana OnCall to read your calendar.
|
||||
|
||||
To import an on-call schedule:
|
||||
|
||||
1. In Grafana OnCall, navigate to the **Schedules** tab and click **+ New schedule**.
|
||||
2. Navigate to **Import schedule from iCal URL** and click **+ Create**.
|
||||
3. Copy the secret iCal URL from your calendar and paste it the **Primary schedule iCal URL** field. Repeat this step
|
||||
4. for the **Override schedule iCal URL** field if you have an override calendar.
|
||||
5. Provide a name and review available schedule settings.
|
||||
6. When you’re done, click **Create Schedule**.
|
||||
|
||||
### Create an override calendar (Optional)
|
||||
|
||||
An override calendar allows for on-call flexibility without modifying the primary schedule. You can use an override
|
||||
calendar to enable users to schedule on-call shifts that will override the primary schedule. Events scheduled on the
|
||||
override calendar will always override overlapping events on the primary calendar.
|
||||
|
||||
1. Create a new calendar using the same calendar service you used to create the primary calendar.
|
||||
1. Be sure to set permissions that allow team members to edit the calendar.
|
||||
1. In the **Schedules** tab of Grafana OnCall, select the primary calendar you want to override.Click **Edit**.
|
||||
1. Enter the secret iCal URL in the **Overrides schedule iCal URL** field and click **Update**.
|
||||
|
||||
## Calendar events
|
||||
|
||||
Whether your schedule is basic or complex, consider how your on-call coverage is structured before configuring your
|
||||
calendar events. To minimize the number of calendar events you need to create, try leveraging recurrence settings and
|
||||
event duplication.
|
||||
|
||||
> **Note:** Each calendar event represents one on-call shift for a specific user. For Grafana OnCall to associate a
|
||||
> calendar event with the intended on-call user, you must use their Grafana username as the event title.
|
||||
|
||||
### Create overlapping schedules (optional)
|
||||
|
||||
If you create schedules that overlap, you can prioritize a schedule by adding a level marker to the calendar event
|
||||
title. You can prioritize schedule overlaps using [L0] - [L9] prioritization. Overlapping calendar events that do not
|
||||
contain a level marker result in all overlapping users receiving notifications.
|
||||
|
||||
For example, users AliceGrafana and BobGrafana have overlapping schedules but BobGrafana is the intended primary
|
||||
contact. The calendar events titles would be `[L1] BobGrafana` and `[L0] AliceGrafana` - In this case AliceGrafana
|
||||
maintains the default [L0] status, and would not receive notifications during the overlapping time with BobGrafana.
|
||||
61
docs/sources/calendar-schedules/web-schedule/_index.md
Normal file
61
docs/sources/calendar-schedules/web-schedule/_index.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
title: Web-based schedules
|
||||
aliases:
|
||||
- /docs/oncall/latest/calendar-schedules/web-schedule/
|
||||
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/
|
||||
description: "Learn more about Grafana OnCalls built in schedule tool"
|
||||
keywords:
|
||||
- Grafana
|
||||
- oncall
|
||||
- schedule
|
||||
- calendar
|
||||
weight: 100
|
||||
---
|
||||
|
||||
# About web-based schedules
|
||||
|
||||
Grafana OnCall allows you to map out recurring on-call coverage and automate the escalation of alert notifications to
|
||||
on-call users. Configure and manage on-call schedules directly in the Grafana OnCall plugin to easily customize
|
||||
rotations with a live schedule preview, reference teammates' time zones, and add overrides.
|
||||
|
||||
This topic provides an overview of key components and features.
|
||||
|
||||
For information on how to create a schedule in Grafana OnCall, refer to
|
||||
[Create an on-call schedule]({{< relref "create-schedule" >}})
|
||||
|
||||
>**Note**: User permissions determine which components of Grafana OnCall are available to you.
|
||||
|
||||
## Schedule settings
|
||||
|
||||
Schedule settings are initially configured when a new schedule is created and can be updated at any time by clicking
|
||||
the gear icon next to an existing schedule.
|
||||
|
||||
Available schedule settings:
|
||||
|
||||
- **Slack channel:** Choose a primary Slack channel to send notifications about on-call shifts, such as unassigned
|
||||
- on-call shifts.
|
||||
- **Slack user group:** Choose a Slack user group to receive current on-call updates.
|
||||
- **Notification frequency:** Specify whether or not to send shift notifications to scheduled team members.
|
||||
- **Action for slot when no one is on-call:** Define how your team is notified when an empty shift causes a gap in
|
||||
- on-call coverage.
|
||||
- **Current shift notification settings:** Select how users are notified when their on-call shift begins.
|
||||
- **Next shift notification settings:** Specify how users are notified of upcoming shifts.
|
||||
|
||||
## Schedule view
|
||||
|
||||
The schedule view is a detailed calendar representation of your on-call schedule. It contains three interactive weekly
|
||||
calendars and a 24-hour on-call status bar for visualizing who’s on-call and what time it is for your teammates.
|
||||
|
||||
Understand your schedule view:
|
||||
|
||||
- **Final schedule:** The final schedule provides a combined view of rotations and overrides
|
||||
- **Rotations:** The rotations calendar represents all recurring on-call rotations for a given schedule.
|
||||
- **Overrides:** The override calendar represents temporary adjustments to the recurring on-call schedule. Any events
|
||||
- on this calendar will take precedence over the rotations calendar.
|
||||
|
||||
## Schedule export
|
||||
|
||||
Export on-call schedules from Grafana OnCall to your preferred calendar app with a one-time secret iCal URL. The
|
||||
schedule export allows you to view on-call shifts alongside the rest of your schedule.
|
||||
|
||||
For more information, refer to [Export on-call schedules]({{< relref "calendar-export" >}})
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
title: Export on-call schedules
|
||||
aliases:
|
||||
- /docs/oncall/latest/calendar-schedules/web-schedule/calendar-export/
|
||||
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/calendar-export/
|
||||
description: "Learn how to export an on-call schedule from Grafana OnCall"
|
||||
keywords:
|
||||
- Grafana
|
||||
- oncall
|
||||
- on-call
|
||||
- calendar
|
||||
- iCal export
|
||||
weight: 500
|
||||
---
|
||||
|
||||
# Export on-call schedules
|
||||
|
||||
Export on-call schedules from Grafana OnCall to your preferred calendar app with a one-time secret iCal URL.
|
||||
The schedule export allows you to add on-call schedules to your existing calendar to view on-call shifts alongside the
|
||||
rest of your schedule.
|
||||
|
||||
There are two schedule export options available:
|
||||
|
||||
- **On-call schedule export** - Exports all on-call shifts for a particular schedule, including rotations, overrides,
|
||||
- and assigned users.
|
||||
- **User-specific schedule export** - Exports assigned on-call shifts for a particular user. Use this export option to
|
||||
- add your assigned on-call shifts to your calendar.
|
||||
|
||||
> **Note:** Calendar exports include all scheduled shifts, including those which are lower priority or overridden.
|
||||
|
||||
## Export an on-call schedule
|
||||
|
||||
Use this export option to add all on-call shifts associated with a schedule to a calendar. Best for a team or shared
|
||||
calendars.
|
||||
|
||||
To export a schedule from Grafana OnCall:
|
||||
|
||||
1. In Grafana OnCall, navigate to the **Schedules** tab.
|
||||
2. Open the schedule you’d like to export by clicking on the schedule name.
|
||||
3. Click **Export** in the upper right corner, then click **+ Create iCal link** to generate a secret iCal URL.
|
||||
4. Copy the iCal link and store it somewhere you’ll remember. Once you close the schedule export window, you won't be
|
||||
5. able to access the iCal link.
|
||||
6. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app).
|
||||
|
||||
## Export a user on-call schedule
|
||||
|
||||
Use this export option to add your assigned on-call shifts to your calendar. Best for personal calendars.
|
||||
|
||||
To export your on-call schedule:
|
||||
|
||||
1. In Grafana OnCall, navigate to the **Users** tab.
|
||||
2. Click **View my profile** in the upper right corner.
|
||||
3. From the **User Info** tab, navigate to the iCal link section.
|
||||
4. Click **+ Create iCal link** to generate your secret iCal URL.
|
||||
5. Copy the iCal link and store it somewhere you’ll remember. Once you close your user profile, you won't be able to
|
||||
6. access the iCal link again.
|
||||
7. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app).
|
||||
|
||||
## Revoke an iCal export link
|
||||
|
||||
iCal links are displayed upon creation, and users are advised to copy their link and store it for future reference.
|
||||
To ensure the security of your and your teams' calendar data, after an iCal link is generated, the link is hidden and
|
||||
cannot be accessed again.
|
||||
|
||||
If you need to revoke an iCal link, you can do so anytime. By doing so, any calendar that references the revoked link
|
||||
will lose access to the calendar data.
|
||||
|
||||
To revoke an active iCal link:
|
||||
|
||||
1. Navigate to the schedule or user profile associated with the iCal link.
|
||||
1. For schedules, click **Export** to open the Schedule export window.
|
||||
1. For users, navigate to the iCal link section of the **User info** tab.
|
||||
1. If there is an active iCal link, click **Revoke iCal link**.
|
||||
1. Once revoked, you can generate a new iCal link by clicking **+ Create iCal link**.
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
title: Create on-call schedules
|
||||
aliases:
|
||||
- /docs/oncall/latest/calendar-schedules/web-schedule/create-schedule/
|
||||
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/create-schedule/
|
||||
description: "Create on-call schedules with Grafana OnCall"
|
||||
keywords:
|
||||
- Grafana
|
||||
- oncall
|
||||
- on-call
|
||||
- schedule
|
||||
- calendar
|
||||
weight: 300
|
||||
---
|
||||
|
||||
# Create on-call schedules in Grafana OnCall
|
||||
|
||||
Schedules allow you to map out recurring on-call coverage and automate the escalation of alert notifications to
|
||||
currently on-call users. With Grafana OnCall, you can customize rotations with a live schedule preview to visualize
|
||||
your schedule, add users, reorder users, and reference teammates' time zones.
|
||||
|
||||
To learn more, see [On-call schedules]({{< relref "../../../calendar-schedules" >}}) which provides the fundamental
|
||||
concepts for this task.
|
||||
|
||||
## Before you begin
|
||||
|
||||
- Users with Admin or Editor roles can create, edit and delete schedules.
|
||||
- Users with Viewer role cannot receive alert notifications, therefore, cannot be on-call.
|
||||
|
||||
For more information about permissions, refer to
|
||||
[Manage users and teams for Grafana OnCall]({{< relref "../../../configure-user-settings" >}})
|
||||
|
||||
## Create an on-call schedule
|
||||
|
||||
To create a new on-call schedule:
|
||||
|
||||
1. In Grafana OnCall, navigate to the **Schedules** tab and click **+ New schedule**
|
||||
1. Navigate to **Set up on-call rotation schedule** and click **+ Create**
|
||||
1. Provide a name and review available schedule settings
|
||||
1. When you’re done, click **Create Schedule**
|
||||
|
||||
>**Note:** You can edit your schedule settings at any time.
|
||||
|
||||
### Add a rotation to your on-call schedule
|
||||
|
||||
After creating your schedule, you can add rotations to build out your coverage needs.
|
||||
Think of a rotation as a recurring schedule containing on-call shifts that users rotate through.
|
||||
|
||||
To add a rotation to an on-call schedule:
|
||||
|
||||
1. From your newly created schedule, click **+ Add rotation** and select **New Layer**.
|
||||
2. Complete the rotation creation form according to your rotation parameters.
|
||||
3. Add users to the rotation from the dropdown.
|
||||
You can separate users into user groups to rotate through individual users per shift. User groups that contain
|
||||
4. multiple users results in all users in the group being included in corresponding shifts.
|
||||
5. When you’re satisfied with the rotation preview, click **Create**.
|
||||
|
||||
### Add an on-call schedule to escalation chains
|
||||
|
||||
Now that you’ve created your schedule, it must be referenced in the steps of an escalation chain for on-call users
|
||||
to receive alert notifications.
|
||||
|
||||
To connect a schedule to an escalation chain:
|
||||
|
||||
1. In Grafana OnCall, go to the **Escalation Chains** tab.
|
||||
1. Navigate to an existing escalation chain or click **+ New Escalation Chain**.
|
||||
1. Select **Notify users from on-call schedule** from the **Add escalation step** dropdown.
|
||||
1. Specify which notification policy to use and the appropriate schedule.
|
||||
1. Click and drag the escalation steps to reorder, if needed.
|
||||
|
||||
Escalation chain steps are saved automatically.
|
||||
|
||||
For more information about Escalation Chains, refer to
|
||||
[Configure and manage Escalation Chains]({{< relref "../../../escalation-policies/configure-escalation-chains" >}})
|
||||
|
|
@ -20,7 +20,7 @@ COPY ./ ./
|
|||
|
||||
# Collect static files and create an SQLite database
|
||||
RUN mkdir -p /var/lib/oncall
|
||||
RUN DJANGO_SETTINGS_MODULE=settings.prod_without_db DATABASE_TYPE=sqlite3 DATABASE_NAME=/var/lib/oncall/oncall.db SECRET_KEY="ThEmUsTSecretKEYforBUILDstage123" python manage.py collectstatic --no-input
|
||||
RUN DJANGO_SETTINGS_MODULE=settings.prod_without_db DATABASE_TYPE=sqlite3 DATABASE_NAME=/var/lib/oncall/oncall.db SECRET_KEY="ThEmUsTSecretKEYforBUILDstage123" SILK_PROFILER_ENABLED="True" python manage.py collectstatic --no-input
|
||||
RUN chown -R 1000:2000 /var/lib/oncall
|
||||
|
||||
FROM base AS dev
|
||||
|
|
|
|||
|
|
@ -312,13 +312,14 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer):
|
|||
resolution_notes_button["text"]["text"] = "Add Resolution notes"
|
||||
buttons.append(resolution_notes_button)
|
||||
|
||||
# Incident button
|
||||
# Declare Incident button
|
||||
if self.alert_group.channel.organization.is_grafana_incident_enabled:
|
||||
incident_button = {
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": ":fire: Declare Incident", "emoji": True},
|
||||
"value": "declare_incident",
|
||||
"url": self.alert_group.declare_incident_link,
|
||||
"action_id": ScenarioStep.get_step("declare_incident", "DeclareIncidentStep").routing_uid(),
|
||||
}
|
||||
buttons.append(incident_button)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.16 on 2023-01-19 18:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0007_populate_web_title_cache'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='alertgrouplogrecord',
|
||||
name='type',
|
||||
field=models.IntegerField(choices=[(0, 'Acknowledged'), (1, 'Unacknowledged'), (2, 'Invite'), (3, 'Stop invitation'), (4, 'Re-invite'), (5, 'Escalation triggered'), (6, 'Invitation triggered'), (16, 'Escalation finished'), (7, 'Silenced'), (15, 'Unsilenced'), (8, 'Attached'), (9, 'Unattached'), (10, 'Custom button triggered'), (11, 'Unacknowledged by timeout'), (12, 'Failed attachment'), (13, 'Incident resolved'), (14, 'Incident unresolved'), (17, 'Escalation failed'), (18, 'Acknowledge reminder triggered'), (19, 'Wiped'), (20, 'Deleted'), (21, 'Incident registered'), (22, 'A route is assigned to the incident'), (23, 'Trigger direct paging escalation'), (24, 'Unpage a user')]),
|
||||
),
|
||||
]
|
||||
|
|
@ -351,19 +351,39 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
|
||||
@staticmethod
|
||||
def get_silenced_state_filter():
|
||||
return Q(silenced=True) & Q(acknowledged=False) & Q(resolved=False)
|
||||
"""
|
||||
models.Value(0/1) is used instead of True/False because django translates that into
|
||||
WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field
|
||||
which works much faster in mysql
|
||||
"""
|
||||
return Q(silenced=models.Value("1")) & Q(acknowledged=models.Value("0")) & Q(resolved=models.Value("0"))
|
||||
|
||||
@staticmethod
|
||||
def get_new_state_filter():
|
||||
return Q(silenced=False) & Q(acknowledged=False) & Q(resolved=False)
|
||||
"""
|
||||
models.Value(0/1) is used instead of True/False because django translates that into
|
||||
WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field
|
||||
which works much faster in mysql
|
||||
"""
|
||||
return Q(silenced=models.Value("0")) & Q(acknowledged=models.Value("0")) & Q(resolved=models.Value("0"))
|
||||
|
||||
@staticmethod
|
||||
def get_acknowledged_state_filter():
|
||||
return Q(acknowledged=True) & Q(resolved=False)
|
||||
"""
|
||||
models.Value(0/1) is used instead of True/False because django translates that into
|
||||
WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field
|
||||
which works much faster in mysql
|
||||
"""
|
||||
return Q(acknowledged=models.Value("1")) & Q(resolved=models.Value("0"))
|
||||
|
||||
@staticmethod
|
||||
def get_resolved_state_filter():
|
||||
return Q(resolved=True)
|
||||
"""
|
||||
models.Value(0/1) is used instead of True/False because django translates that into
|
||||
WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field
|
||||
which works much faster in mysql
|
||||
"""
|
||||
return Q(resolved=models.Value("1"))
|
||||
|
||||
class Meta:
|
||||
get_latest_by = "pk"
|
||||
|
|
|
|||
|
|
@ -111,13 +111,12 @@ class CustomButton(models.Model):
|
|||
alert_payload=self._escape_alert_payload(alert.raw_request_data),
|
||||
alert_group_id=alert.group.public_primary_key,
|
||||
)
|
||||
try:
|
||||
post_kwargs["json"] = json.loads(rendered_data)
|
||||
except JSONDecodeError:
|
||||
post_kwargs["data"] = rendered_data
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
post_kwargs["json"] = {"error": e.fallback_message}
|
||||
|
||||
try:
|
||||
post_kwargs["json"] = json.loads(rendered_data)
|
||||
except JSONDecodeError:
|
||||
post_kwargs["data"] = rendered_data
|
||||
return post_kwargs
|
||||
|
||||
def _escape_alert_payload(self, payload: dict):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import enum
|
||||
import typing
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework import permissions
|
||||
from rest_framework.authentication import BasicAuthentication, SessionAuthentication
|
||||
from rest_framework.request import Request
|
||||
|
|
@ -184,6 +185,11 @@ class RBACPermission(permissions.BasePermission):
|
|||
return view.action if isinstance(view, ViewSetMixin) else request.method.lower()
|
||||
|
||||
def has_permission(self, request: Request, view: ViewSetOrAPIView) -> bool:
|
||||
# the django-debug-toolbar UI makes OPTIONS calls. Without this statement the debug UI can't gather the
|
||||
# necessary info it needs to work properly
|
||||
if settings.DEBUG and request.method == "OPTIONS":
|
||||
return True
|
||||
|
||||
action = self._get_view_action(request, view)
|
||||
|
||||
rbac_permissions: RBACPermissionsAttribute = getattr(view, RBAC_PERMISSIONS_ATTR, None)
|
||||
|
|
|
|||
|
|
@ -169,7 +169,8 @@ class FastAlertReceiveChannelSerializer(serializers.ModelSerializer):
|
|||
fields = ["id", "integration", "verbal_name", "deleted"]
|
||||
|
||||
def get_deleted(self, obj):
|
||||
return obj.deleted_at is not None
|
||||
# Treat direct paging integrations as deleted, so integration settings are disabled on the frontend
|
||||
return obj.deleted_at is not None or obj.integration == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
|
||||
|
||||
|
||||
class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer):
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ class DirectPagingSerializer(serializers.Serializer):
|
|||
users = UserReferenceSerializer(many=True, required=False, default=list)
|
||||
schedules = ScheduleReferenceSerializer(many=True, required=False, default=list)
|
||||
|
||||
escalation_chain_id = serializers.CharField(required=False, default=None)
|
||||
escalation_chain = serializers.HiddenField(default=None) # set in DirectPagingSerializer.validate
|
||||
|
||||
alert_group_id = serializers.CharField(required=False, default=None)
|
||||
alert_group = serializers.HiddenField(default=None) # set in DirectPagingSerializer.validate
|
||||
|
||||
|
|
@ -47,19 +50,37 @@ class DirectPagingSerializer(serializers.Serializer):
|
|||
message = serializers.CharField(required=False, default=None)
|
||||
|
||||
def validate(self, attrs):
|
||||
if len(attrs["users"]) == 0 and len(attrs["schedules"]) == 0:
|
||||
raise serializers.ValidationError("Provide at least one user or schedule")
|
||||
organization = self.context["organization"]
|
||||
|
||||
if attrs["alert_group_id"] and (attrs["title"] or attrs["message"]):
|
||||
users = attrs["users"]
|
||||
schedules = attrs["schedules"]
|
||||
escalation_chain_id = attrs["escalation_chain_id"]
|
||||
|
||||
alert_group_id = attrs["alert_group_id"]
|
||||
title = attrs["title"]
|
||||
message = attrs["message"]
|
||||
|
||||
if len(users) == 0 and len(schedules) == 0 and not escalation_chain_id:
|
||||
raise serializers.ValidationError("Provide users, schedules, or an escalation chain")
|
||||
|
||||
if alert_group_id and (title or message):
|
||||
raise serializers.ValidationError("alert_group_id and (title, message) are mutually exclusive")
|
||||
|
||||
if attrs["alert_group_id"]:
|
||||
organization = self.context["organization"]
|
||||
if alert_group_id and escalation_chain_id:
|
||||
raise serializers.ValidationError("escalation_chain_id is not supported for existing alert groups")
|
||||
|
||||
if alert_group_id:
|
||||
try:
|
||||
attrs["alert_group"] = AlertGroup.unarchived_objects.get(
|
||||
public_primary_key=attrs["alert_group_id"], channel__organization=organization
|
||||
public_primary_key=alert_group_id, channel__organization=organization
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise serializers.ValidationError("Alert group {} does not exist".format(attrs["alert_group_id"]))
|
||||
raise serializers.ValidationError("Alert group {} does not exist".format(alert_group_id))
|
||||
|
||||
if escalation_chain_id:
|
||||
try:
|
||||
attrs["escalation_chain"] = organization.escalation_chains.get(public_primary_key=escalation_chain_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise serializers.ValidationError("Escalation chain {} does not exist".format(escalation_chain_id))
|
||||
|
||||
return attrs
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from rest_framework import status
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.models import AlertGroup, AlertGroupLogRecord
|
||||
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, AlertReceiveChannel
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
|
||||
alert_raw_request_data = {
|
||||
|
|
@ -1585,3 +1585,25 @@ def test_alert_group_paged_users(
|
|||
url = reverse("api-internal:alertgroup-detail", kwargs={"pk": new_alert_group.public_primary_key})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.json()["paged_users"] == [user2.short()]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_integration_treated_as_deleted(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
alert_group_internal_api_setup,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
|
||||
)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alertgroup-detail", kwargs={"pk": alert_group.public_primary_key})
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.json()["alert_receive_channel"]["deleted"] is True
|
||||
|
|
|
|||
|
|
@ -668,3 +668,19 @@ def test_alert_receive_channel_counters_per_integration_permissions(
|
|||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_alert_receive_channels_direct_paging_hidden(
|
||||
make_organization_and_user_with_plugin_token, make_alert_receive_channel, make_user_auth_headers
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
make_alert_receive_channel(user.organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alert_receive_channel-list")
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
# Check no direct paging integrations in the response
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_new_alert_group(
|
||||
make_organization_and_user_with_plugin_token, make_user, make_schedule, make_user_auth_headers
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user,
|
||||
make_schedule,
|
||||
make_escalation_chain,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
|
||||
|
||||
|
|
@ -32,6 +36,8 @@ def test_direct_paging_new_alert_group(
|
|||
},
|
||||
]
|
||||
|
||||
escalation_chain_to_page = make_escalation_chain(organization)
|
||||
|
||||
title = "Test Alert Group"
|
||||
message = "Testing direct paging with new alert group"
|
||||
|
||||
|
|
@ -40,7 +46,13 @@ def test_direct_paging_new_alert_group(
|
|||
|
||||
response = client.post(
|
||||
url,
|
||||
data={"users": users_to_page, "schedules": schedules_to_page, "title": title, "message": message},
|
||||
data={
|
||||
"users": users_to_page,
|
||||
"schedules": schedules_to_page,
|
||||
"escalation_chain_id": escalation_chain_to_page.public_primary_key,
|
||||
"title": title,
|
||||
"message": message,
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
|
@ -94,6 +106,38 @@ def test_direct_paging_existing_alert_group(
|
|||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_existing_alert_group_and_escalation_chain(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user,
|
||||
make_schedule,
|
||||
make_escalation_chain,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
|
||||
escalation_chain_to_page = make_escalation_chain(organization)
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:direct_paging")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={
|
||||
"escalation_chain_id": escalation_chain_to_page.public_primary_key,
|
||||
"alert_group_id": alert_group.public_primary_key,
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_no_title(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Count, Max, Q
|
||||
from django.utils import timezone
|
||||
from django_filters import rest_framework as filters
|
||||
from django_filters.widgets import RangeWidget
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
|
@ -15,9 +17,10 @@ from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel
|
|||
from apps.alerts.paging import unpage_user
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer
|
||||
from apps.api.serializers.team import TeamSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
|
||||
from apps.user_management.models import User
|
||||
from apps.user_management.models import Team, User
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.filters import DateRangeFilterMixin, ModelFieldFilterMixin
|
||||
from common.api_helpers.mixins import PreviewTemplateMixin, PublicPrimaryKeyMixin, TeamFilteringMixin
|
||||
|
|
@ -88,8 +91,6 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
|
|||
model = AlertGroup
|
||||
fields = [
|
||||
"id__in",
|
||||
"resolved",
|
||||
"acknowledged",
|
||||
"started_at_gte",
|
||||
"started_at_lte",
|
||||
"resolved_at_lte",
|
||||
|
|
@ -148,6 +149,37 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
|
|||
class AlertGroupTeamFilteringMixin(TeamFilteringMixin):
|
||||
TEAM_LOOKUP = "channel__team"
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
try:
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
except NotFound:
|
||||
alert_receive_channels_ids = list(
|
||||
AlertReceiveChannel.objects.filter(
|
||||
organization_id=self.request.auth.organization.id,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
queryset = AlertGroup.unarchived_objects.filter(
|
||||
channel__in=alert_receive_channels_ids,
|
||||
).only("public_primary_key")
|
||||
|
||||
try:
|
||||
obj = queryset.get(public_primary_key=self.kwargs["pk"])
|
||||
except ObjectDoesNotExist:
|
||||
raise NotFound
|
||||
|
||||
obj_team = self._getattr_with_related(obj, self.TEAM_LOOKUP)
|
||||
|
||||
if obj_team is None or obj_team in self.request.user.teams.all():
|
||||
if obj_team is None:
|
||||
obj_team = Team(public_primary_key=None, name="General", email=None, avatar_url=None)
|
||||
|
||||
return Response(
|
||||
data={"error_code": "wrong_team", "owner_team": TeamSerializer(obj_team).data},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
return Response(data={"error_code": "wrong_team"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
class AlertGroupView(
|
||||
PreviewTemplateMixin,
|
||||
|
|
@ -206,8 +238,14 @@ class AlertGroupView(
|
|||
|
||||
def get_queryset(self):
|
||||
# no select_related or prefetch_related is used at this point, it will be done on paginate_queryset.
|
||||
alert_receive_channels_ids = list(
|
||||
AlertReceiveChannel.objects.filter(
|
||||
organization_id=self.request.auth.organization.id,
|
||||
team_id=self.request.user.current_team,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
queryset = AlertGroup.unarchived_objects.filter(
|
||||
channel__organization=self.request.auth.organization, channel__team=self.request.user.current_team
|
||||
channel__in=alert_receive_channels_ids,
|
||||
).only("id")
|
||||
|
||||
return queryset
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ class AlertReceiveChannelView(
|
|||
)
|
||||
if eager:
|
||||
queryset = self.serializer_class.setup_eager_loading(queryset)
|
||||
|
||||
# Hide direct paging integrations
|
||||
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class DirectPagingAPIView(APIView):
|
|||
message=serializer.validated_data["message"],
|
||||
users=users,
|
||||
schedules=schedules,
|
||||
escalation_chain=serializer.validated_data["escalation_chain"],
|
||||
alert_group=serializer.validated_data["alert_group"],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,14 @@ class PluginAuthentication(BaseAuthentication):
|
|||
logger.debug(f"Could not get user from grafana request. Context {context}")
|
||||
raise exceptions.AuthenticationFailed("Non-existent or anonymous user.")
|
||||
|
||||
@classmethod
|
||||
def is_user_from_request_present_in_organization(cls, request: Request, organization: Organization) -> User:
|
||||
try:
|
||||
cls._get_user(request, organization)
|
||||
return True
|
||||
except exceptions.AuthenticationFailed:
|
||||
return False
|
||||
|
||||
|
||||
class GrafanaIncidentUser(AnonymousUser):
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -54,16 +54,16 @@ class APIClient:
|
|||
self.api_url = api_url
|
||||
self.api_token = api_token
|
||||
|
||||
def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]:
|
||||
return self.call_api(endpoint, requests.head, body)
|
||||
def api_head(self, endpoint: str, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]:
|
||||
return self.call_api(endpoint, requests.head, body, **kwargs)
|
||||
|
||||
def api_get(self, endpoint: str) -> Tuple[Optional[Response], dict]:
|
||||
return self.call_api(endpoint, requests.get)
|
||||
def api_get(self, endpoint: str, **kwargs) -> Tuple[Optional[Response], dict]:
|
||||
return self.call_api(endpoint, requests.get, **kwargs)
|
||||
|
||||
def api_post(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]:
|
||||
return self.call_api(endpoint, requests.post, body)
|
||||
def api_post(self, endpoint: str, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]:
|
||||
return self.call_api(endpoint, requests.post, body, **kwargs)
|
||||
|
||||
def call_api(self, endpoint: str, http_method, body: dict = None) -> Tuple[Optional[Response], dict]:
|
||||
def call_api(self, endpoint: str, http_method, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]:
|
||||
request_start = time.perf_counter()
|
||||
call_status = {
|
||||
"url": urljoin(self.api_url, endpoint),
|
||||
|
|
@ -72,7 +72,7 @@ class APIClient:
|
|||
"message": "",
|
||||
}
|
||||
try:
|
||||
response = http_method(call_status["url"], json=body, headers=self.request_headers)
|
||||
response = http_method(call_status["url"], json=body, headers=self.request_headers, **kwargs)
|
||||
call_status["status_code"] = response.status_code
|
||||
response.raise_for_status()
|
||||
|
||||
|
|
@ -82,11 +82,14 @@ class APIClient:
|
|||
if response.status_code == status.HTTP_204_NO_CONTENT:
|
||||
return {}, call_status
|
||||
|
||||
return response.json(), call_status
|
||||
# ex. a HEAD call (self.api_head) would have a response.content of b''
|
||||
# and hence calling response.json() throws a json.JSONDecodeError
|
||||
return response.json() if response.content else None, call_status
|
||||
except (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.HTTPError,
|
||||
requests.exceptions.TooManyRedirects,
|
||||
requests.exceptions.Timeout,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.warning("Error connecting to api instance " + str(e))
|
||||
|
|
@ -151,8 +154,8 @@ class GrafanaAPIClient(APIClient):
|
|||
_, resp_status = self.api_head(self.USER_PERMISSION_ENDPOINT)
|
||||
return resp_status["status_code"] == status.HTTP_200_OK
|
||||
|
||||
def get_users(self, rbac_is_enabled_for_org: bool) -> List[GrafanaUserWithPermissions]:
|
||||
users, _ = self.api_get("api/org/users")
|
||||
def get_users(self, rbac_is_enabled_for_org: bool, **kwargs) -> List[GrafanaUserWithPermissions]:
|
||||
users, _ = self.api_get("api/org/users", **kwargs)
|
||||
|
||||
if not users:
|
||||
return []
|
||||
|
|
@ -164,8 +167,8 @@ class GrafanaAPIClient(APIClient):
|
|||
user["permissions"] = user_permissions.get(str(user["userId"]), [])
|
||||
return users
|
||||
|
||||
def get_teams(self):
|
||||
return self.api_get("api/teams/search?perpage=1000000")
|
||||
def get_teams(self, **kwargs):
|
||||
return self.api_get("api/teams/search?perpage=1000000", **kwargs)
|
||||
|
||||
def get_team_members(self, team_id):
|
||||
return self.api_get(f"api/teams/{team_id}/members")
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
from .sync import start_sync_organizations, sync_organization_async # noqa: F401
|
||||
from .sync import ( # noqa: F401
|
||||
start_sync_organizations,
|
||||
sync_organization_async,
|
||||
sync_team_members_for_organization_async,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ from django.conf import settings
|
|||
from django.utils import timezone
|
||||
|
||||
from apps.grafana_plugin.helpers import GcomAPIClient
|
||||
from apps.grafana_plugin.helpers.client import GrafanaAPIClient
|
||||
from apps.grafana_plugin.helpers.gcom import get_active_instance_ids, get_deleted_instance_ids, get_stack_regions
|
||||
from apps.user_management.models import Organization
|
||||
from apps.user_management.models.region import sync_regions
|
||||
from apps.user_management.sync import cleanup_organization, sync_organization
|
||||
from apps.user_management.sync import cleanup_organization, sync_organization, sync_team_members
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
|
@ -117,3 +118,15 @@ def start_sync_regions():
|
|||
return
|
||||
|
||||
sync_regions(regions)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), max_retries=1)
|
||||
def sync_team_members_for_organization_async(organization_pk):
|
||||
try:
|
||||
organization = Organization.objects.get(pk=organization_pk)
|
||||
except Organization.DoesNotExist:
|
||||
logger.info(f"Organization {organization_pk} was not found")
|
||||
return
|
||||
|
||||
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
|
||||
sync_team_members(grafana_api_client, organization)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from rest_framework.request import Request
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.grafana_plugin.permissions import PluginTokenVerified
|
||||
from apps.grafana_plugin.tasks.sync import plugin_sync_organization_async
|
||||
from apps.user_management.models import Organization
|
||||
|
|
@ -22,26 +23,29 @@ class PluginSyncView(GrafanaHeadersMixin, APIView):
|
|||
stack_id = self.instance_context["stack_id"]
|
||||
org_id = self.instance_context["org_id"]
|
||||
is_installed = False
|
||||
|
||||
allow_signup = True
|
||||
try:
|
||||
organization = Organization.objects.get(stack_id=stack_id, org_id=org_id)
|
||||
|
||||
if organization.api_token_status == Organization.API_TOKEN_STATUS_OK:
|
||||
is_installed = True
|
||||
organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING
|
||||
|
||||
organization.save(update_fields=["api_token_status"])
|
||||
user_is_present_in_org = PluginAuthentication.is_user_from_request_present_in_organization(
|
||||
request, organization
|
||||
)
|
||||
if not user_is_present_in_org:
|
||||
organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING
|
||||
organization.save(update_fields=["api_token_status"])
|
||||
|
||||
if not organization:
|
||||
DynamicSetting = apps.get_model("base", "DynamicSetting")
|
||||
allow_signup = DynamicSetting.objects.get_or_create(
|
||||
name="allow_plugin_organization_signup", defaults={"boolean_value": True}
|
||||
)[0].boolean_value
|
||||
|
||||
plugin_sync_organization_async.apply_async((organization.pk,))
|
||||
except Organization.DoesNotExist:
|
||||
logger.info(f"Organization for stack {stack_id} org {org_id} was not found")
|
||||
|
||||
allow_signup = True
|
||||
if not organization:
|
||||
DynamicSetting = apps.get_model("base", "DynamicSetting")
|
||||
allow_signup = DynamicSetting.objects.get_or_create(
|
||||
name="allow_plugin_organization_signup", defaults={"boolean_value": True}
|
||||
)[0].boolean_value
|
||||
|
||||
return Response(
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
data={
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ class MobileAppBackend(BaseMessagingBackend):
|
|||
|
||||
@staticmethod
|
||||
def is_enabled_for_organization(organization):
|
||||
# Setting FEATURE_MOBILE_APP_INTEGRATION_ENABLED to True is enough to enable mobile app on OSS instances
|
||||
if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME:
|
||||
return True
|
||||
|
||||
mobile_app_settings, _ = DynamicSetting.objects.get_or_create(
|
||||
name="mobile_app_settings", defaults={"json_value": {"org_ids": []}}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,25 +5,37 @@ from django.conf import settings
|
|||
from fcm_django.models import FCMDevice
|
||||
from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.auth_token.auth import ApiTokenAuthentication
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
||||
task_logger = get_task_logger(__name__)
|
||||
task_logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class FCMRelayThrottler(UserRateThrottle):
|
||||
scope = "fcm_relay"
|
||||
rate = "300/m"
|
||||
|
||||
|
||||
class FCMRelayView(APIView):
|
||||
# TODO: use public API authentication (then it would be required to connect to a cloud instance to use the app)
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
"""
|
||||
This view accepts push notifications from OSS instances and forwards these requests to FCM.
|
||||
Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay.
|
||||
The view uses public API authentication, so an OSS instance must be connected to cloud to use FCM relay.
|
||||
"""
|
||||
|
||||
authentication_classes = [ApiTokenAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_classes = [FCMRelayThrottler]
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
This view accepts push notifications from OSS instances and forwards these requests to FCM.
|
||||
Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay
|
||||
"""
|
||||
if not settings.FCM_RELAY_ENABLED:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
token = request.data["token"]
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ from celery.utils.log import get_task_logger
|
|||
from django.conf import settings
|
||||
from fcm_django.models import FCMDevice
|
||||
from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
|
||||
from requests import HTTPError
|
||||
from rest_framework import status
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.base.utils import live_settings
|
||||
from apps.mobile_app.alert_rendering import get_push_notification_message
|
||||
from apps.user_management.models import User
|
||||
from common.api_helpers.utils import create_engine_url
|
||||
|
|
@ -41,10 +44,10 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
|
|||
logger.warning(f"User notification policy {notification_policy_pk} does not exist")
|
||||
return
|
||||
|
||||
device_to_notify = FCMDevice.objects.filter(user=user).first()
|
||||
|
||||
# create an error log in case user has no devices set up
|
||||
if not device_to_notify:
|
||||
def _create_error_log_record():
|
||||
"""
|
||||
Utility method to create a UserNotificationPolicyLogRecord with error
|
||||
"""
|
||||
UserNotificationPolicyLogRecord.objects.create(
|
||||
author=user,
|
||||
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
|
||||
|
|
@ -54,7 +57,13 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
|
|||
notification_step=notification_policy.step,
|
||||
notification_channel=notification_policy.notify_by,
|
||||
)
|
||||
logger.info(f"Error while sending a mobile push notification: user {user_pk} has no device set up")
|
||||
|
||||
device_to_notify = FCMDevice.objects.filter(user=user).first()
|
||||
|
||||
# create an error log in case user has no devices set up
|
||||
if not device_to_notify:
|
||||
_create_error_log_record()
|
||||
logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up")
|
||||
return
|
||||
|
||||
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
|
||||
|
|
@ -116,8 +125,27 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
|
|||
logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};")
|
||||
|
||||
if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME:
|
||||
response = send_push_notification_to_fcm_relay(message)
|
||||
logger.debug(f"FCM relay response: {response}")
|
||||
# FCM relay uses cloud connection to send push notifications
|
||||
from apps.oss_installation.models import CloudConnector
|
||||
|
||||
if not CloudConnector.objects.exists():
|
||||
_create_error_log_record()
|
||||
logger.error(f"Error while sending a mobile push notification: not connected to cloud")
|
||||
return
|
||||
|
||||
try:
|
||||
response = send_push_notification_to_fcm_relay(message)
|
||||
logger.debug(f"FCM relay response: {response}")
|
||||
except HTTPError as e:
|
||||
if status.HTTP_400_BAD_REQUEST <= e.response.status_code < status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
# do not retry on HTTP client errors (4xx errors)
|
||||
_create_error_log_record()
|
||||
logger.error(
|
||||
f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
response = device_to_notify.send_message(message)
|
||||
# NOTE: we may want to further handle the response from FCM, but for now lets simply log it out
|
||||
|
|
@ -131,7 +159,9 @@ def send_push_notification_to_fcm_relay(message):
|
|||
"""
|
||||
url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL)
|
||||
|
||||
response = requests.post(url, json=json.loads(str(message)))
|
||||
response = requests.post(
|
||||
url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message))
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
|
|
|||
0
engine/apps/mobile_app/tests/__init__.py
Normal file
0
engine/apps/mobile_app/tests/__init__.py
Normal file
81
engine/apps/mobile_app/tests/test_fcm_relay.py
Normal file
81
engine/apps/mobile_app/tests/test_fcm_relay.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.mobile_app.fcm_relay import FCMRelayThrottler
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fcm_relay_disabled(
|
||||
settings,
|
||||
load_mobile_app_urls,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_public_api_token,
|
||||
):
|
||||
settings.FCM_RELAY_ENABLED = False
|
||||
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
_, token = make_public_api_token(user, organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("mobile_app:fcm_relay")
|
||||
|
||||
response = client.post(url, HTTP_AUTHORIZATION=token)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fcm_relay_post(
|
||||
settings,
|
||||
load_mobile_app_urls,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_public_api_token,
|
||||
):
|
||||
settings.FCM_RELAY_ENABLED = True
|
||||
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
_, token = make_public_api_token(user, organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("mobile_app:fcm_relay")
|
||||
|
||||
data = {
|
||||
"token": "test_registration_id",
|
||||
"data": {},
|
||||
"apns": {},
|
||||
}
|
||||
|
||||
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fcm_relay_ratelimit(
|
||||
settings,
|
||||
load_mobile_app_urls,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_public_api_token,
|
||||
):
|
||||
settings.FCM_RELAY_ENABLED = True
|
||||
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
_, token = make_public_api_token(user, organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("mobile_app:fcm_relay")
|
||||
|
||||
data = {
|
||||
"token": "test_registration_id",
|
||||
"data": {},
|
||||
"apns": {},
|
||||
}
|
||||
|
||||
with patch.object(FCMRelayThrottler, "rate", "0/m"):
|
||||
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token)
|
||||
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
171
engine/apps/mobile_app/tests/test_notify_user.py
Normal file
171
engine/apps/mobile_app/tests/test_notify_user.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fcm_django.models import FCMDevice
|
||||
|
||||
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
|
||||
from apps.mobile_app.tasks import notify_user_async
|
||||
from apps.oss_installation.models import CloudConnector
|
||||
|
||||
MOBILE_APP_BACKEND_ID = 5
|
||||
CLOUD_LICENSE_NAME = "Cloud"
|
||||
OPEN_SOURCE_LICENSE_NAME = "OpenSource"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_async_cloud(
|
||||
settings,
|
||||
make_organization_and_user,
|
||||
make_user_notification_policy,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
):
|
||||
# create a user and connect a mobile device
|
||||
organization, user = make_organization_and_user()
|
||||
FCMDevice.objects.create(user=user, registration_id="test_device_id")
|
||||
|
||||
# set up notification policy and alert group
|
||||
notification_policy = make_user_notification_policy(
|
||||
user,
|
||||
UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=MOBILE_APP_BACKEND_ID,
|
||||
)
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
channel_filter = make_channel_filter(alert_receive_channel)
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
# check FCM is contacted directly when using the cloud license
|
||||
settings.LICENSE = CLOUD_LICENSE_NAME
|
||||
with patch.object(FCMDevice, "send_message", return_value="ok") as mock:
|
||||
notify_user_async(
|
||||
user_pk=user.pk,
|
||||
alert_group_pk=alert_group.pk,
|
||||
notification_policy_pk=notification_policy.pk,
|
||||
critical=False,
|
||||
)
|
||||
mock.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_async_oss(
|
||||
settings,
|
||||
make_organization_and_user,
|
||||
make_user_notification_policy,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
):
|
||||
# create a user and connect a mobile device
|
||||
organization, user = make_organization_and_user()
|
||||
FCMDevice.objects.create(user=user, registration_id="test_device_id")
|
||||
|
||||
# set up notification policy and alert group
|
||||
notification_policy = make_user_notification_policy(
|
||||
user,
|
||||
UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=MOBILE_APP_BACKEND_ID,
|
||||
)
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
channel_filter = make_channel_filter(alert_receive_channel)
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
# create cloud connection
|
||||
CloudConnector.objects.create(cloud_url="test")
|
||||
|
||||
# check FCM relay is contacted when using the OSS license
|
||||
settings.LICENSE = OPEN_SOURCE_LICENSE_NAME
|
||||
with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock:
|
||||
notify_user_async(
|
||||
user_pk=user.pk,
|
||||
alert_group_pk=alert_group.pk,
|
||||
notification_policy_pk=notification_policy.pk,
|
||||
critical=False,
|
||||
)
|
||||
mock.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_async_oss_no_device_connected(
|
||||
settings,
|
||||
make_organization_and_user,
|
||||
make_user_notification_policy,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
):
|
||||
# create a user without mobile device
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
# set up notification policy and alert group
|
||||
notification_policy = make_user_notification_policy(
|
||||
user,
|
||||
UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=MOBILE_APP_BACKEND_ID,
|
||||
)
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
channel_filter = make_channel_filter(alert_receive_channel)
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
# create cloud connection
|
||||
CloudConnector.objects.create(cloud_url="test")
|
||||
|
||||
# check FCM relay is contacted when using the OSS license
|
||||
settings.LICENSE = OPEN_SOURCE_LICENSE_NAME
|
||||
with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock:
|
||||
notify_user_async(
|
||||
user_pk=user.pk,
|
||||
alert_group_pk=alert_group.pk,
|
||||
notification_policy_pk=notification_policy.pk,
|
||||
critical=False,
|
||||
)
|
||||
mock.assert_not_called()
|
||||
|
||||
log_record = alert_group.personal_log_records.last()
|
||||
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_async_oss_no_cloud_connection(
|
||||
settings,
|
||||
make_organization_and_user,
|
||||
make_user_notification_policy,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
):
|
||||
# create a user and connect a mobile device
|
||||
organization, user = make_organization_and_user()
|
||||
FCMDevice.objects.create(user=user, registration_id="test_device_id")
|
||||
|
||||
# set up notification policy and alert group
|
||||
notification_policy = make_user_notification_policy(
|
||||
user,
|
||||
UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=MOBILE_APP_BACKEND_ID,
|
||||
)
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
channel_filter = make_channel_filter(alert_receive_channel)
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
# check FCM relay is contacted when using the OSS license
|
||||
settings.LICENSE = OPEN_SOURCE_LICENSE_NAME
|
||||
with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock:
|
||||
notify_user_async(
|
||||
user_pk=user.pk,
|
||||
alert_group_pk=alert_group.pk,
|
||||
notification_policy_pk=notification_policy.pk,
|
||||
critical=False,
|
||||
)
|
||||
mock.assert_not_called()
|
||||
|
||||
log_record = alert_group.personal_log_records.last()
|
||||
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
from django.conf import settings
|
||||
|
||||
from apps.mobile_app.fcm_relay import FCMRelayView
|
||||
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView
|
||||
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
|
||||
|
|
@ -14,7 +12,6 @@ urlpatterns = [
|
|||
optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"),
|
||||
]
|
||||
|
||||
if settings.FCM_RELAY_ENABLED:
|
||||
urlpatterns += [
|
||||
optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"),
|
||||
]
|
||||
urlpatterns += [
|
||||
optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"),
|
||||
]
|
||||
|
|
|
|||
0
engine/apps/oss_installation/tests/__init__.py
Normal file
0
engine/apps/oss_installation/tests/__init__.py
Normal file
41
engine/apps/oss_installation/tests/test_views.py
Normal file
41
engine/apps/oss_installation/tests/test_views.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.oss_installation.models import CloudConnector
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cloud_connection_viewer_can_read(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER)
|
||||
|
||||
# create cloud connection
|
||||
CloudConnector.objects.create(cloud_url="test")
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("oss_installation:cloud-connection-status")
|
||||
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cloud_connection_viewer_cant_delete(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER)
|
||||
|
||||
# create cloud connection
|
||||
CloudConnector.objects.create(cloud_url="test")
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("oss_installation:cloud-connection-status")
|
||||
response = client.delete(url, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
|
@ -4,6 +4,8 @@ from common.api_helpers.optional_slash_router import OptionalSlashRouter, option
|
|||
|
||||
from .views import CloudConnectionView, CloudHeartbeatView, CloudUsersView, CloudUserView
|
||||
|
||||
app_name = "oss_installation"
|
||||
|
||||
router = OptionalSlashRouter()
|
||||
router.register("cloud_users", CloudUserView, basename="cloud-users")
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class CloudConnectionView(APIView):
|
|||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
rbac_permissions = {
|
||||
"get": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE],
|
||||
"get": [RBACPermission.Permissions.OTHER_SETTINGS_READ],
|
||||
"delete": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from django.urls import reverse
|
|||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.base.tests.messaging_backend import TestOnlyBackend
|
||||
|
||||
TEST_MESSAGING_BACKEND_FIELD = TestOnlyBackend.backend_id.lower()
|
||||
|
|
@ -570,3 +571,22 @@ def test_set_default_template(
|
|||
response = client.put(url, data=data_for_update, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == expected_response
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_list_integrations_direct_paging_hidden(
|
||||
make_organization_and_user_with_token,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_integration_heartbeat,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:integrations-list")
|
||||
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
|
||||
# Check no direct paging integrations in the response
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["results"] == []
|
||||
|
|
|
|||
35
engine/apps/public_api/tf_sync.py
Normal file
35
engine/apps/public_api/tf_sync.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import logging
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.grafana_plugin.helpers.client import GrafanaAPIClient
|
||||
from apps.grafana_plugin.tasks import sync_team_members_for_organization_async
|
||||
from apps.user_management.sync import sync_teams, sync_users
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYNC_REQUEST_TIMEOUT = 5
|
||||
SYNC_PERIOD = 60
|
||||
|
||||
|
||||
def is_request_from_terraform(request) -> bool:
|
||||
return "terraform-provider-grafana" in request.META.get("HTTP_USER_AGENT", "")
|
||||
|
||||
|
||||
def sync_users_on_tf_request(organization):
|
||||
cache_key = f"sync_users_on_tf_request_{organization.id}"
|
||||
if not cache.get(cache_key):
|
||||
logger.info(f"Start sync_users_on_tf_request organization_id={organization.id}")
|
||||
client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
|
||||
cache.set(cache_key, True, SYNC_PERIOD)
|
||||
sync_users(client, organization, timeout=SYNC_REQUEST_TIMEOUT)
|
||||
|
||||
|
||||
def sync_teams_on_tf_request(organization):
|
||||
cache_key = f"sync_teams_on_tf_request_{organization.id}"
|
||||
if not cache.get(cache_key):
|
||||
logger.info(f"Start sync_teams_on_tf_request organization_id={organization.id}")
|
||||
cache.set(cache_key, True, SYNC_PERIOD)
|
||||
client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
|
||||
sync_teams(client, organization, timeout=SYNC_REQUEST_TIMEOUT)
|
||||
sync_team_members_for_organization_async.apply_async((organization.id,))
|
||||
|
|
@ -47,6 +47,10 @@ class IntegrationView(
|
|||
queryset = self.filter_queryset(queryset)
|
||||
queryset = self.serializer_class.setup_eager_loading(queryset)
|
||||
queryset = queryset.annotate(alert_groups_count_annotated=Count("alert_groups", distinct=True))
|
||||
|
||||
# Hide direct paging integrations
|
||||
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from rest_framework.permissions import IsAuthenticated
|
|||
|
||||
from apps.auth_token.auth import ApiTokenAuthentication
|
||||
from apps.public_api.serializers.teams import TeamSerializer
|
||||
from apps.public_api.tf_sync import is_request_from_terraform, sync_teams_on_tf_request
|
||||
from apps.public_api.throttlers.user_throttle import UserThrottle
|
||||
from apps.user_management.models import Team
|
||||
from common.api_helpers.mixins import PublicPrimaryKeyMixin
|
||||
|
|
@ -20,6 +21,8 @@ class TeamView(PublicPrimaryKeyMixin, RetrieveModelMixin, ListModelMixin, viewse
|
|||
throttle_classes = [UserThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
if is_request_from_terraform(self.request):
|
||||
sync_teams_on_tf_request(self.request.auth.organization)
|
||||
name = self.request.query_params.get("name", None)
|
||||
queryset = self.request.auth.organization.teams.all()
|
||||
if name:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from apps.api.permissions import LegacyAccessControlRole
|
|||
from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication
|
||||
from apps.public_api.custom_renderers import CalendarRenderer
|
||||
from apps.public_api.serializers import FastUserSerializer, UserSerializer
|
||||
from apps.public_api.tf_sync import is_request_from_terraform, sync_users_on_tf_request
|
||||
from apps.public_api.throttlers.user_throttle import UserThrottle
|
||||
from apps.schedules.ical_utils import user_ical_export
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
|
|
@ -48,6 +49,8 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet
|
|||
throttle_classes = [UserThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
if is_request_from_terraform(self.request):
|
||||
sync_users_on_tf_request(self.request.auth.organization)
|
||||
is_short_request = self.request.query_params.get("short", "false") == "true"
|
||||
queryset = self.request.auth.organization.users.all()
|
||||
if not is_short_request:
|
||||
|
|
|
|||
23
engine/apps/slack/scenarios/declare_incident.py
Normal file
23
engine/apps/slack/scenarios/declare_incident.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from apps.slack.scenarios import scenario_step
|
||||
|
||||
|
||||
class DeclareIncidentStep(scenario_step.ScenarioStep):
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=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 = [
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
|
||||
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON,
|
||||
"block_action_id": DeclareIncidentStep.routing_uid(),
|
||||
"step": DeclareIncidentStep,
|
||||
},
|
||||
]
|
||||
663
engine/apps/slack/scenarios/paging.py
Normal file
663
engine/apps/slack/scenarios/paging.py
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
from apps.alerts.paging import (
|
||||
USER_HAS_NO_NOTIFICATION_POLICY,
|
||||
USER_IS_NOT_ON_CALL,
|
||||
check_user_availability,
|
||||
direct_paging,
|
||||
)
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.slack_client.exceptions import SlackAPIException
|
||||
|
||||
DIRECT_PAGING_TEAM_SELECT_ID = "paging_team_select"
|
||||
DIRECT_PAGING_ORG_SELECT_ID = "paging_org_select"
|
||||
DIRECT_PAGING_ROUTE_SELECT_ID = "paging_route_select"
|
||||
DIRECT_PAGING_USER_SELECT_ID = "paging_user_select"
|
||||
DIRECT_PAGING_TITLE_INPUT_ID = "paging_title_input"
|
||||
DIRECT_PAGING_MESSAGE_INPUT_ID = "paging_message_input"
|
||||
|
||||
DEFAULT_TEAM_VALUE = "default_team"
|
||||
|
||||
|
||||
# selected user available actions
|
||||
DEFAULT_POLICY = "default"
|
||||
IMPORTANT_POLICY = "important"
|
||||
REMOVE_ACTION = "remove"
|
||||
|
||||
USER_ACTIONS = (
|
||||
(DEFAULT_POLICY, "Set default notification policy"),
|
||||
(IMPORTANT_POLICY, "Set important notification policy"),
|
||||
(REMOVE_ACTION, "Remove from escalation"),
|
||||
)
|
||||
|
||||
|
||||
# helpers to manage current selected users state
|
||||
|
||||
|
||||
def add_or_update_user(payload, user_pk, policy):
|
||||
metadata = json.loads(payload["view"]["private_metadata"])
|
||||
metadata["current_users"][user_pk] = policy
|
||||
payload["view"]["private_metadata"] = json.dumps(metadata)
|
||||
return payload
|
||||
|
||||
|
||||
def remove_user(payload, user_pk):
|
||||
metadata = json.loads(payload["view"]["private_metadata"])
|
||||
if user_pk in metadata["current_users"]:
|
||||
del metadata["current_users"][user_pk]
|
||||
payload["view"]["private_metadata"] = json.dumps(metadata)
|
||||
return payload
|
||||
|
||||
|
||||
def reset_users(payload):
|
||||
metadata = json.loads(payload["view"]["private_metadata"])
|
||||
metadata["current_users"] = {}
|
||||
payload["view"]["private_metadata"] = json.dumps(metadata)
|
||||
return payload
|
||||
|
||||
|
||||
def get_current_users(payload, organization):
|
||||
metadata = json.loads(payload["view"]["private_metadata"])
|
||||
current_users = []
|
||||
for u, p in metadata["current_users"].items():
|
||||
user = organization.users.filter(pk=u).first()
|
||||
current_users.append((user, p))
|
||||
return current_users
|
||||
|
||||
|
||||
# Slack scenario steps
|
||||
|
||||
|
||||
class StartDirectPaging(scenario_step.ScenarioStep):
|
||||
"""Handle slash command invocation and show initial dialog."""
|
||||
|
||||
command_name = [settings.SLACK_DIRECT_PAGING_SLASH_COMMAND]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
input_id_prefix = _generate_input_id_prefix()
|
||||
|
||||
try:
|
||||
channel_id = payload["event"]["channel"]
|
||||
except KeyError:
|
||||
channel_id = payload["channel_id"]
|
||||
|
||||
private_metadata = {
|
||||
"channel_id": channel_id,
|
||||
"input_id_prefix": input_id_prefix,
|
||||
"submit_routing_uid": FinishDirectPaging.routing_uid(),
|
||||
"current_users": {},
|
||||
}
|
||||
|
||||
blocks = _get_initial_form_fields(slack_team_identity, slack_user_identity, input_id_prefix, payload)
|
||||
view = _get_form_view(FinishDirectPaging.routing_uid(), blocks, json.dumps(private_metadata))
|
||||
self._slack_client.api_call(
|
||||
"views.open",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
)
|
||||
|
||||
|
||||
class FinishDirectPaging(scenario_step.ScenarioStep):
|
||||
"""Handle page command dialog submit."""
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
title = _get_title_from_payload(payload)
|
||||
message = _get_message_from_payload(payload)
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
channel_id = private_metadata["channel_id"]
|
||||
input_id_prefix = private_metadata["input_id_prefix"]
|
||||
selected_organization = _get_selected_org_from_payload(payload, input_id_prefix)
|
||||
selected_team = _get_selected_team_from_payload(payload, input_id_prefix)
|
||||
user = slack_user_identity.get_user(selected_organization)
|
||||
selected_users = [(u, p == IMPORTANT_POLICY) for u, p in get_current_users(payload, selected_organization)]
|
||||
|
||||
# trigger direct paging to selected users
|
||||
direct_paging(selected_organization, selected_team, user, title, message, selected_users)
|
||||
|
||||
try:
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
|
||||
)
|
||||
except SlackAPIException as e:
|
||||
if e.response["error"] == "channel_not_found":
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=slack_user_identity.im_channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
# OnChange steps, responsible for rerendering form on changed values
|
||||
|
||||
|
||||
class OnOrgChange(scenario_step.ScenarioStep):
|
||||
"""Reload form with updated organization."""
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
updated_payload = reset_users(payload)
|
||||
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
|
||||
self._slack_client.api_call(
|
||||
"views.update",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
view_id=payload["view"]["id"],
|
||||
)
|
||||
|
||||
|
||||
class OnTeamChange(OnOrgChange):
|
||||
"""Reload form with updated team."""
|
||||
|
||||
|
||||
class OnUserChange(scenario_step.ScenarioStep):
|
||||
"""Add selected to user to the list.
|
||||
|
||||
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):
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
selected_organization = _get_selected_org_from_payload(payload, private_metadata["input_id_prefix"])
|
||||
selected_team = _get_selected_team_from_payload(payload, private_metadata["input_id_prefix"])
|
||||
selected_user = _get_selected_user_from_payload(payload, private_metadata["input_id_prefix"])
|
||||
if selected_user is None:
|
||||
return
|
||||
|
||||
# check availability
|
||||
availability_warnings = check_user_availability(selected_user, selected_team)
|
||||
if availability_warnings:
|
||||
# display warnings and require additional confirmation
|
||||
view = _display_availability_warnings(payload, availability_warnings, selected_organization, selected_user)
|
||||
self._slack_client.api_call(
|
||||
"views.push",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
)
|
||||
else:
|
||||
# user is available to be paged
|
||||
updated_payload = add_or_update_user(payload, selected_user.pk, DEFAULT_POLICY)
|
||||
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
|
||||
self._slack_client.api_call(
|
||||
"views.update",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
view_id=payload["view"]["id"],
|
||||
)
|
||||
|
||||
|
||||
class OnUserActionChange(scenario_step.ScenarioStep):
|
||||
"""Reload form with updated user details."""
|
||||
|
||||
def _parse_action(self, payload):
|
||||
value = payload["actions"][0]["selected_option"]["value"]
|
||||
return value.split("|")
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, policy=None):
|
||||
policy, user_pk = self._parse_action(payload)
|
||||
|
||||
if policy == REMOVE_ACTION:
|
||||
updated_payload = remove_user(payload, user_pk)
|
||||
else:
|
||||
updated_payload = add_or_update_user(payload, user_pk, policy)
|
||||
|
||||
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
|
||||
self._slack_client.api_call(
|
||||
"views.update",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
view_id=payload["view"]["id"],
|
||||
)
|
||||
|
||||
|
||||
class OnConfirmUserChange(scenario_step.ScenarioStep):
|
||||
"""Confirm user selection despite not being available."""
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
metadata = json.loads(payload["view"]["private_metadata"])
|
||||
|
||||
# recreate original view state and metadata
|
||||
private_metadata = {
|
||||
"channel_id": metadata["channel_id"],
|
||||
"input_id_prefix": metadata["input_id_prefix"],
|
||||
"submit_routing_uid": metadata["submit_routing_uid"],
|
||||
"current_users": metadata["current_users"],
|
||||
}
|
||||
previous_view_payload = {
|
||||
"view": {
|
||||
"state": metadata["state"],
|
||||
"private_metadata": json.dumps(private_metadata),
|
||||
},
|
||||
}
|
||||
# add selected user
|
||||
selected_user = _get_selected_user_from_payload(previous_view_payload, private_metadata["input_id_prefix"])
|
||||
updated_payload = add_or_update_user(previous_view_payload, selected_user.pk, DEFAULT_POLICY)
|
||||
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
|
||||
self._slack_client.api_call(
|
||||
"views.update",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
view_id=payload["view"]["previous_view_id"],
|
||||
)
|
||||
|
||||
|
||||
# slack view/blocks rendering helpers
|
||||
|
||||
|
||||
def render_dialog(slack_user_identity, slack_team_identity, payload):
|
||||
# data/state
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
submit_routing_uid = private_metadata.get("submit_routing_uid")
|
||||
old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata(
|
||||
private_metadata
|
||||
)
|
||||
selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix)
|
||||
selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix)
|
||||
selected_user = _get_selected_user_from_payload(payload, old_input_id_prefix)
|
||||
|
||||
# widgets
|
||||
organization_select = _get_organization_select(
|
||||
slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix
|
||||
)
|
||||
team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix)
|
||||
users_select = _get_users_select(
|
||||
slack_user_identity, selected_organization, selected_team, selected_user, new_input_id_prefix
|
||||
)
|
||||
|
||||
# blocks
|
||||
blocks = [organization_select, team_select, users_select]
|
||||
selected_users = get_current_users(payload, selected_organization)
|
||||
blocks.extend(_get_selected_users_list(new_input_id_prefix, selected_users))
|
||||
blocks.extend([_get_title_input(payload), _get_message_input(payload)])
|
||||
|
||||
view = _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata))
|
||||
return view
|
||||
|
||||
|
||||
def _get_form_view(routing_uid, blocks, private_metatada):
|
||||
view = {
|
||||
"type": "modal",
|
||||
"callback_id": routing_uid,
|
||||
"title": {
|
||||
"type": "plain_text",
|
||||
"text": "Create alert group",
|
||||
},
|
||||
"close": {
|
||||
"type": "plain_text",
|
||||
"text": "Cancel",
|
||||
"emoji": True,
|
||||
},
|
||||
"submit": {
|
||||
"type": "plain_text",
|
||||
"text": "Submit",
|
||||
},
|
||||
"blocks": blocks,
|
||||
"private_metadata": private_metatada,
|
||||
}
|
||||
|
||||
return view
|
||||
|
||||
|
||||
def _get_initial_form_fields(slack_team_identity, slack_user_identity, input_id_prefix, payload):
|
||||
initial_organization = (
|
||||
slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity)
|
||||
.order_by("pk")
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
|
||||
organization_select = _get_organization_select(
|
||||
slack_team_identity, slack_user_identity, initial_organization, input_id_prefix
|
||||
)
|
||||
|
||||
initial_team = None # means default team
|
||||
initial_user = None # no user
|
||||
team_select = _get_team_select(slack_user_identity, initial_organization, initial_team, input_id_prefix)
|
||||
users_select = _get_users_select(
|
||||
slack_user_identity, initial_organization, initial_team, initial_user, input_id_prefix
|
||||
)
|
||||
|
||||
blocks = [organization_select, team_select, users_select]
|
||||
title_input = _get_title_input(payload)
|
||||
message_input = _get_message_input(payload)
|
||||
blocks.append(title_input)
|
||||
blocks.append(message_input)
|
||||
return blocks
|
||||
|
||||
|
||||
def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix):
|
||||
organizations = slack_team_identity.organizations.filter(
|
||||
users__slack_user_identity=slack_user_identity,
|
||||
).distinct()
|
||||
organizations_options = []
|
||||
initial_option_idx = 0
|
||||
for idx, org in enumerate(organizations):
|
||||
if org == value:
|
||||
initial_option_idx = idx
|
||||
organizations_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"{org.stack_slug}",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{org.pk}",
|
||||
}
|
||||
)
|
||||
|
||||
organization_select = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "Select an organization"},
|
||||
"block_id": input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID,
|
||||
"accessory": {
|
||||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "Select an organization", "emoji": True},
|
||||
"options": organizations_options,
|
||||
"action_id": OnOrgChange.routing_uid(),
|
||||
"initial_option": organizations_options[initial_option_idx],
|
||||
},
|
||||
}
|
||||
|
||||
return organization_select
|
||||
|
||||
|
||||
def _get_selected_org_from_payload(payload, input_id_prefix):
|
||||
Organization = apps.get_model("user_management", "Organization")
|
||||
selected_org_id = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID][
|
||||
OnOrgChange.routing_uid()
|
||||
]["selected_option"]["value"]
|
||||
org = Organization.objects.filter(pk=selected_org_id).first()
|
||||
return org
|
||||
|
||||
|
||||
def _get_team_select(slack_user_identity, organization, value, input_id_prefix):
|
||||
teams = organization.teams.filter(
|
||||
users__slack_user_identity=slack_user_identity,
|
||||
).distinct()
|
||||
|
||||
team_options = []
|
||||
# Adding pseudo option for default team
|
||||
initial_option_idx = 0
|
||||
team_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"General",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": DEFAULT_TEAM_VALUE,
|
||||
}
|
||||
)
|
||||
for idx, team in enumerate(teams, start=1):
|
||||
if team == value:
|
||||
initial_option_idx = idx
|
||||
team_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"{team.name}",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{team.pk}",
|
||||
}
|
||||
)
|
||||
|
||||
team_select = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "Select a team"},
|
||||
"block_id": input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID,
|
||||
"accessory": {
|
||||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "Select a team", "emoji": True},
|
||||
"options": team_options,
|
||||
"action_id": OnTeamChange.routing_uid(),
|
||||
"initial_option": team_options[initial_option_idx],
|
||||
},
|
||||
}
|
||||
return team_select
|
||||
|
||||
|
||||
def _get_users_select(slack_user_identity, organization, team, value, input_id_prefix):
|
||||
users = organization.users.all()
|
||||
if team is not None:
|
||||
users = users.filter(teams=team)
|
||||
|
||||
user_options = [
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"{user.name or user.username}",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{user.pk}",
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
|
||||
user_select = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "Add responders"},
|
||||
"block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID,
|
||||
"accessory": {
|
||||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "Select a user", "emoji": True},
|
||||
"options": user_options,
|
||||
"action_id": OnUserChange.routing_uid(),
|
||||
},
|
||||
}
|
||||
return user_select
|
||||
|
||||
|
||||
def _get_selected_users_list(input_id_prefix, users):
|
||||
user_entries = (
|
||||
[{"type": "divider"}]
|
||||
+ [
|
||||
{
|
||||
"type": "section",
|
||||
"block_id": input_id_prefix + f"user_{u.pk}",
|
||||
"text": {"type": "mrkdwn", "text": f"*{u.name or u.username}* | {p} notifications\n_({u.timezone})_"},
|
||||
"accessory": {
|
||||
"type": "overflow",
|
||||
"options": [
|
||||
{"text": {"type": "plain_text", "text": f"{label}"}, "value": f"{action}|{u.pk}"}
|
||||
for (action, label) in USER_ACTIONS
|
||||
],
|
||||
"action_id": OnUserActionChange.routing_uid(),
|
||||
},
|
||||
}
|
||||
for u, p in users
|
||||
]
|
||||
+ [{"type": "divider"}]
|
||||
)
|
||||
return user_entries
|
||||
|
||||
|
||||
def _display_availability_warnings(payload, warnings, organization, user):
|
||||
metadata = json.loads(payload["view"]["private_metadata"])
|
||||
|
||||
messages = []
|
||||
for w in warnings:
|
||||
if w["error"] == 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."
|
||||
)
|
||||
schedules_available = w["data"].get("schedules", {})
|
||||
if schedules_available:
|
||||
messages.append(":information_source: Currently on-call from schedules:")
|
||||
for schedule, users in schedules_available.items():
|
||||
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:
|
||||
messages.append(f":warning: User *{user.name or user.username}* has no notification policy setup.")
|
||||
|
||||
return {
|
||||
"type": "modal",
|
||||
"callback_id": OnConfirmUserChange.routing_uid(),
|
||||
"title": {"type": "plain_text", "text": "Are you sure?"},
|
||||
"submit": {"type": "plain_text", "text": "Confirm"},
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": message,
|
||||
},
|
||||
}
|
||||
for message in messages
|
||||
],
|
||||
"private_metadata": json.dumps(
|
||||
{
|
||||
"state": payload["view"]["state"],
|
||||
"input_id_prefix": metadata["input_id_prefix"],
|
||||
"channel_id": metadata["channel_id"],
|
||||
"submit_routing_uid": metadata["submit_routing_uid"],
|
||||
"current_users": metadata["current_users"],
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _get_selected_team_from_payload(payload, input_id_prefix):
|
||||
Team = apps.get_model("user_management", "Team")
|
||||
selected_team_id = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID][
|
||||
OnTeamChange.routing_uid()
|
||||
]["selected_option"]["value"]
|
||||
if selected_team_id == DEFAULT_TEAM_VALUE:
|
||||
return None
|
||||
team = Team.objects.filter(pk=selected_team_id).first()
|
||||
return team
|
||||
|
||||
|
||||
def _get_selected_user_from_payload(payload, input_id_prefix):
|
||||
User = apps.get_model("user_management", "User")
|
||||
selected_option = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_USER_SELECT_ID][
|
||||
OnUserChange.routing_uid()
|
||||
]["selected_option"]
|
||||
if selected_option is not None:
|
||||
selected_user_id = selected_option["value"]
|
||||
user = User.objects.filter(pk=selected_user_id).first()
|
||||
return user
|
||||
|
||||
|
||||
def _get_and_change_input_id_prefix_from_metadata(metadata):
|
||||
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 = {
|
||||
"type": "input",
|
||||
"block_id": DIRECT_PAGING_TITLE_INPUT_ID,
|
||||
"label": {
|
||||
"type": "plain_text",
|
||||
"text": "Title:",
|
||||
},
|
||||
"element": {
|
||||
"type": "plain_text_input",
|
||||
"action_id": FinishDirectPaging.routing_uid(),
|
||||
"placeholder": {
|
||||
"type": "plain_text",
|
||||
"text": " ",
|
||||
},
|
||||
},
|
||||
}
|
||||
if payload.get("text", None) is not None:
|
||||
title_input_block["element"]["initial_value"] = payload["text"]
|
||||
return title_input_block
|
||||
|
||||
|
||||
def _get_title_from_payload(payload):
|
||||
title = payload["view"]["state"]["values"][DIRECT_PAGING_TITLE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"]
|
||||
return title
|
||||
|
||||
|
||||
def _get_message_input(payload):
|
||||
message_input_block = {
|
||||
"type": "input",
|
||||
"block_id": DIRECT_PAGING_MESSAGE_INPUT_ID,
|
||||
"label": {
|
||||
"type": "plain_text",
|
||||
"text": "Message:",
|
||||
},
|
||||
"element": {
|
||||
"type": "plain_text_input",
|
||||
"action_id": FinishDirectPaging.routing_uid(),
|
||||
"multiline": True,
|
||||
"placeholder": {
|
||||
"type": "plain_text",
|
||||
"text": " ",
|
||||
},
|
||||
},
|
||||
"optional": True,
|
||||
}
|
||||
if payload.get("message", {}).get("text") is not None:
|
||||
message_input_block["element"]["initial_value"] = payload["message"]["text"]
|
||||
return message_input_block
|
||||
|
||||
|
||||
def _get_message_from_payload(payload):
|
||||
message = (
|
||||
payload["view"]["state"]["values"][DIRECT_PAGING_MESSAGE_INPUT_ID][FinishDirectPaging.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():
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
STEPS_ROUTING = [
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
|
||||
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_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,
|
||||
"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,
|
||||
"block_action_id": OnUserChange.routing_uid(),
|
||||
"step": OnUserChange,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION,
|
||||
"view_callback_id": OnConfirmUserChange.routing_uid(),
|
||||
"step": OnConfirmUserChange,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
|
||||
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_OVERFLOW,
|
||||
"block_action_id": OnUserActionChange.routing_uid(),
|
||||
"step": OnUserActionChange,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND,
|
||||
"command_name": StartDirectPaging.command_name,
|
||||
"step": StartDirectPaging,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION,
|
||||
"view_callback_id": FinishDirectPaging.routing_uid(),
|
||||
"step": FinishDirectPaging,
|
||||
},
|
||||
]
|
||||
183
engine/apps/slack/tests/test_scenario_steps/test_paging.py
Normal file
183
engine/apps/slack/tests/test_scenario_steps/test_paging.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
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_MESSAGE_INPUT_ID,
|
||||
DIRECT_PAGING_ORG_SELECT_ID,
|
||||
DIRECT_PAGING_TEAM_SELECT_ID,
|
||||
DIRECT_PAGING_TITLE_INPUT_ID,
|
||||
DIRECT_PAGING_USER_SELECT_ID,
|
||||
IMPORTANT_POLICY,
|
||||
REMOVE_ACTION,
|
||||
FinishDirectPaging,
|
||||
OnOrgChange,
|
||||
OnTeamChange,
|
||||
OnUserActionChange,
|
||||
OnUserChange,
|
||||
StartDirectPaging,
|
||||
)
|
||||
|
||||
|
||||
def make_slack_payload(organization, user=None, current_users=None, actions=None):
|
||||
payload = {
|
||||
"channel_id": "123",
|
||||
"trigger_id": "111",
|
||||
"view": {
|
||||
"id": "view-id",
|
||||
"private_metadata": json.dumps(
|
||||
{
|
||||
"input_id_prefix": "",
|
||||
"channel_id": "123",
|
||||
"submit_routing_uid": "FinishStepUID",
|
||||
"current_users": current_users or {},
|
||||
}
|
||||
),
|
||||
"state": {
|
||||
"values": {
|
||||
DIRECT_PAGING_ORG_SELECT_ID: {
|
||||
OnOrgChange.routing_uid(): {"selected_option": {"value": organization.pk}}
|
||||
},
|
||||
DIRECT_PAGING_TEAM_SELECT_ID: {OnTeamChange.routing_uid(): {"selected_option": {"value": 0}}},
|
||||
DIRECT_PAGING_USER_SELECT_ID: {
|
||||
OnUserChange.routing_uid(): {"selected_option": {"value": user.pk} if user else None}
|
||||
},
|
||||
DIRECT_PAGING_TITLE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Title"}},
|
||||
DIRECT_PAGING_MESSAGE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Message"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
if actions is not None:
|
||||
payload["actions"] = actions
|
||||
return payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_initial_users(
|
||||
make_organization_and_user_with_slack_identities,
|
||||
):
|
||||
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
payload = {"channel_id": "123", "trigger_id": "111"}
|
||||
|
||||
step = StartDirectPaging(slack_team_identity)
|
||||
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
||||
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["current_users"] == {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_add_user_no_warning(
|
||||
make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift, make_user_notification_policy
|
||||
):
|
||||
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
# set up schedule: user is on call
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
team=None,
|
||||
)
|
||||
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = now - timezone.timedelta(days=7)
|
||||
data = {
|
||||
"start": start_date,
|
||||
"rotation_start": start_date,
|
||||
"duration": timezone.timedelta(hours=23, minutes=59, seconds=59),
|
||||
"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]])
|
||||
schedule.refresh_ical_file()
|
||||
# setup notification policy
|
||||
make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.SMS,
|
||||
)
|
||||
|
||||
payload = make_slack_payload(organization=organization, user=user)
|
||||
|
||||
step = OnUserChange(slack_team_identity)
|
||||
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
||||
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["current_users"] == {str(user.pk): DEFAULT_POLICY}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_add_user_raise_warning(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift):
|
||||
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
# user is not on call
|
||||
payload = make_slack_payload(organization=organization, user=user)
|
||||
|
||||
step = OnUserChange(slack_team_identity)
|
||||
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
||||
assert mock_slack_api_call.call_args.args == ("views.push",)
|
||||
assert mock_slack_api_call.call_args.kwargs["view"]["callback_id"] == "OnConfirmUserChange"
|
||||
text_from_blocks = "".join(
|
||||
b["text"]["text"] for b in mock_slack_api_call.call_args.kwargs["view"]["blocks"] if b["type"] == "section"
|
||||
)
|
||||
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["current_users"] == {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_user_policy(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift):
|
||||
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}|{user.pk}"}}]
|
||||
)
|
||||
|
||||
step = OnUserActionChange(slack_team_identity)
|
||||
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
||||
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["current_users"] == {str(user.pk): IMPORTANT_POLICY}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_remove_user(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift):
|
||||
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}|{user.pk}"}}]
|
||||
)
|
||||
|
||||
step = OnUserActionChange(slack_team_identity)
|
||||
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
||||
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["current_users"] == {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_trigger_paging(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift):
|
||||
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
payload = make_slack_payload(organization=organization, current_users={str(user.pk): IMPORTANT_POLICY})
|
||||
|
||||
step = FinishDirectPaging(slack_team_identity)
|
||||
with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging:
|
||||
with patch.object(step._slack_client, "api_call"):
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
||||
assert mock_direct_paging.called_with(organization, None, user, "The Title", "The Message", [(user, True)])
|
||||
|
|
@ -15,12 +15,14 @@ from apps.api.permissions import RBACPermission
|
|||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.base.utils import live_settings
|
||||
from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING
|
||||
|
||||
# Importing routes from scenarios
|
||||
from apps.slack.scenarios.declare_incident import STEPS_ROUTING as DECLARE_INCIDENT_ROUTING
|
||||
from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION_STEPS_ROUTING
|
||||
from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING
|
||||
from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING
|
||||
|
||||
# Importing routes from scenarios
|
||||
from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING
|
||||
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 (
|
||||
|
|
@ -68,6 +70,8 @@ SCENARIOS_ROUTES.extend(SLACK_USERGROUP_UPDATE_ROUTING)
|
|||
SCENARIOS_ROUTES.extend(CHANNEL_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(PROFILE_UPDATE_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(MANUAL_INCIDENT_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(DIRECT_PAGE_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(DECLARE_INCIDENT_ROUTING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -516,8 +520,8 @@ class SlackEventApiEndpointView(APIView):
|
|||
return
|
||||
|
||||
text = (
|
||||
"The information in workspace is read-only. To be able to intercat with OnCall alert groups you need to connect a personal account.\n"
|
||||
"Please go to the *Grafana* -> *OnCall* -> *Users*, "
|
||||
"The information in this workspace is read-only. To interact with OnCall alert groups you need to connect a personal account.\n"
|
||||
"Please go to *Grafana* -> *OnCall* -> *Users*, "
|
||||
"choose *your profile* and click the *connect* button.\n"
|
||||
":rocket: :rocket: :rocket:"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ def sync_organization(organization):
|
|||
|
||||
_sync_instance_info(organization)
|
||||
|
||||
api_users = grafana_api_client.get_users(rbac_is_enabled)
|
||||
|
||||
if api_users:
|
||||
_, check_token_call_status = grafana_api_client.check_token()
|
||||
if check_token_call_status["status_code"] == 200:
|
||||
organization.api_token_status = Organization.API_TOKEN_STATUS_OK
|
||||
sync_users_and_teams(grafana_api_client, api_users, organization)
|
||||
sync_users_and_teams(grafana_api_client, organization)
|
||||
organization.last_time_synced = timezone.now()
|
||||
organization.is_grafana_incident_enabled = check_grafana_incident_is_enabled(grafana_api_client)
|
||||
else:
|
||||
organization.api_token_status = Organization.API_TOKEN_STATUS_FAILED
|
||||
|
|
@ -71,27 +71,42 @@ def _sync_instance_info(organization):
|
|||
organization.gcom_token_org_last_time_synced = timezone.now()
|
||||
|
||||
|
||||
def sync_users_and_teams(client, api_users, organization):
|
||||
def sync_users_and_teams(client: GrafanaAPIClient, organization):
|
||||
sync_users(client, organization)
|
||||
sync_teams(client, organization)
|
||||
sync_team_members(client, organization)
|
||||
|
||||
|
||||
def sync_users(client: GrafanaAPIClient, organization, **kwargs):
|
||||
api_users = client.get_users(organization.is_rbac_permissions_enabled, **kwargs)
|
||||
# check if api_users are shaped correctly. e.g. for paused instance, the response is not a list.
|
||||
if not api_users or not isinstance(api_users, (tuple, list)):
|
||||
return
|
||||
|
||||
User.objects.sync_for_organization(organization=organization, api_users=api_users)
|
||||
|
||||
api_teams_result, _ = client.get_teams()
|
||||
|
||||
def sync_teams(client: GrafanaAPIClient, organization, **kwargs):
|
||||
api_teams_result, _ = client.get_teams(**kwargs)
|
||||
if not api_teams_result:
|
||||
return
|
||||
|
||||
api_teams = api_teams_result["teams"]
|
||||
Team.objects.sync_for_organization(organization=organization, api_teams=api_teams)
|
||||
|
||||
|
||||
def sync_team_members(client: GrafanaAPIClient, organization):
|
||||
for team in organization.teams.all():
|
||||
members, _ = client.get_team_members(team.team_id)
|
||||
if not members:
|
||||
continue
|
||||
User.objects.sync_for_team(team=team, api_members=members)
|
||||
|
||||
organization.last_time_synced = timezone.now()
|
||||
|
||||
def sync_users_for_teams(client: GrafanaAPIClient, organization, **kwargs):
|
||||
api_teams_result, _ = client.get_teams(**kwargs)
|
||||
if not api_teams_result:
|
||||
return
|
||||
api_teams = api_teams_result["teams"]
|
||||
Team.objects.sync_for_organization(organization=organization, api_teams=api_teams)
|
||||
|
||||
|
||||
def check_grafana_incident_is_enabled(client):
|
||||
|
|
|
|||
|
|
@ -134,14 +134,19 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat
|
|||
},
|
||||
)
|
||||
|
||||
api_check_token_call_status = {"status_code": 200}
|
||||
|
||||
with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=False):
|
||||
with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response):
|
||||
with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)):
|
||||
with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)):
|
||||
with patch.object(
|
||||
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
|
||||
GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status)
|
||||
):
|
||||
sync_organization(organization)
|
||||
with patch.object(
|
||||
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
|
||||
):
|
||||
sync_organization(organization)
|
||||
|
||||
# check that users are populated
|
||||
assert organization.users.count() == 1
|
||||
|
|
@ -167,9 +172,50 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat
|
|||
def test_sync_organization_is_rbac_permissions_enabled_open_source(make_organization, grafana_api_response):
|
||||
organization = make_organization()
|
||||
|
||||
api_users_response = (
|
||||
{
|
||||
"userId": 1,
|
||||
"email": "test@test.test",
|
||||
"name": "Test",
|
||||
"login": "test",
|
||||
"role": "admin",
|
||||
"avatarUrl": "test.test/test",
|
||||
"permissions": [],
|
||||
},
|
||||
)
|
||||
|
||||
api_teams_response = {
|
||||
"totalCount": 1,
|
||||
"teams": (
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Test",
|
||||
"email": "test@test.test",
|
||||
"avatarUrl": "test.test/test",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
api_members_response = (
|
||||
{
|
||||
"orgId": organization.org_id,
|
||||
"teamId": 1,
|
||||
"userId": 1,
|
||||
},
|
||||
)
|
||||
api_check_token_call_status = {"status_code": 200}
|
||||
|
||||
with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=grafana_api_response):
|
||||
with patch.object(GrafanaAPIClient, "get_users", return_value=[]):
|
||||
sync_organization(organization)
|
||||
with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response):
|
||||
with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)):
|
||||
with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)):
|
||||
with patch.object(
|
||||
GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status)
|
||||
):
|
||||
with patch.object(
|
||||
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
|
||||
):
|
||||
sync_organization(organization)
|
||||
|
||||
organization.refresh_from_db()
|
||||
assert organization.is_rbac_permissions_enabled == grafana_api_response
|
||||
|
|
@ -184,10 +230,50 @@ def test_sync_organization_is_rbac_permissions_enabled_cloud(mocked_gcom_client,
|
|||
stack_id = 5
|
||||
organization = make_organization(stack_id=stack_id)
|
||||
|
||||
api_check_token_call_status = {"status_code": 200}
|
||||
|
||||
mocked_gcom_client.return_value.is_rbac_enabled_for_stack.return_value = gcom_api_response
|
||||
|
||||
with patch.object(GrafanaAPIClient, "get_users", return_value=[]):
|
||||
sync_organization(organization)
|
||||
api_users_response = (
|
||||
{
|
||||
"userId": 1,
|
||||
"email": "test@test.test",
|
||||
"name": "Test",
|
||||
"login": "test",
|
||||
"role": "admin",
|
||||
"avatarUrl": "test.test/test",
|
||||
"permissions": [],
|
||||
},
|
||||
)
|
||||
|
||||
api_teams_response = {
|
||||
"totalCount": 1,
|
||||
"teams": (
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Test",
|
||||
"email": "test@test.test",
|
||||
"avatarUrl": "test.test/test",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
api_members_response = (
|
||||
{
|
||||
"orgId": organization.org_id,
|
||||
"teamId": 1,
|
||||
"userId": 1,
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status)):
|
||||
with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response):
|
||||
with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)):
|
||||
with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)):
|
||||
with patch.object(
|
||||
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
|
||||
):
|
||||
sync_organization(organization)
|
||||
|
||||
organization.refresh_from_db()
|
||||
|
||||
|
|
|
|||
|
|
@ -129,11 +129,8 @@ class OnCallGatewayAPIClient:
|
|||
if response.status_code not in [200, 201, 202, 204]:
|
||||
err_msg = cls._get_error_msg_from_response(response)
|
||||
if 400 <= response.status_code < 500:
|
||||
print(1)
|
||||
err_msg = "%s Client Error: %s for url: %s" % (response.status_code, err_msg, response.url)
|
||||
|
||||
elif 500 <= response.status_code < 600:
|
||||
print(2)
|
||||
err_msg = "%s Server Error: %s for url: %s" % (response.status_code, err_msg, response.url)
|
||||
print(err_msg)
|
||||
raise requests.exceptions.HTTPError(err_msg, response=response)
|
||||
|
|
|
|||
|
|
@ -736,10 +736,12 @@ def make_integration_heartbeat():
|
|||
return _make_integration_heartbeat
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def load_slack_urls(settings):
|
||||
def reload_urls(settings):
|
||||
"""
|
||||
Reloads Django URLs, especially useful when testing conditionally registered URLs
|
||||
"""
|
||||
|
||||
clear_url_caches()
|
||||
settings.FEATURE_SLACK_INTEGRATION_ENABLED = True
|
||||
urlconf = settings.ROOT_URLCONF
|
||||
if urlconf in sys.modules:
|
||||
reload(sys.modules[urlconf])
|
||||
|
|
@ -747,6 +749,18 @@ def load_slack_urls(settings):
|
|||
import_module(urlconf)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def load_slack_urls(settings):
|
||||
settings.FEATURE_SLACK_INTEGRATION_ENABLED = True
|
||||
reload_urls(settings)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def load_mobile_app_urls(settings):
|
||||
settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED = True
|
||||
reload_urls(settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_region():
|
||||
def _make_region(**kwargs):
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
|
|||
|
||||
if settings.OSS_INSTALLATION:
|
||||
urlpatterns += [
|
||||
path("api/internal/v1/", include("apps.oss_installation.urls")),
|
||||
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
|
@ -70,5 +70,7 @@ if settings.DEBUG:
|
|||
path("__debug__/", include(debug_toolbar.urls)),
|
||||
] + urlpatterns
|
||||
|
||||
if settings.SILK_PROFILER_ENABLED:
|
||||
urlpatterns += [path(settings.SILK_PATH, include("silk.urls", namespace="silk"))]
|
||||
|
||||
admin.site.site_header = settings.ADMIN_SITE_HEADER
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ django-cors-headers==3.7.0
|
|||
django-debug-toolbar==3.2.1
|
||||
django-sns-view==0.1.2
|
||||
python-telegram-bot==13.13
|
||||
django-silk==4.1.0
|
||||
django-silk==5.0.3
|
||||
django-redis-cache==3.0.0
|
||||
hiredis==1.0.0
|
||||
django-ratelimit==2.0.0
|
||||
|
|
@ -23,7 +23,7 @@ recurring-ical-events==0.1.16b0
|
|||
slack-export-viewer==1.0.0
|
||||
beautifulsoup4==4.8.1
|
||||
social-auth-app-django==3.1.0
|
||||
cryptography==39.0.0
|
||||
cryptography==38.0.4 # version 39.0.0 introduced an issue - https://stackoverflow.com/a/75053968/3902555
|
||||
pytest==5.4.3
|
||||
pytest-django==3.9.0
|
||||
pytest_factoryboy==2.0.3
|
||||
|
|
@ -45,3 +45,4 @@ opentelemetry-instrumentation-celery==0.36b0
|
|||
opentelemetry-instrumentation-pymysql==0.36b0
|
||||
opentelemetry-instrumentation-wsgi==0.36b0
|
||||
opentelemetry-exporter-otlp-proto-grpc==1.15.0
|
||||
pyroscope-io==0.8.1
|
||||
|
|
@ -464,14 +464,20 @@ INTERNAL_IPS = ["127.0.0.1"]
|
|||
|
||||
SELF_IP = os.environ.get("SELF_IP")
|
||||
|
||||
SILK_PATH = os.environ.get("SILK_PATH", "silk/")
|
||||
SILKY_AUTHENTICATION = True
|
||||
SILKY_AUTHORISATION = True
|
||||
SILKY_META = True
|
||||
SILKY_INTERCEPT_PERCENT = 1
|
||||
SILKY_MAX_RECORDED_REQUESTS = 10**4
|
||||
SILK_PROFILER_ENABLED = getenv_boolean("SILK_PROFILER_ENABLED", default=False)
|
||||
if SILK_PROFILER_ENABLED:
|
||||
SILK_PATH = os.environ.get("SILK_PATH", "silk/")
|
||||
SILKY_INTERCEPT_PERCENT = getenv_integer("SILKY_INTERCEPT_PERCENT", 100)
|
||||
|
||||
INSTALLED_APPS += ["silk"]
|
||||
MIDDLEWARE += ["silk.middleware.SilkyMiddleware"]
|
||||
|
||||
SILKY_AUTHENTICATION = True
|
||||
SILKY_AUTHORISATION = True
|
||||
SILKY_META = True
|
||||
SILKY_MAX_RECORDED_REQUESTS = 10**4
|
||||
SILKY_PYTHON_PROFILER = True
|
||||
|
||||
INSTALLED_APPS += ["silk"]
|
||||
# get ONCALL_DJANGO_ADMIN_PATH from env and add trailing / to it
|
||||
ONCALL_DJANGO_ADMIN_PATH = os.environ.get("ONCALL_DJANGO_ADMIN_PATH", "django-admin") + "/"
|
||||
|
||||
|
|
@ -495,6 +501,7 @@ SLACK_CLIENT_OAUTH_ID = os.environ.get("SLACK_CLIENT_OAUTH_ID")
|
|||
SLACK_CLIENT_OAUTH_SECRET = os.environ.get("SLACK_CLIENT_OAUTH_SECRET")
|
||||
|
||||
SLACK_SLASH_COMMAND_NAME = os.environ.get("SLACK_SLASH_COMMAND_NAME", "/oncall")
|
||||
SLACK_DIRECT_PAGING_SLASH_COMMAND = os.environ.get("SLACK_DIRECT_PAGING_SLASH_COMMAND", "/escalate")
|
||||
|
||||
SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID
|
||||
SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET
|
||||
|
|
@ -642,3 +649,17 @@ if OSS_INSTALLATION:
|
|||
"schedule": crontab(hour="*/12"), # noqa
|
||||
"args": (),
|
||||
} # noqa
|
||||
|
||||
PYROSCOPE_PROFILER_ENABLED = getenv_boolean("PYROSCOPE_PROFILER_ENABLED", default=False)
|
||||
if PYROSCOPE_PROFILER_ENABLED:
|
||||
import pyroscope
|
||||
|
||||
pyroscope.configure(
|
||||
application_name=os.getenv("PYROSCOPE_APPLICATION_NAME", "oncall"),
|
||||
server_address=os.getenv("PYROSCOPE_SERVER_ADDRESS", "http://pyroscope:4040"),
|
||||
auth_token=os.getenv("PYROSCOPE_AUTH_TOKEN", ""),
|
||||
detect_subprocesses=True,
|
||||
tags={
|
||||
"celery_worker": os.getenv("CELERY_WORKER_QUEUE", None),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# flake8: noqa
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from .base import *
|
||||
|
|
@ -25,35 +26,31 @@ MIRAGE_CIPHER_IV = os.environ.get("MIRAGE_CIPHER_IV", "tZZa+60zTZO2NRcS")
|
|||
|
||||
TESTING = "pytest" in sys.modules or "unittest" in sys.modules
|
||||
|
||||
SILKY_PYTHON_PROFILER = True
|
||||
|
||||
# For any requests that come in with that header/value, request.is_secure() will return True.
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
# Uncomment this to view SQL queries
|
||||
# LOGGING = {
|
||||
# 'version': 1,
|
||||
# 'filters': {
|
||||
# 'require_debug_true': {
|
||||
# '()': 'django.utils.log.RequireDebugTrue',
|
||||
# }
|
||||
# },
|
||||
# 'handlers': {
|
||||
# 'console': {
|
||||
# 'level': 'DEBUG',
|
||||
# 'filters': ['require_debug_true'],
|
||||
# 'class': 'logging.StreamHandler',
|
||||
# }
|
||||
# },
|
||||
# 'loggers': {
|
||||
# 'django.db.backends': {
|
||||
# 'level': 'DEBUG',
|
||||
# 'handlers': ['console'],
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
SILKY_INTERCEPT_PERCENT = 100
|
||||
if getenv_boolean("DEV_DEBUG_VIEW_SQL_QUERIES", default=False):
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"filters": {
|
||||
"require_debug_true": {
|
||||
"()": "django.utils.log.RequireDebugTrue",
|
||||
}
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"filters": ["require_debug_true"],
|
||||
"class": "logging.StreamHandler",
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
"django.db.backends": {
|
||||
"level": "DEBUG",
|
||||
"handlers": ["console"],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
"SECURITY_DEFINITIONS": {
|
||||
|
|
@ -67,3 +64,13 @@ if TESTING:
|
|||
EXTRA_MESSAGING_BACKENDS = [("apps.base.tests.messaging_backend.TestOnlyBackend", 42)]
|
||||
TELEGRAM_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX"
|
||||
TWILIO_AUTH_TOKEN = "twilio_auth_token"
|
||||
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
]
|
||||
|
||||
# the below two lines make it possible to use django-debug-toolbar inside of docker locally
|
||||
# https://knasmueller.net/fix-djangos-debug-toolbar-not-showing-inside-docker
|
||||
# https://stackoverflow.com/questions/10517765/django-debug-toolbar-not-showing-up
|
||||
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
|
||||
INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips]
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export interface NotificationPolicyProps {
|
|||
notifyByOptions?: NotifyBy[];
|
||||
telegramVerified: boolean;
|
||||
phoneStatus: number;
|
||||
isMobileAppConnected: boolean;
|
||||
showCloudConnectionWarning: boolean;
|
||||
color: string;
|
||||
number: number;
|
||||
userAction: UserAction;
|
||||
|
|
@ -132,6 +134,20 @@ export class NotificationPolicy extends React.Component<NotificationPolicyProps,
|
|||
}
|
||||
}
|
||||
|
||||
_renderMobileAppNote() {
|
||||
const { isMobileAppConnected, showCloudConnectionWarning } = this.props;
|
||||
|
||||
if (showCloudConnectionWarning) {
|
||||
return <PolicyNote type="danger">Cloud is not connected</PolicyNote>;
|
||||
}
|
||||
|
||||
if (!isMobileAppConnected) {
|
||||
return <PolicyNote type="danger">Mobile app is not connected</PolicyNote>;
|
||||
}
|
||||
|
||||
return <PolicyNote type="success">Mobile app is connected</PolicyNote>;
|
||||
}
|
||||
|
||||
_renderTelegramNote() {
|
||||
const { telegramVerified } = this.props;
|
||||
|
||||
|
|
@ -203,6 +219,12 @@ export class NotificationPolicy extends React.Component<NotificationPolicyProps,
|
|||
case 3:
|
||||
return <>{this._renderTelegramNote()}</>;
|
||||
|
||||
case 5:
|
||||
return <>{this._renderMobileAppNote()}</>;
|
||||
|
||||
case 6:
|
||||
return <>{this._renderMobileAppNote()}</>;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import React, { ReactElement, useCallback, useState } from 'react';
|
||||
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
import { ConfirmModal, ConfirmModalProps } from '@grafana/ui';
|
||||
|
||||
interface WithConfirmProps {
|
||||
type WithConfirmProps = Partial<ConfirmModalProps> & {
|
||||
children: ReactElement;
|
||||
title?: string;
|
||||
body?: React.ReactNode;
|
||||
confirmText?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const WithConfirm = (props: WithConfirmProps) => {
|
||||
const { children, title = 'Are you sure to delete?', body, confirmText = 'Delete', disabled } = props;
|
||||
};
|
||||
|
||||
const WithConfirm: React.FC<WithConfirmProps> = ({
|
||||
title = 'Are you sure to delete?',
|
||||
confirmText = 'Delete',
|
||||
body,
|
||||
description,
|
||||
confirmationText,
|
||||
children,
|
||||
disabled,
|
||||
}) => {
|
||||
const [showConfirmation, setShowConfirmation] = useState<boolean>(false);
|
||||
|
||||
const onClickCallback = useCallback((event) => {
|
||||
|
|
@ -39,6 +42,8 @@ const WithConfirm = (props: WithConfirmProps) => {
|
|||
dismissText="Cancel"
|
||||
onConfirm={onConfirmCallback}
|
||||
body={body}
|
||||
description={description}
|
||||
confirmationText={confirmationText}
|
||||
onDismiss={() => {
|
||||
setShowConfirmation(false);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import React from 'react';
|
|||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { CloudStore } from 'models/cloud/cloud';
|
||||
import { UserStore } from 'models/user/user';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { RootStore } from 'state';
|
||||
|
|
@ -10,12 +12,33 @@ import { useStore as useStoreOriginal } from 'state/useStore';
|
|||
|
||||
import MobileAppConnection from './MobileAppConnection';
|
||||
|
||||
jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({
|
||||
isTopNavbar: () => false,
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
config: {
|
||||
featureToggles: {
|
||||
topNav: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('utils/authorization', () => ({
|
||||
...jest.requireActual('utils/authorization'),
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getLocationSrv: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('state/useStore');
|
||||
|
||||
const useStore = useStoreOriginal as jest.Mock<ReturnType<typeof useStoreOriginal>>;
|
||||
const loadUserMock = jest.fn().mockReturnValue(undefined);
|
||||
|
||||
const mockUseStore = (rest?: any, connected = false) => {
|
||||
const mockUseStore = (rest?: any, connected = false, cloud_connected = true) => {
|
||||
const store = {
|
||||
userStore: {
|
||||
loadUser: loadUserMock,
|
||||
|
|
@ -26,6 +49,11 @@ const mockUseStore = (rest?: any, connected = false) => {
|
|||
} as unknown as User,
|
||||
...(rest ? rest : {}),
|
||||
} as unknown as UserStore,
|
||||
cloudStore: {
|
||||
getCloudConnectionStatus: jest.fn().mockReturnValue({ cloud_connection_status: cloud_connected }),
|
||||
cloudConnectionStatus: { cloud_connection_status: cloud_connected },
|
||||
} as unknown as CloudStore,
|
||||
hasFeature: jest.fn().mockReturnValue(true),
|
||||
} as unknown as RootStore;
|
||||
|
||||
useStore.mockReturnValue(store);
|
||||
|
|
@ -232,4 +260,16 @@ describe('MobileAppConnection', () => {
|
|||
{ timeout: 6000 }
|
||||
);
|
||||
});
|
||||
|
||||
test('it shows a warning when cloud is not connected', async () => {
|
||||
mockUseStore({}, true, false);
|
||||
|
||||
// Using MemoryRouter to avoid "Invariant failed: You should not use <Link> outside a <Router>"
|
||||
const component = render(
|
||||
<MemoryRouter>
|
||||
<MobileAppConnection userPk={USER_PK} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { Button, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import qrCodeImage from 'assets/img/qr-code.png';
|
||||
import Block from 'components/GBlock/Block';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
|
||||
import styles from './MobileAppConnection.module.scss';
|
||||
import DisconnectButton from './parts/DisconnectButton/DisconnectButton';
|
||||
|
|
@ -29,7 +32,29 @@ const INTERVAL_POLLING = 5000;
|
|||
const BACKEND = 'MOBILE_APP';
|
||||
|
||||
const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||
const { userStore } = useStore();
|
||||
const store = useStore();
|
||||
const { userStore, cloudStore } = store;
|
||||
|
||||
// Show link to cloud page for OSS instances with no cloud connection
|
||||
if (store.hasFeature(AppFeature.CloudConnection) && !cloudStore.cloudConnectionStatus.cloud_connection_status) {
|
||||
return (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text type="secondary">Please connect Cloud OnCall to use the mobile app</Text>
|
||||
{isUserActionAllowed(UserActions.OtherSettingsWrite) ? (
|
||||
<PluginLink query={{ page: 'cloud' }}>
|
||||
<Button variant="secondary" icon="external-link-alt">
|
||||
Connect Cloud OnCall
|
||||
</Button>
|
||||
</PluginLink>
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
You do not have permission to perform this action. Ask an admin to connect Cloud OnCall or upgrade your
|
||||
permissions.
|
||||
</Text>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const isMounted = useRef(false);
|
||||
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(isUserConnected());
|
||||
|
|
|
|||
|
|
@ -2774,6 +2774,59 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="css-1j7sh2x-vertical-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-ztyofd-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
Please connect Cloud OnCall to use the mobile app
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-ztyofd-layoutChildrenWrapper"
|
||||
>
|
||||
<a
|
||||
class="root"
|
||||
href="/a/grafana-oncall-app/cloud"
|
||||
>
|
||||
<button
|
||||
class="css-1a8393j-button"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-1gebccs"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18,10.82a1,1,0,0,0-1,1V19a1,1,0,0,1-1,1H5a1,1,0,0,1-1-1V8A1,1,0,0,1,5,7h7.18a1,1,0,0,0,0-2H5A3,3,0,0,0,2,8V19a3,3,0,0,0,3,3H16a3,3,0,0,0,3-3V11.82A1,1,0,0,0,18,10.82Zm3.92-8.2a1,1,0,0,0-.54-.54A1,1,0,0,0,21,2H15a1,1,0,0,0,0,2h3.59L8.29,14.29a1,1,0,0,0,0,1.42,1,1,0,0,0,1.42,0L20,5.41V9a1,1,0,0,0,2,0V3A1,1,0,0,0,21.92,2.62Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Connect Cloud OnCall
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MobileAppConnection it shows an error message if there was an error disconnecting the mobile app 1`] = `
|
||||
<div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -113,6 +113,11 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
|
|||
return Number(user.verified_phone_number) + 2;
|
||||
};
|
||||
|
||||
// Mobile app related NotificationPolicy props
|
||||
const isMobileAppConnected = user.messaging_backends['MOBILE_APP']?.connected;
|
||||
const showCloudConnectionWarning =
|
||||
store.hasFeature(AppFeature.CloudConnection) && !store.cloudStore.cloudConnectionStatus.cloud_connection_status;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
{title}
|
||||
|
|
@ -134,6 +139,8 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
|
|||
number={index + 1}
|
||||
telegramVerified={Boolean(user.telegram_configuration)}
|
||||
phoneStatus={getPhoneStatus()}
|
||||
isMobileAppConnected={isMobileAppConnected}
|
||||
showCloudConnectionWarning={showCloudConnectionWarning}
|
||||
slackTeamIdentity={store.teamStore.currentTeam?.slack_team_identity}
|
||||
slackUserIdentity={user.slack_user_identity}
|
||||
data={notificationPolicy}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Modal } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
const SlackIntegrationButton = observer((props: { className: string; disabled?: boolean }) => {
|
||||
const { className, disabled } = props;
|
||||
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const onInstallModalCallback = useCallback(() => {
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const onInstallModalHideCallback = useCallback(() => {
|
||||
setShowModal(false);
|
||||
}, []);
|
||||
|
||||
const onRemoveClickCallback = useCallback(() => {
|
||||
store.slackStore.removeSlackIntegration().then(() => {
|
||||
store.teamStore.loadCurrentTeam();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onInstallClickCallback = useCallback(() => {
|
||||
store.slackStore.installSlackIntegration();
|
||||
}, []);
|
||||
|
||||
if (store.teamStore.currentTeam?.slack_team_identity) {
|
||||
return (
|
||||
<WithPermissionControl userAction={UserActions.IntegrationsWrite}>
|
||||
<WithConfirm title="Are you sure to delete this Slack Integration?">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="md"
|
||||
icon="slack"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={onRemoveClickCallback}
|
||||
>
|
||||
Remove Slack Integration ({store.teamStore.currentTeam.slack_team_identity?.cached_name})
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
</WithPermissionControl>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WithPermissionControl userAction={UserActions.IntegrationsWrite}>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="primary"
|
||||
icon="slack"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={onInstallModalCallback}
|
||||
>
|
||||
Connect Slack
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
{showModal && <SlackModal onHide={onInstallModalHideCallback} onConfirm={onInstallClickCallback} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface SlackModalProps {
|
||||
onHide: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const SlackModal = (props: SlackModalProps) => {
|
||||
const { onHide, onConfirm } = props;
|
||||
|
||||
return (
|
||||
<Modal title="Slack connection" closeOnEscape isOpen onDismiss={onHide}>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
You can view your Slack Workspace at the top-right corner after you are redirected. It should be a Workspace
|
||||
with App Bot installed:
|
||||
</div>
|
||||
<img
|
||||
style={{ height: '350px', display: 'block', margin: '0 auto' }}
|
||||
src="public/plugins/grafana-oncall-app/img/slack_workspace_choose_attention.png"
|
||||
/>
|
||||
<Button onClick={onConfirm}>I'll check! Proceed to Slack...</Button>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlackIntegrationButton;
|
||||
|
|
@ -122,7 +122,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
{isUserActionAllowed(UserActions.OtherSettingsWrite) ? (
|
||||
<VerticalGroup spacing="lg">
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text.Title level={3}>OnCall use Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
{syncing ? (
|
||||
<Button variant="secondary" icon="sync" disabled>
|
||||
Updating...
|
||||
|
|
@ -137,7 +137,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
</VerticalGroup>
|
||||
) : (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text.Title level={3}>OnCall use Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
<Text>You do not have permission to perform this action. Ask an admin to upgrade your permissions.</Text>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
|
|
@ -314,8 +314,6 @@ export class AlertGroupStore extends BaseStore {
|
|||
const result = await makeRequest(`${this.path}stats/`, {
|
||||
params: {
|
||||
...this.incidentFilters,
|
||||
resolved: false,
|
||||
acknowledged: false,
|
||||
status: [IncidentStatus.New],
|
||||
},
|
||||
});
|
||||
|
|
@ -327,8 +325,6 @@ export class AlertGroupStore extends BaseStore {
|
|||
const result = await makeRequest(`${this.path}stats/`, {
|
||||
params: {
|
||||
...this.incidentFilters,
|
||||
resolved: false,
|
||||
acknowledged: true,
|
||||
status: [IncidentStatus.Acknowledged],
|
||||
},
|
||||
});
|
||||
|
|
@ -341,7 +337,6 @@ export class AlertGroupStore extends BaseStore {
|
|||
const result = await makeRequest(`${this.path}stats/`, {
|
||||
params: {
|
||||
...this.incidentFilters,
|
||||
resolved: true,
|
||||
status: [IncidentStatus.Resolved],
|
||||
},
|
||||
});
|
||||
|
|
@ -354,7 +349,6 @@ export class AlertGroupStore extends BaseStore {
|
|||
const result = await makeRequest(`${this.path}stats/`, {
|
||||
params: {
|
||||
...this.incidentFilters,
|
||||
silenced: true,
|
||||
status: [IncidentStatus.Silenced],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export class CloudStore extends BaseStore {
|
|||
@observable.shallow
|
||||
items: { [id: string]: Cloud } = {};
|
||||
|
||||
@observable
|
||||
cloudConnectionStatus: { cloud_connection_status: boolean } = { cloud_connection_status: false };
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
|
|
@ -67,6 +70,10 @@ export class CloudStore extends BaseStore {
|
|||
return await makeRequest(`${this.path}${id}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async loadCloudConnectionStatus() {
|
||||
this.cloudConnectionStatus = await this.getCloudConnectionStatus();
|
||||
}
|
||||
|
||||
async getCloudConnectionStatus() {
|
||||
return await makeRequest(`/cloud_connection/`, { method: 'GET' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,7 +297,10 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
</HorizontalGroup>
|
||||
|
||||
<HorizontalGroup>
|
||||
<PluginLink query={{ page: 'integrations', id: incident.alert_receive_channel.id }}>
|
||||
<PluginLink
|
||||
disabled={incident.alert_receive_channel.deleted}
|
||||
query={{ page: 'integrations', id: incident.alert_receive_channel.id }}
|
||||
>
|
||||
<Button disabled={incident.alert_receive_channel.deleted} variant="secondary" size="sm" icon="compass">
|
||||
Go to Integration
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
import { Field, HorizontalGroup, LoadingPlaceholder, VerticalGroup, Icon, Button } from '@grafana/ui';
|
||||
import { Alert, Field, HorizontalGroup, LoadingPlaceholder, VerticalGroup, Icon, Button } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
|
|
@ -124,8 +124,30 @@ class SlackSettings extends Component<SlackProps, SlackState> {
|
|||
</WithPermissionControl>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
<WithPermissionControl userAction={UserActions.ChatOpsWrite}>
|
||||
<WithConfirm title="Are you sure to delete this Slack Integration?">
|
||||
<WithPermissionControl userAction={UserActions.ChatOpsUpdateSettings}>
|
||||
<WithConfirm
|
||||
title="Remove Slack Integration for all of OnCall"
|
||||
description={
|
||||
<Alert severity="error" title="WARNING">
|
||||
<p>Are you sure to delete this Slack Integration?</p>
|
||||
<p>
|
||||
Removing the integration will also irreverisbly remove the following data for your OnCall plugin:
|
||||
</p>
|
||||
<ul style={{ marginLeft: '20px' }}>
|
||||
<li>default organization Slack channel</li>
|
||||
<li>default Slack channels for OnCall Integrations</li>
|
||||
<li>Slack channels & Slack user groups for OnCall Schedules</li>
|
||||
<li>linked Slack usernames for OnCall Users</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p>
|
||||
If you would like to instead remove your linked Slack username, please head{' '}
|
||||
<PluginLink query={{ page: 'users/me' }}>here</PluginLink>.
|
||||
</p>
|
||||
</Alert>
|
||||
}
|
||||
confirmationText="DELETE"
|
||||
>
|
||||
<Button variant="destructive" size="sm" onClick={() => this.removeSlackIntegration()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
|
|
@ -189,16 +211,6 @@ class SlackSettings extends Component<SlackProps, SlackState> {
|
|||
);
|
||||
};
|
||||
|
||||
renderActionButtons = () => {
|
||||
<WithPermissionControl userAction={UserActions.ChatOpsUpdateSettings}>
|
||||
<WithConfirm title="Are you sure to delete this Slack Integration?">
|
||||
<Button variant="destructive" size="sm" onClick={() => this.removeSlackIntegration()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
</WithPermissionControl>;
|
||||
};
|
||||
|
||||
removeSlackIntegration = () => {
|
||||
const { store } = this.props;
|
||||
store.slackStore.removeSlackIntegration().then(() => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Text from 'components/Text/Text';
|
|||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
import { CrossCircleIcon, HeartIcon } from 'icons';
|
||||
import { Cloud } from 'models/cloud/cloud.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -60,9 +61,10 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
setApiKeyError(false);
|
||||
}, []);
|
||||
|
||||
const disconnectCloudOncall = () => {
|
||||
const disconnectCloudOncall = async () => {
|
||||
setCloudIsConnected(false);
|
||||
store.cloudStore.disconnectToCloud();
|
||||
await store.cloudStore.disconnectToCloud();
|
||||
await store.cloudStore.loadCloudConnectionStatus();
|
||||
};
|
||||
|
||||
const connectToCloud = async () => {
|
||||
|
|
@ -81,6 +83,7 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
const heartbeatData: { link: string } = await store.cloudStore.getCloudHeartbeat();
|
||||
setheartbeatLink(heartbeatData?.link);
|
||||
}
|
||||
await store.cloudStore.loadCloudConnectionStatus();
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -312,6 +315,19 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
</VerticalGroup>
|
||||
)}
|
||||
</Block>
|
||||
{store.hasFeature(AppFeature.MobileApp) && (
|
||||
<Block bordered withBackground className={cx('info-block')}>
|
||||
<VerticalGroup>
|
||||
<Text.Title level={4}>
|
||||
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
|
||||
</Text.Title>
|
||||
<Text type="secondary">
|
||||
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
|
||||
mobile app.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
||||
|
|
@ -358,6 +374,19 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
<Text type="secondary">Users matched between OSS and Cloud OnCall currently unavailable.</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
{store.hasFeature(AppFeature.MobileApp) && (
|
||||
<Block bordered withBackground className={cx('info-block')}>
|
||||
<VerticalGroup>
|
||||
<Text.Title level={4}>
|
||||
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
|
||||
</Text.Title>
|
||||
<Text type="secondary">
|
||||
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
|
||||
mobile app.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -98,14 +98,23 @@ export class RootBaseStore {
|
|||
// stores
|
||||
|
||||
async updateBasicData() {
|
||||
const updateFeatures = async () => {
|
||||
await this.updateFeatures();
|
||||
|
||||
// Only fetch cloud connection status when cloud connection feature is enabled on OSS instance
|
||||
// Note that this.hasFeature can only be called after this.updateFeatures()
|
||||
if (this.hasFeature(AppFeature.CloudConnection)) {
|
||||
await this.cloudStore.loadCloudConnectionStatus();
|
||||
}
|
||||
};
|
||||
|
||||
return Promise.all([
|
||||
this.teamStore.loadCurrentTeam(),
|
||||
this.grafanaTeamStore.updateItems(),
|
||||
this.updateFeatures(),
|
||||
updateFeatures(),
|
||||
this.userStore.updateNotificationPolicyOptions(),
|
||||
this.userStore.updateNotifyByOptions(),
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
|
||||
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue