From 892afb8d0680c8d26372f1cb65c31c9e7883922b Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 2 Aug 2022 15:38:39 -0300 Subject: [PATCH 01/21] Refactor OSS image/plugin publish flow --- .drone.yml | 172 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 130 insertions(+), 42 deletions(-) diff --git a/.drone.yml b/.drone.yml index 46c9d5c4..7938b59d 100644 --- a/.drone.yml +++ b/.drone.yml @@ -33,47 +33,6 @@ steps: - zip -r grafana-oncall-app.zip ./grafana-oncall-app - if [ -z "$DRONE_TAG" ]; then echo "No tag, skipping archive"; else cp grafana-oncall-app.zip grafana-oncall-app-${DRONE_TAG}.zip; fi - - name: Publish Plugin to GCS (release) - image: plugins/gcs - settings: - acl: allUsers:READER - source: grafana-plugin/ci/dist/grafana-oncall-app-${DRONE_TAG}.zip - target: grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip - token: - from_secret: gcs_oncall_publisher_key - depends_on: - - Sign and Package Plugin - when: - ref: - - refs/tags/v*.*.* - - - name: Publish Plugin to Github (release) - image: plugins/github-release - settings: - api_key: - from_secret: gh_token - files: grafana-plugin/ci/dist/grafana-oncall-app-${DRONE_TAG}.zip - title: ${DRONE_TAG} - depends_on: - - Sign and Package Plugin - when: - ref: - - refs/tags/v*.*.* - - - name: Publish Plugin to grafana.com (release) - image: curlimages/curl:7.73.0 - environment: - GRAFANA_API_KEY: - from_secret: gcom_plugin_publisher_api_key - commands: - - "curl -f -s -H \"Authorization: Bearer $${GRAFANA_API_KEY}\" -d \"download[any][url]=https://storage.googleapis.com/grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip\" -d \"download[any][md5]=$$(curl -sL https://storage.googleapis.com/grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip | md5sum | cut -d' ' -f1)\" -d url=https://github.com/grafana/oncall/grafana-plugin https://grafana.com/api/plugins" - depends_on: - - Publish Plugin to GCS (release) - - Publish Plugin to Github (release) - when: - ref: - - refs/tags/v*.*.* - - name: Lint Backend image: python:3.9 environment: @@ -142,7 +101,6 @@ steps: when: ref: - refs/heads/dev - - refs/tags/v*.*.* # Services for Unit Test Backend services: @@ -170,6 +128,136 @@ trigger: - refs/heads/dev - refs/tags/v*.*.* +--- +kind: pipeline +type: docker +name: OSS Release + +steps: + - name: Check Promote + image: alpine + commands: + - if [ -z "$DRONE_DEPLOY_TO" ]; then echo "Missing DRONE_DEPLOY_TO (Target)"; exit 1; fi + - if [ -z "$DRONE_TAG" ]; then echo "Missing DRONE_TAG"; exit 1; fi + - echo Promoting $DRONE_TAG to $DRONE_DEPLOY_TO + + - name: Build Plugin + image: node:14.6.0-stretch + commands: + - apt-get update + - apt-get --assume-yes install jq + - cd grafana-plugin/ + - if [ -z "$DRONE_TAG" ]; then echo "No tag, not modifying version"; else jq '.version="${DRONE_TAG}"' package.json > package.new && mv package.new package.json && jq '.version' package.json; fi + - yarn --network-timeout 500000 + - yarn build + - ls ./ + depends_on: + - Check Promote + when: + event: + - promote + target: + - oss + ref: + - refs/tags/v*.*.* + + - name: Sign and Package Plugin + image: node:14.6.0-stretch + environment: + GRAFANA_API_KEY: + from_secret: gcom_plugin_publisher_api_key + depends_on: + - Build Plugin + commands: + - apt-get update + - apt-get install zip + - cd grafana-plugin + - yarn sign + - yarn ci-build:finish + - yarn ci-package + - cd ci/dist + - zip -r grafana-oncall-app.zip ./grafana-oncall-app + - if [ -z "$DRONE_TAG" ]; then echo "No tag, skipping archive"; else cp grafana-oncall-app.zip grafana-oncall-app-${DRONE_TAG}.zip; fi + + - name: Publish Plugin to GCS (release) + image: plugins/gcs + settings: + acl: allUsers:READER + source: grafana-plugin/ci/dist/grafana-oncall-app-${DRONE_TAG}.zip + target: grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip + token: + from_secret: gcs_oncall_publisher_key + depends_on: + - Sign and Package Plugin + + - name: Publish Plugin to Github (release) + image: plugins/github-release + settings: + api_key: + from_secret: gh_token + files: grafana-plugin/ci/dist/grafana-oncall-app-${DRONE_TAG}.zip + title: ${DRONE_TAG} + depends_on: + - Sign and Package Plugin + + - name: Publish Plugin to grafana.com (release) + image: curlimages/curl:7.73.0 + environment: + GRAFANA_API_KEY: + from_secret: gcom_plugin_publisher_api_key + commands: + - "curl -f -s -H \"Authorization: Bearer $${GRAFANA_API_KEY}\" -d \"download[any][url]=https://storage.googleapis.com/grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip\" -d \"download[any][md5]=$$(curl -sL https://storage.googleapis.com/grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip | md5sum | cut -d' ' -f1)\" -d url=https://github.com/grafana/oncall/grafana-plugin https://grafana.com/api/plugins" + depends_on: + - Publish Plugin to GCS (release) + - Publish Plugin to Github (release) + + - name: Image Tag + image: alpine + commands: + - apk add --no-cache bash git sed + - git fetch origin --tags + - chmod +x ./tools/image-tag.sh + - echo $(./tools/image-tag.sh) + - echo $(./tools/image-tag.sh) > .tags + - if [ -z "$DRONE_TAG" ]; then echo "No tag, not modifying version"; else sed "0,/VERSION.*/ s/VERSION.*/VERSION = \"${DRONE_TAG}\"/g" engine/settings/base.py > engine/settings/base.temp && mv engine/settings/base.temp engine/settings/base.py; fi + - cat engine/settings/base.py | grep VERSION | head -1 + depends_on: + - Check Promote + when: + event: + - promote + target: + - oss + ref: + - refs/tags/v*.*.* + + - name: Build and Push Engine Docker Image Backend to Dockerhub + image: plugins/docker + settings: + repo: grafana/oncall + dockerfile: engine/Dockerfile + context: engine/ + password: + from_secret: docker_password + username: + from_secret: docker_username + depends_on: + - Image Tag + + - name: Unrecognized Promote Target + image: alpine + commands: + - echo $DRONE_DEPLOY_TO is not a recognized promote target! + - exit 1 + when: + target: + exclude: + - oss + +trigger: + event: + - promote + --- # Secret for pulling docker images. kind: secret From 9712380787f609799506b0a1ca294a51fc97eb42 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 2 Aug 2022 16:52:44 -0300 Subject: [PATCH 02/21] Keep GCS plugin release on tag creation --- .drone.yml | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/.drone.yml b/.drone.yml index 7938b59d..d5b65eff 100644 --- a/.drone.yml +++ b/.drone.yml @@ -33,6 +33,20 @@ steps: - zip -r grafana-oncall-app.zip ./grafana-oncall-app - if [ -z "$DRONE_TAG" ]; then echo "No tag, skipping archive"; else cp grafana-oncall-app.zip grafana-oncall-app-${DRONE_TAG}.zip; fi + - name: Publish Plugin to GCS (release) + image: plugins/gcs + settings: + acl: allUsers:READER + source: grafana-plugin/ci/dist/grafana-oncall-app-${DRONE_TAG}.zip + target: grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip + token: + from_secret: gcs_oncall_publisher_key + depends_on: + - Sign and Package Plugin + when: + ref: + - refs/tags/v*.*.* + - name: Lint Backend image: python:3.9 environment: @@ -179,17 +193,6 @@ steps: - zip -r grafana-oncall-app.zip ./grafana-oncall-app - if [ -z "$DRONE_TAG" ]; then echo "No tag, skipping archive"; else cp grafana-oncall-app.zip grafana-oncall-app-${DRONE_TAG}.zip; fi - - name: Publish Plugin to GCS (release) - image: plugins/gcs - settings: - acl: allUsers:READER - source: grafana-plugin/ci/dist/grafana-oncall-app-${DRONE_TAG}.zip - target: grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip - token: - from_secret: gcs_oncall_publisher_key - depends_on: - - Sign and Package Plugin - - name: Publish Plugin to Github (release) image: plugins/github-release settings: @@ -208,7 +211,6 @@ steps: commands: - "curl -f -s -H \"Authorization: Bearer $${GRAFANA_API_KEY}\" -d \"download[any][url]=https://storage.googleapis.com/grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip\" -d \"download[any][md5]=$$(curl -sL https://storage.googleapis.com/grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip | md5sum | cut -d' ' -f1)\" -d url=https://github.com/grafana/oncall/grafana-plugin https://grafana.com/api/plugins" depends_on: - - Publish Plugin to GCS (release) - Publish Plugin to Github (release) - name: Image Tag From 3b3802870accdded8ddf437113e106fc33716158 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 8 Aug 2022 15:30:56 -0300 Subject: [PATCH 03/21] Add postgresql support for development/testing --- .github/workflows/ci.yml | 33 +++++++++++++++ docker-compose-developer-pg.yml | 74 +++++++++++++++++++++++++++++++++ docker-compose-developer.yml | 2 +- engine/requirements.txt | 1 + engine/settings/base.py | 18 ++++++++ engine/settings/ci-test.py | 24 ++++++----- engine/settings/dev.py | 39 ++++++++--------- 7 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 docker-compose-developer-pg.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84bd05bc..c27055c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,36 @@ jobs: cd engine/ pip install -r requirements.txt ./wait_for_test_mysql_start.sh && pytest --ds=settings.ci-test -x + + unit-test-backend-postgresql: + runs-on: ubuntu-latest + container: python:3.9 + env: + DB_BACKEND: postgresql + DJANGO_SETTINGS_MODULE: settings.ci-test + SLACK_CLIENT_OAUTH_ID: 1 + services: + rabbit_test: + image: rabbitmq:3.7.19 + env: + RABBITMQ_DEFAULT_USER: rabbitmq + RABBITMQ_DEFAULT_PASS: rabbitmq + postgresql_test: + image: postgres:14.4 + env: + POSTGRES_DB: oncall_local_dev + POSTGRES_PASSWORD: local_dev_pwd + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + - name: Unit Test Backend + run: | + cd engine/ + pip install -r requirements.txt + pytest --ds=settings.ci-test -x + diff --git a/docker-compose-developer-pg.yml b/docker-compose-developer-pg.yml new file mode 100644 index 00000000..6be8ae2e --- /dev/null +++ b/docker-compose-developer-pg.yml @@ -0,0 +1,74 @@ +version: '3.2' + +services: + + postgres: + image: postgres:14.4 + platform: linux/x86_64 + mem_limit: 500m + cpus: 0.5 + restart: always + ports: + - 5432:5432 + environment: + POSTGRES_DB: oncall_local_dev + POSTGRES_PASSWORD: empty + POSTGRES_INITDB_ARGS: '--encoding=UTF-8' + + redis: + image: redis + mem_limit: 100m + cpus: 0.1 + restart: always + ports: + - 6379:6379 + + rabbit: + image: "rabbitmq:3.7.15-management" + mem_limit: 1000m + cpus: 0.5 + environment: + RABBITMQ_DEFAULT_USER: "rabbitmq" + RABBITMQ_DEFAULT_PASS: "rabbitmq" + RABBITMQ_DEFAULT_VHOST: "/" + ports: + - 15672:15672 + - 5672:5672 + + mysql-to-create-grafana-db: + image: mysql:5.7 + platform: linux/x86_64 + mem_limit: 500m + cpus: 0.5 + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + restart: always + ports: + - 3306:3306 + environment: + MYSQL_ROOT_PASSWORD: empty + MYSQL_DATABASE: grafana + healthcheck: + test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] + timeout: 20s + retries: 10 + + grafana: + image: "grafana/grafana:9.0.0-beta3" + restart: always + mem_limit: 500m + cpus: 0.5 + environment: + GF_DATABASE_TYPE: mysql + GF_DATABASE_HOST: mysql + GF_DATABASE_USER: root + GF_DATABASE_PASSWORD: empty + GF_SECURITY_ADMIN_USER: oncall + GF_SECURITY_ADMIN_PASSWORD: oncall + GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app + volumes: + - ./grafana-plugin:/var/lib/grafana/plugins/grafana-plugin + ports: + - 3000:3000 + depends_on: + mysql-to-create-grafana-db: + condition: service_healthy diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index e35c3c70..d2889bbc 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -65,5 +65,5 @@ services: ports: - 3000:3000 depends_on: - mysql: + mysql-to-create-grafana-db: condition: service_healthy diff --git a/engine/requirements.txt b/engine/requirements.txt index d0896ae0..cc9eac99 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -40,6 +40,7 @@ https://github.com/iskhakov/django-push-notifications/archive/refs/tags/3.0.0-fi django-mirage-field==1.3.0 django-mysql==4.6.0 PyMySQL==1.0.2 +psycopg2-binary==2.9.3 emoji==1.7.0 apns2==0.7.2 diff --git a/engine/settings/base.py b/engine/settings/base.py index 578f0591..c1ee02d3 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -81,6 +81,24 @@ GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) # Outgoing webhook settings DANGEROUS_WEBHOOKS_ENABLED = getenv_boolean("DANGEROUS_WEBHOOKS_ENABLED", default=False) +# DB backend defaults +DB_BACKEND = os.environ.get("DB_BACKEND", "mysql") +DB_BACKEND_DEFAULT_VALUES = { + "mysql": { + "USER": "root", + "PORT": "3306", + "OPTIONS": { + "charset": "utf8mb4", + "connect_timeout": 1, + }, + }, + "postgresql": { + "USER": "postgres", + "PORT": "5432", + "OPTIONS": {}, + }, +} + # Application definition INSTALLED_APPS = [ diff --git a/engine/settings/ci-test.py b/engine/settings/ci-test.py index 16c655b5..f351d2c5 100644 --- a/engine/settings/ci-test.py +++ b/engine/settings/ci-test.py @@ -1,8 +1,5 @@ # flake8: noqa: F405 -# Workaround to use pymysql instead of mysqlclient -import pymysql - from .base import * # noqa SECRET_KEY = "u5/IIbuiJR3Y9FQMBActk+btReZ5oOxu+l8MIJQWLfVzESoan5REE6UNSYYEQdjBOcty9CDak2X" @@ -14,18 +11,23 @@ BASE_URL = "http://localhost" CELERY_BROKER_URL = "amqp://rabbitmq:rabbitmq@rabbit_test:5672" -pymysql.install_as_MySQLdb() +if DB_BACKEND == "mysql": + # Workaround to use pymysql instead of mysqlclient + import pymysql + + pymysql.install_as_MySQLdb() + DB_BACKEND_DEFAULT_VALUES[DB_BACKEND]["OPTIONS"] = {"charset": "utf8mb4"} + -# Primary database must have the name "default" DATABASES = { "default": { - "ENGINE": "django.db.backends.mysql", - "NAME": "oncall_local_dev", - "USER": "root", + "ENGINE": "django.db.backends.{}".format(DB_BACKEND), + "NAME": os.environ.get("DB_NAME", "oncall_local_dev"), + "USER": os.environ.get("DB_USER", DB_BACKEND_DEFAULT_VALUES.get(DB_BACKEND, {}).get("USER", "root")), "PASSWORD": "local_dev_pwd", - "HOST": "mysql_test", - "PORT": "3306", - "OPTIONS": {"charset": "utf8mb4"}, + "HOST": "{}_test".format(DB_BACKEND), + "PORT": os.environ.get("DB_PORT", DB_BACKEND_DEFAULT_VALUES.get(DB_BACKEND, {}).get("PORT", "3306")), + "OPTIONS": DB_BACKEND_DEFAULT_VALUES.get(DB_BACKEND, {}).get("OPTIONS", {}), }, } diff --git a/engine/settings/dev.py b/engine/settings/dev.py index b5e0e2f5..9dd65948 100644 --- a/engine/settings/dev.py +++ b/engine/settings/dev.py @@ -1,11 +1,26 @@ import os import sys -# Workaround to use pymysql instead of mysqlclient -import pymysql - from .base import * # noqa +if DB_BACKEND == "mysql": # noqa + # Workaround to use pymysql instead of mysqlclient + import pymysql + + pymysql.install_as_MySQLdb() + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.{}".format(DB_BACKEND), # noqa + "NAME": os.environ.get("DB_NAME", "oncall_local_dev"), + "USER": os.environ.get("DB_USER", DB_BACKEND_DEFAULT_VALUES.get(DB_BACKEND, {}).get("USER", "root")), # noqa + "PASSWORD": os.environ.get("DB_PASSWORD", "empty"), + "HOST": os.environ.get("DB_HOST", "127.0.0.1"), + "PORT": os.environ.get("DB_PORT", DB_BACKEND_DEFAULT_VALUES.get(DB_BACKEND, {}).get("PORT", "3306")), # noqa + "OPTIONS": DB_BACKEND_DEFAULT_VALUES.get(DB_BACKEND, {}).get("OPTIONS", {}), # noqa + }, +} + SECRET_KEY = os.environ.get("SECRET_KEY", "osMsNM0PqlRHBlUvqmeJ7+ldU3IUETCrY9TrmiViaSmInBHolr1WUlS0OFS4AHrnnkp1vp9S9z1") MIRAGE_SECRET_KEY = os.environ.get( @@ -13,26 +28,8 @@ MIRAGE_SECRET_KEY = os.environ.get( ) MIRAGE_CIPHER_IV = os.environ.get("MIRAGE_CIPHER_IV", "tZZa+60zTZO2NRcS") -pymysql.install_as_MySQLdb() - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.mysql", - "NAME": os.environ.get("MYSQL_DB_NAME", "oncall_local_dev"), - "USER": os.environ.get("MYSQL_USER", "root"), - "PASSWORD": os.environ.get("MYSQL_PASSWORD"), - "HOST": os.environ.get("MYSQL_HOST", "127.0.0.1"), - "PORT": os.environ.get("MYSQL_PORT", "3306"), - "OPTIONS": { - "charset": "utf8mb4", - "connect_timeout": 1, - }, - }, -} - TESTING = "pytest" in sys.modules or "unittest" in sys.modules - CACHES = { "default": { "BACKEND": "redis_cache.RedisCache", From 28b4d84f918fa123f9a312620b70e70eac3363a6 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 8 Aug 2022 15:45:20 -0300 Subject: [PATCH 04/21] Update DEVELOPER.md to include postgresql setup details --- DEVELOPER.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DEVELOPER.md b/DEVELOPER.md index ec014908..7fd8fb04 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -31,6 +31,8 @@ docker-compose -f docker-compose-developer.yml up -d ``` +NOTE: to use a PostgreSQL db backend, use the `docker-compose-developer-pg.yml` file instead. + 2. Prepare a python environment: ```bash # Create and activate the virtual environment @@ -45,6 +47,9 @@ pip install -U pip wheel # Copy and check .env file. cp .env.example .env +# NOTE: if you want to use the PostgreSQL db backend add DB_BACKEND=postgresql to your .env file; +# currently allowed backend values are `mysql` (default) and `postgresql` + # Apply .env to current terminal. # For PyCharm it's better to use https://plugins.jetbrains.com/plugin/7861-envfile/ export $(grep -v '^#' .env | xargs -0) From f54f500757d3ee47e3663536e016f83922ac211d Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Tue, 9 Aug 2022 18:18:17 +0300 Subject: [PATCH 05/21] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 36e96a28..f6c0e447 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ See [Grafana docs](https://grafana.com/docs/grafana/latest/administration/plugin + +## Stargazers over time + +[![Stargazers over time](https://starchart.cc/grafana/oncall.svg)](https://starchart.cc/grafana/oncall) + + ## Further Reading - *Migration from the PagerDuty* - [Migrator](https://github.com/grafana/oncall/tree/dev/tools/pagerduty-migrator) - *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) From 83d5ccb95c2d0077549007310080ab1eb3b875f6 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 10 Aug 2022 12:33:46 -0300 Subject: [PATCH 06/21] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fce73ea1..153c5b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Change Log +## v1.0.19 (2022-08-10) +- Bug fixes + ## v1.0.15 (2022-08-03) - Bug fixes From 5694dc2bd8bc826f68ee4f69fc50041338c9b0cb Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Thu, 11 Aug 2022 14:32:39 +0500 Subject: [PATCH 07/21] Terraform examples --- examples/terraform/basic.tf | 42 +++++++++++ examples/terraform/routes.tf | 106 +++++++++++++++++++++++++++ examples/terraform/shift_schedule.tf | 75 +++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 examples/terraform/basic.tf create mode 100644 examples/terraform/routes.tf create mode 100644 examples/terraform/shift_schedule.tf diff --git a/examples/terraform/basic.tf b/examples/terraform/basic.tf new file mode 100644 index 00000000..6c33884d --- /dev/null +++ b/examples/terraform/basic.tf @@ -0,0 +1,42 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 1.22.0" + } + } +} + +provider "grafana" { + alias = "oncall" + oncall_access_token = +} + +data "grafana_oncall_user" "ikonstantinov" { + provider = grafana.oncall + username = "ikonstantinov" +} + +resource "grafana_oncall_integration" "prod_alertmanager" { + provider = grafana.oncall + name = "Prod AM" + type = "alertmanager" + default_route { + escalation_chain_id = grafana_oncall_escalation_chain.default.id + } +} + +resource "grafana_oncall_escalation_chain" "default" { + provider = grafana.oncall + name = "default" +} + +resource "grafana_oncall_escalation" "notify_me_step" { + provider = grafana.oncall + escalation_chain_id = grafana_oncall_escalation_chain.default.id + type = "notify_persons" + persons_to_notify = [ + data.grafana_oncall_user.ikonstantinov.id + ] + position = 0 +} \ No newline at end of file diff --git a/examples/terraform/routes.tf b/examples/terraform/routes.tf new file mode 100644 index 00000000..fdbb81f1 --- /dev/null +++ b/examples/terraform/routes.tf @@ -0,0 +1,106 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 1.22.0" + } + } +} + +provider "grafana" { + alias = "oncall" + oncall_access_token = +} + +// Users +data "grafana_oncall_user" "ikonstantinov" { + provider = grafana.oncall + username = "ikonstantinov" +} + +data "grafana_oncall_user" "mkukuy" { + provider = grafana.oncall + username = "mkukuy" +} + +// Schedule +resource "grafana_oncall_schedule" "primary" { + provider = grafana.oncall + name = "Primary" + type = "calendar" + time_zone = "UTC" + shifts = [ + grafana_oncall_on_call_shift.week_shift.id + ] +} + +resource "grafana_oncall_on_call_shift" "week_shift" { + provider = grafana.oncall + name = "Week shift" + type = "rolling_users" + start = "2022-06-01T00:00:00" + duration = 60 * 60 * 24 // 24 hours + frequency = "weekly" + by_day = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] + week_start = "MO" + rolling_users = [ + [data.grafana_oncall_user.ikonstantinov.id], + [data.grafana_oncall_user.mkukuy.id] + ] + time_zone = "UTC" +} + +// Prod Alertmanager Integration +resource "grafana_oncall_integration" "prod_alertmanager" { + provider = grafana.oncall + name = "Prod AM" + type = "alertmanager" + default_route { + escalation_chain_id = grafana_oncall_escalation_chain.default.id + } +} + +// Routes +resource "grafana_oncall_route" "critical_route" { + provider = grafana.oncall + integration_id = grafana_oncall_integration.prod_alertmanager.id + escalation_chain_id = grafana_oncall_escalation_chain.critical.id + routing_regex = "\"severity\": \"critical\"" + position = 0 +} + +// Default escalation chain +resource "grafana_oncall_escalation_chain" "default" { + provider = grafana.oncall + name = "default" +} + +resource "grafana_oncall_escalation" "wait" { + provider = grafana.oncall + escalation_chain_id = grafana_oncall_escalation_chain.default.id + type = "wait" + duration = 60 * 5 + position = 0 +} + +resource "grafana_oncall_escalation" "notify_schedule" { + provider = grafana.oncall + escalation_chain_id = grafana_oncall_escalation_chain.default.id + type = "notify_on_call_from_schedule" + notify_on_call_from_schedule = grafana_oncall_schedule.primary.id + position = 1 +} + +// Critical escalation chain +resource "grafana_oncall_escalation_chain" "critical" { + provider = grafana.oncall + name = "critical" +} + +resource "grafana_oncall_escalation" "notify_schedule_critical" { + provider = grafana.oncall + escalation_chain_id = grafana_oncall_escalation_chain.critical.id + type = "notify_on_call_from_schedule" + notify_on_call_from_schedule = grafana_oncall_schedule.primary.id + position = 0 +} \ No newline at end of file diff --git a/examples/terraform/shift_schedule.tf b/examples/terraform/shift_schedule.tf new file mode 100644 index 00000000..9b2eec37 --- /dev/null +++ b/examples/terraform/shift_schedule.tf @@ -0,0 +1,75 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 1.22.0" + } + } +} + +provider "grafana" { + alias = "oncall" + oncall_access_token = +} + +// Users +data "grafana_oncall_user" "ikonstantinov" { + provider = grafana.oncall + username = "ikonstantinov" +} + +data "grafana_oncall_user" "mkukuy" { + provider = grafana.oncall + username = "mkukuy" +} + +// Schedule +resource "grafana_oncall_schedule" "primary" { + provider = grafana.oncall + name = "Primary" + type = "calendar" + time_zone = "UTC" + shifts = [ + grafana_oncall_on_call_shift.week_shift.id + ] +} + +resource "grafana_oncall_on_call_shift" "week_shift" { + provider = grafana.oncall + name = "Week shift" + type = "rolling_users" + start = "2022-06-01T00:00:00" + duration = 60 * 60 * 24 // 24 hours + frequency = "weekly" + by_day = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] + week_start = "MO" + rolling_users = [ + [data.grafana_oncall_user.ikonstantinov.id], + [data.grafana_oncall_user.mkukuy.id] + ] + time_zone = "UTC" +} + +// Prod Alertmanager Integration +resource "grafana_oncall_integration" "prod_alertmanager" { + provider = grafana.oncall + name = "Prod AM" + type = "alertmanager" + default_route { + escalation_chain_id = grafana_oncall_escalation_chain.default.id + } +} + +// Default escalation chain +resource "grafana_oncall_escalation_chain" "default" { + provider = grafana.oncall + name = "default" +} + +resource "grafana_oncall_escalation" "notify_schedule" { + provider = grafana.oncall + escalation_chain_id = grafana_oncall_escalation_chain.default.id + type = "notify_on_call_from_schedule" + notify_on_call_from_schedule = grafana_oncall_schedule.primary.id + position = 0 +} \ No newline at end of file From 7bc4aaa3b745d047399629637b802590c474e4d2 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Fri, 12 Aug 2022 11:49:20 +0300 Subject: [PATCH 08/21] Bump django version (#362) --- engine/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/requirements.txt b/engine/requirements.txt index d0896ae0..5e0f4f61 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -1,4 +1,4 @@ -django==3.2.14 +django==3.2.15 djangorestframework==3.12.4 slackclient==1.3.0 whitenoise==5.3.0 From 3c2a14e0dca05555cf8f8fad961dada094b273d1 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Fri, 12 Aug 2022 09:57:54 -0600 Subject: [PATCH 09/21] Tolerate UserId/UserID in X-Grafana-Context header (#364) --- engine/apps/auth_token/auth.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index be4a99f3..551116c6 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -81,7 +81,10 @@ class PluginAuthentication(BaseAuthentication): @staticmethod def _get_user(request: Request, organization: Organization) -> User: context = json.loads(request.headers.get("X-Grafana-Context")) - user_id = context["UserId"] + try: + user_id = context["UserId"] + except KeyError: + user_id = context["UserID"] try: return organization.users.get(user_id=user_id) except User.DoesNotExist: From 775dec75c0497a56adef275174fbfdda57b66222 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Fri, 12 Aug 2022 10:23:15 -0600 Subject: [PATCH 10/21] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 153c5b01..d713db60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Change Log +## v1.0.21 (2022-08-12) +- Bug fixes +- ## v1.0.19 (2022-08-10) - Bug fixes From 2de3d5793f412bc7dbe36f09c69686e3df1eb840 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Fri, 12 Aug 2022 10:23:51 -0600 Subject: [PATCH 11/21] Update CHANGELOG.md --- grafana-plugin/CHANGELOG.md | 57 +++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md index e48e4082..d713db60 100644 --- a/grafana-plugin/CHANGELOG.md +++ b/grafana-plugin/CHANGELOG.md @@ -1,9 +1,62 @@ # Change Log +## v1.0.21 (2022-08-12) +- Bug fixes +- +## v1.0.19 (2022-08-10) +- Bug fixes + +## v1.0.15 (2022-08-03) +- Bug fixes + +## v1.0.13 (2022-07-27) +- Optimize alert group list view +- Fix a bug related to Twilio setup + +## v1.0.12 (2022-07-26) +- Update push-notifications dependency +- Rework how absolute URLs are built +- Fix to show maintenance windows per team +- Logging improvements +- Internal api to get a schedule final events + +## v1.0.10 (2022-07-22) +- Speed-up of alert group web caching +- Internal api for OnCall shifts + +## v1.0.9 (2022-07-21) +- Frontend bug fixes & improvements +- Support regex_replace() in templates +- Bring back alert group caching and list view + +## v1.0.7 (2022-07-18) +- Backend & frontend bug fixes +- Deployment improvements +- Reshape webhook payload for outgoing webhooks +- Add escalation chain usage info on escalation chains page +- Improve alert group list load speeds and simplify caching system + +## v1.0.6 (2022-07-12) +- Manual Incidents enabled for teams +- Fix phone notifications for OSS +- Public API improvements + +## v1.0.5 (2022-07-06) +- Bump Django to 3.2.14 +- Fix PagerDuty iCal parsing + +## 1.0.4 (2022-06-28) +- Allow Telegram DMs without channel connection. + +## 1.0.3 (2022-06-27) +- Fix users public api endpoint. Now it returns users with all roles. +- Fix redundant notifications about gaps in schedules. +- Frontend fixes. + ## 1.0.2 (2022-06-17) - Fix Grafana Alerting integration to handle API changes in Grafana 9 -- Improve public API endpoint for outgoing webhooks (/actions) by adding ability to create, update and delete +- Improve public api endpoint for for outgoing webhooks (/actions) by adding ability to create, update and delete outgoing webhook instance ## 1.0.0 (2022-06-14) @@ -11,4 +64,4 @@ ## 0.0.71 (2022-06-06) -- Initial Commit Release \ No newline at end of file +- Initial Commit Release From b1df6b54db822705b3d2d0ee1132dab3290df245 Mon Sep 17 00:00:00 2001 From: alyssa wada Date: Fri, 12 Aug 2022 15:27:07 -0600 Subject: [PATCH 12/21] Add notification types --- docs/sources/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/getting-started.md b/docs/sources/getting-started.md index 77460b05..38726bb5 100644 --- a/docs/sources/getting-started.md +++ b/docs/sources/getting-started.md @@ -83,7 +83,7 @@ For more information on Escalation Chains and more ways to customize them, refer In order for Grafana OnCall to notify you of an alert, you must configure how you want to be notified. Personal notification policies, chatops integrations, and on-call schedules allow you to automate how users are notified of alerts. ### Configure personal notification policies -Personal notification policies determine how a user is notified for a certain type of alert. Administrators can configure how users receive notification for certain types of alerts. For more information on personal notification policies, refer to [Manage users and teams for Grafana OnCall]({{< relref "configure-user-settings/" >}}) +Personal notification policies determine how a user is notified for a certain type of alert. Get notified by SMS, phone call, or Slack mentions. Administrators can configure how users receive notification for certain types of alerts. For more information on personal notification policies, refer to [Manage users and teams for Grafana OnCall]({{< relref "configure-user-settings/" >}}) To configure users personal notification policies: From 0221ce612a1c8069ff98f9e6b6a7ee52bf52feb6 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 15 Aug 2022 12:08:53 -0600 Subject: [PATCH 13/21] Make STATIC_URL configurable from env --- engine/settings/base.py | 2 +- engine/settings/prod_without_db.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/engine/settings/base.py b/engine/settings/base.py index 578f0591..89cafb47 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -238,7 +238,7 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.1/howto/static-files/ -STATIC_URL = "/static/" +STATIC_URL = os.environ.get("STATIC_URL", "/static/") STATIC_ROOT = "./static/" CELERY_BROKER_URL = "amqp://rabbitmq:rabbitmq@localhost:5672" diff --git a/engine/settings/prod_without_db.py b/engine/settings/prod_without_db.py index 5b8a83b4..ed73daed 100644 --- a/engine/settings/prod_without_db.py +++ b/engine/settings/prod_without_db.py @@ -53,7 +53,6 @@ STATICFILES_DIRS = [ "/etc/app/static", ] STATIC_ROOT = "./collected_static/" -STATIC_URL = "/static/" DEBUG = False From 4477c56b255483cfe42050eeba441b948d66f1e0 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 15 Aug 2022 10:24:22 -0300 Subject: [PATCH 14/21] Add shift preview endpoint for web schedule --- engine/apps/api/tests/test_oncall_shift.py | 173 +++++++++++++++- engine/apps/api/views/on_call_shifts.py | 24 ++- engine/apps/api/views/schedule.py | 18 +- .../apps/schedules/models/on_call_schedule.py | 45 ++++- .../schedules/tests/test_on_call_schedule.py | 190 ++++++++++++++++++ engine/common/api_helpers/utils.py | 40 ++++ 6 files changed, 470 insertions(+), 20 deletions(-) diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index a40fbd46..fe9f77cf 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -7,7 +7,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb +from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb from common.constants.role import Role @@ -1140,3 +1140,174 @@ def test_on_call_shift_days_options_permissions( response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (Role.ADMIN, status.HTTP_200_OK), + (Role.EDITOR, status.HTTP_403_FORBIDDEN), + (Role.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_on_call_shift_preview_permissions( + make_organization_and_user_with_plugin_token, + make_schedule, + make_user_auth_headers, + role, + expected_status, +): + organization, user, token = make_organization_and_user_with_plugin_token(role) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + start_date = timezone.now() + client = APIClient() + + shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rotation_start": shift_start, + "shift_start": shift_start, + "shift_end": shift_end, + "rolling_users": [[user.public_primary_key]], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + + url = reverse("api-internal:oncall_shifts-preview") + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +def test_on_call_shift_preview_missing_data( + make_organization_and_user_with_plugin_token, + make_schedule, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + client = APIClient() + + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rolling_users": [[user.public_primary_key]], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + + url = reverse("api-internal:oncall_shifts-preview") + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_on_call_shift_preview( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + request_date = start_date + + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + data = { + "start": start_date + timezone.timedelta(hours=9), + "rotation_start": start_date + timezone.timedelta(hours=9), + "duration": timezone.timedelta(hours=9), + "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]]) + + url = "{}?date={}&days={}".format( + reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1 + ) + shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rotation_start": shift_start, + "shift_start": shift_start, + "shift_end": shift_end, + "rolling_users": [[other_user.public_primary_key]], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + # check rotation events + rotation_events = response.json()["rotation"] + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": shift_start, + "end": shift_end, + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": 2, + "missing_users": [], + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "source": "web", + } + ] + # there isn't a saved shift, we don't care/know the temp pk + _ = [r.pop("shift") for r in rotation_events] + assert rotation_events == expected_rotation_events + + # check final schedule events + final_events = response.json()["final"] + expected = ( + # start (h), duration (H), user, priority + (9, 3, user.username, 1), # 9-12 user + (12, 1, other_user.username, 2), # 12-13 other_user + (13, 5, user.username, 1), # 13-18 C + ) + expected_events = [ + { + "end": (start_date + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "priority_level": priority, + "start": (start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index a12e5c0b..ad9fe688 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -1,5 +1,6 @@ from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -12,6 +13,7 @@ from apps.schedules.models import CustomOnCallShift from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.mixins import PublicPrimaryKeyMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator +from common.api_helpers.utils import get_date_range_from_request class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): @@ -19,7 +21,7 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet permission_classes = (IsAuthenticated, ActionPermission) action_permissions = { - IsAdmin: MODIFY_ACTIONS, + IsAdmin: (*MODIFY_ACTIONS, "preview"), AnyRole: (*READ_ACTIONS, "details", "frequency_options", "days_options"), } @@ -77,6 +79,26 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet create_organization_log(organization, user, OrganizationLogType.TYPE_ON_CALL_SHIFT_DELETED, description) instance.delete() + @action(detail=False, methods=["post"]) + def preview(self, request): + user_tz, starting_date, days = get_date_range_from_request(self.request) + + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + validated_data = serializer._correct_validated_data( + serializer.validated_data["type"], serializer.validated_data + ) + shift = CustomOnCallShift(**validated_data) + schedule = shift.schedule + shift_events, final_events = schedule.preview_shift(shift, user_tz, starting_date, days) + data = { + "rotation": shift_events, + "final": final_events, + } + return Response(data=data, status=status.HTTP_200_OK) + @action(detail=False, methods=["get"]) def frequency_options(self, request): return Response( diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 78f9f837..5bec4ef1 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -1,5 +1,3 @@ -import datetime - import pytz from django.core.exceptions import ObjectDoesNotExist from django.db.models import OuterRef, Subquery @@ -35,7 +33,7 @@ from common.api_helpers.mixins import ( ShortSerializerMixin, UpdateSerializerMixin, ) -from common.api_helpers.utils import create_engine_url +from common.api_helpers.utils import create_engine_url, get_date_range_from_request EVENTS_FILTER_BY_ROTATION = "rotation" EVENTS_FILTER_BY_OVERRIDE = "override" @@ -224,24 +222,14 @@ class ScheduleView( @action(detail=True, methods=["get"]) def filter_events(self, request, pk): - user_tz, date = self.get_request_timezone() - filter_by = self.request.query_params.get("type") + user_tz, starting_date, days = get_date_range_from_request(self.request) + filter_by = self.request.query_params.get("type") valid_filters = (EVENTS_FILTER_BY_ROTATION, EVENTS_FILTER_BY_OVERRIDE, EVENTS_FILTER_BY_FINAL) if filter_by is not None and filter_by not in valid_filters: raise BadRequest(detail="Invalid type value") resolve_schedule = filter_by is None or filter_by == EVENTS_FILTER_BY_FINAL - starting_date = date if self.request.query_params.get("date") else None - if starting_date is None: - # default to current week start - starting_date = date - datetime.timedelta(days=date.weekday()) - - try: - days = int(self.request.query_params.get("days", 7)) # fallback to a week - except ValueError: - raise BadRequest(detail="Invalid days format") - schedule = self.original_get_object() if filter_by is not None: diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 52d4782f..98d605f3 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -1,4 +1,5 @@ import datetime +import itertools import icalendar from django.apps import apps @@ -509,10 +510,12 @@ class OnCallScheduleCalendar(OnCallSchedule): class OnCallScheduleWeb(OnCallSchedule): time_zone = models.CharField(max_length=100, default="UTC") - def _generate_ical_file_from_shifts(self, qs): + def _generate_ical_file_from_shifts(self, qs, extra_shifts=None): """Generate iCal events file from custom on-call shifts.""" ical = None - if qs.exists(): + if qs.exists() or extra_shifts is not None: + if extra_shifts is None: + extra_shifts = [] end_line = "END:VCALENDAR" calendar = Calendar() calendar.add("prodid", "-//web schedule//oncall//") @@ -521,7 +524,7 @@ class OnCallScheduleWeb(OnCallSchedule): ical_file = calendar.to_ical().decode() ical = ical_file.replace(end_line, "").strip() ical = f"{ical}\r\n" - for event in qs.all(): + for event in itertools.chain(qs.all(), extra_shifts): ical += event.convert_to_ical(self.time_zone) ical += f"{end_line}\r\n" return ical @@ -559,3 +562,39 @@ class OnCallScheduleWeb(OnCallSchedule): self.prev_ical_file_overrides = self.cached_ical_file_overrides self.cached_ical_file_overrides = self._generate_ical_file_overrides() self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) + + def preview_shift(self, custom_shift, user_tz, starting_date, days): + """Return unsaved rotation and final schedule preview events.""" + if custom_shift.type == CustomOnCallShift.TYPE_OVERRIDE: + qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE) + ical_attr = "cached_ical_file_overrides" + ical_property = "_ical_file_overrides" + elif custom_shift.type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: + qs = self.custom_shifts.exclude(type=CustomOnCallShift.TYPE_OVERRIDE) + ical_attr = "cached_ical_file_primary" + ical_property = "_ical_file_primary" + else: + raise ValueError("Invalid shift type") + + def _invalidate_cache(schedule, prop_name): + """Invalidate cached property cache""" + try: + delattr(schedule, prop_name) + except AttributeError: + pass + + ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=[custom_shift]) + + original_value = getattr(self, ical_attr) + _invalidate_cache(self, ical_property) + setattr(self, ical_attr, ical_file) + + # filter events using a temporal overriden calendar including the not-yet-saved shift + events = self.filter_events(user_tz, starting_date, days=days, with_empty=True, with_gap=True) + shift_events = [e for e in events if e["shift"]["pk"] == custom_shift.public_primary_key] + final_events = self._resolve_schedule(events) + + _invalidate_cache(self, ical_property) + setattr(self, ical_attr, original_value) + + return shift_events, final_events diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 11f4be13..3752e1f2 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -320,3 +320,193 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma for e in returned_events ] assert returned_events == expected_events + + +@pytest.mark.django_db +def test_preview_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + data = { + "start": start_date + timezone.timedelta(hours=9), + "rotation_start": start_date + timezone.timedelta(hours=9), + "duration": timezone.timedelta(hours=9), + "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_primary_ical = schedule._ical_file_primary + + # proposed shift + new_shift = CustomOnCallShift( + type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + organization=organization, + schedule=schedule, + name="testing", + start=start_date + timezone.timedelta(hours=12), + rotation_start=start_date + timezone.timedelta(hours=12), + duration=timezone.timedelta(seconds=3600), + frequency=CustomOnCallShift.FREQUENCY_DAILY, + priority_level=2, + rolling_users=[{other_user.pk: other_user.public_primary_key}], + ) + + rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1) + + # check rotation events + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": new_shift.start, + "end": new_shift.start + new_shift.duration, + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": new_shift.priority_level, + "missing_users": [], + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "shift": {"pk": new_shift.public_primary_key}, + "source": "api", + } + ] + assert rotation_events == expected_rotation_events + + # check final schedule events + expected = ( + # start (h), duration (H), user, priority + (9, 3, user.username, 1), # 9-12 user + (12, 1, other_user.username, 2), # 12-13 other_user + (13, 5, user.username, 1), # 13-18 C + ) + expected_events = [ + { + "end": start_date + timezone.timedelta(hours=start + duration), + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events + + # final ical schedule didn't change + assert schedule._ical_file_primary == schedule_primary_ical + + +@pytest.mark.django_db +def test_preview_override_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + data = { + "start": start_date + timezone.timedelta(hours=9), + "rotation_start": start_date + timezone.timedelta(hours=9), + "duration": timezone.timedelta(hours=9), + "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_overrides_ical = schedule._ical_file_overrides + + # proposed override + new_shift = CustomOnCallShift( + type=CustomOnCallShift.TYPE_OVERRIDE, + organization=organization, + schedule=schedule, + name="testing", + start=start_date + timezone.timedelta(hours=12), + rotation_start=start_date + timezone.timedelta(hours=12), + duration=timezone.timedelta(seconds=3600), + rolling_users=[{other_user.pk: other_user.public_primary_key}], + ) + + rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1) + + # check rotation events + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_OVERRIDES, + "start": new_shift.start, + "end": new_shift.start + new_shift.duration, + "all_day": False, + "is_override": True, + "is_empty": False, + "is_gap": False, + "priority_level": None, + "missing_users": [], + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "shift": {"pk": new_shift.public_primary_key}, + "source": "api", + } + ] + assert rotation_events == expected_rotation_events + + # check final schedule events + expected = ( + # start (h), duration (H), user, priority, is_override + (9, 3, user.username, 1, False), # 9-12 user + (12, 1, other_user.username, None, True), # 12-13 other_user + (13, 5, user.username, 1, False), # 13-18 C + ) + expected_events = [ + { + "end": start_date + timezone.timedelta(hours=start + duration), + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0), + "user": user, + "is_override": is_override, + } + for start, duration, user, priority, is_override in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + "is_override": e["is_override"], + } + for e in final_events + if not e["is_gap"] + ] + assert returned_events == expected_events + + # final ical schedule didn't change + assert schedule._ical_file_overrides == schedule_overrides_ical diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index 7ecd5d47..5ccc93b1 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -1,10 +1,15 @@ +import datetime from urllib.parse import urljoin +import pytz import requests from django.conf import settings +from django.utils import dateparse, timezone from icalendar import Calendar from rest_framework import serializers +from common.api_helpers.exceptions import BadRequest + class CurrentOrganizationDefault: """ @@ -71,3 +76,38 @@ def create_engine_url(path, override_base=None): base += "/" trimmed_path = path.lstrip("/") return urljoin(base, trimmed_path) + + +def get_date_range_from_request(request): + """Extract timezone, starting date and number of days params from request. + + Used mainly for schedules and shifts API. + """ + user_tz = request.query_params.get("user_tz", "UTC") + try: + pytz.timezone(user_tz) + except pytz.exceptions.UnknownTimeZoneError: + raise BadRequest(detail="Invalid tz format") + + date = timezone.now().date() + date_param = request.query_params.get("date") + if date_param is not None: + try: + date = dateparse.parse_date(date_param) + except ValueError: + raise BadRequest(detail="Invalid date format") + else: + if date is None: + raise BadRequest(detail="Invalid date format") + + starting_date = date if request.query_params.get("date") else None + if starting_date is None: + # default to current week start + starting_date = date - datetime.timedelta(days=date.weekday()) + + try: + days = int(request.query_params.get("days", 7)) # fallback to a week + except ValueError: + raise BadRequest(detail="Invalid days format") + + return user_tz, starting_date, days From 5c87b7562c6d4027092f94b2f4a364b324cd9838 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 15 Aug 2022 11:44:26 -0300 Subject: [PATCH 15/21] Fix to check for final type in schedule filter_events --- engine/apps/api/views/schedule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 5bec4ef1..8a066cee 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -232,7 +232,7 @@ class ScheduleView( schedule = self.original_get_object() - if filter_by is not None: + if filter_by is not None and filter_by != EVENTS_FILTER_BY_FINAL: filter_by = OnCallSchedule.PRIMARY if filter_by == EVENTS_FILTER_BY_ROTATION else OnCallSchedule.OVERRIDES events = schedule.filter_events( user_tz, starting_date, days=days, with_empty=True, with_gap=resolve_schedule, filter_by=filter_by From 82d0548cd3eb1570c072a7843bc05f19c46a84fc Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 16 Aug 2022 11:02:12 -0300 Subject: [PATCH 16/21] Fix rotation start in tests after merged updates --- engine/apps/schedules/tests/test_on_call_schedule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 3752e1f2..a6e875ba 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -117,7 +117,7 @@ def test_filter_events_include_gaps(make_organization, make_user_for_organizatio data = { "start": start_date + timezone.timedelta(hours=10), - "rotation_start": start_date + timezone.timedelta(days=1, hours=10), + "rotation_start": start_date + timezone.timedelta(hours=10), "duration": timezone.timedelta(hours=8), "priority_level": 1, "frequency": CustomOnCallShift.FREQUENCY_DAILY, @@ -192,7 +192,7 @@ def test_filter_events_include_empty(make_organization, make_user_for_organizati data = { "start": start_date + timezone.timedelta(hours=10), - "rotation_start": start_date + timezone.timedelta(days=1, hours=10), + "rotation_start": start_date + timezone.timedelta(hours=10), "duration": timezone.timedelta(hours=8), "priority_level": 1, "frequency": CustomOnCallShift.FREQUENCY_DAILY, From af0fcda7dba4a76928239b28aa18894f2f7e13cb Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 16 Aug 2022 09:47:23 -0600 Subject: [PATCH 17/21] Update CHANGELOG.md --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d713db60..ded0f67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Change Log +## v1.0.22 (2022-08-16) +- Make STATIC_URL configurable from environment variable + ## v1.0.21 (2022-08-12) - Bug fixes -- + ## v1.0.19 (2022-08-10) - Bug fixes From f3c5558dc8ff67bda55bfe9af7fd5cedc717adb0 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 16 Aug 2022 09:47:49 -0600 Subject: [PATCH 18/21] Update CHANGELOG.md --- grafana-plugin/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md index d713db60..ded0f67a 100644 --- a/grafana-plugin/CHANGELOG.md +++ b/grafana-plugin/CHANGELOG.md @@ -1,8 +1,11 @@ # Change Log +## v1.0.22 (2022-08-16) +- Make STATIC_URL configurable from environment variable + ## v1.0.21 (2022-08-12) - Bug fixes -- + ## v1.0.19 (2022-08-10) - Bug fixes From 9ee8731ed759a3dbe82f66b3f93a9bef85f54070 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 16 Aug 2022 11:48:30 -0600 Subject: [PATCH 19/21] Move step to publish plugin in github to tag instead of promte as the drone plugin doesn't seem to work with that step. (#374) --- .drone.yml | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.drone.yml b/.drone.yml index d5b65eff..61e0109b 100644 --- a/.drone.yml +++ b/.drone.yml @@ -33,6 +33,19 @@ steps: - zip -r grafana-oncall-app.zip ./grafana-oncall-app - if [ -z "$DRONE_TAG" ]; then echo "No tag, skipping archive"; else cp grafana-oncall-app.zip grafana-oncall-app-${DRONE_TAG}.zip; fi + - name: Publish Plugin to Github (release) + image: plugins/github-release + settings: + api_key: + from_secret: gh_token + files: grafana-plugin/ci/dist/grafana-oncall-app-${DRONE_TAG}.zip + title: ${DRONE_TAG} + depends_on: + - Sign and Package Plugin + when: + ref: + - refs/tags/v*.*.* + - name: Publish Plugin to GCS (release) image: plugins/gcs settings: @@ -193,16 +206,6 @@ steps: - zip -r grafana-oncall-app.zip ./grafana-oncall-app - if [ -z "$DRONE_TAG" ]; then echo "No tag, skipping archive"; else cp grafana-oncall-app.zip grafana-oncall-app-${DRONE_TAG}.zip; fi - - name: Publish Plugin to Github (release) - image: plugins/github-release - settings: - api_key: - from_secret: gh_token - files: grafana-plugin/ci/dist/grafana-oncall-app-${DRONE_TAG}.zip - title: ${DRONE_TAG} - depends_on: - - Sign and Package Plugin - - name: Publish Plugin to grafana.com (release) image: curlimages/curl:7.73.0 environment: @@ -211,7 +214,7 @@ steps: commands: - "curl -f -s -H \"Authorization: Bearer $${GRAFANA_API_KEY}\" -d \"download[any][url]=https://storage.googleapis.com/grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip\" -d \"download[any][md5]=$$(curl -sL https://storage.googleapis.com/grafana-oncall-app/releases/grafana-oncall-app-${DRONE_TAG}.zip | md5sum | cut -d' ' -f1)\" -d url=https://github.com/grafana/oncall/grafana-plugin https://grafana.com/api/plugins" depends_on: - - Publish Plugin to Github (release) + - Sign and Package Plugin - name: Image Tag image: alpine From 8e8806dcbade7d79facf9bc5e75551eca4f5839c Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 16 Aug 2022 11:57:03 -0600 Subject: [PATCH 20/21] Update drone signature (#375) --- .drone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index 61e0109b..58274b4b 100644 --- a/.drone.yml +++ b/.drone.yml @@ -334,6 +334,6 @@ kind: secret name: drone_token --- kind: signature -hmac: 7621bb1ccfcbec9f92c385670f2b2790859aba25f31c4936997123906fb102c0 +hmac: a74dd831a3d0a87b8fc1db45699a6a834ea769da9f437c55979ae665948c3b3f ... From 1ba742d99e3c33766ece5a64ef2776f73d1fc8a6 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 16 Aug 2022 18:13:31 -0300 Subject: [PATCH 21/21] Fix for final event calculation when splitting events --- .../apps/schedules/models/on_call_schedule.py | 21 ++++-- .../schedules/tests/test_on_call_schedule.py | 67 +++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 98d605f3..d05cc0f4 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -276,14 +276,18 @@ class OnCallSchedule(PolymorphicModel): if not events: return [] - # sort schedule events by (type desc, priority desc, start timestamp asc) - events.sort( - key=lambda e: ( - -e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None - -e["priority_level"] if e["priority_level"] else 0, - e["start"], + def apply_sorting(eventlist): + """Sort events keeping the events priority criteria.""" + eventlist.sort( + key=lambda e: ( + -e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None + -e["priority_level"] if e["priority_level"] else 0, + e["start"], + ) ) - ) + + # sort schedule events by (type desc, priority desc, start timestamp asc) + apply_sorting(events) def _merge_intervals(evs): """Keep track of scheduled intervals.""" @@ -345,6 +349,9 @@ class OnCallSchedule(PolymorphicModel): # event ends after current interval, update event start timestamp to match the interval end # and process the updated event as any other event ev["start"] = intervals[current_interval_idx][1] + # reorder pending events after updating current event start date + # (ie. insert the event where it should be to keep the order criteria) + apply_sorting(pending) else: # done, go to next event current_event_idx += 1 diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index a6e875ba..9fae6517 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -322,6 +322,73 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma assert returned_events == expected_events +@pytest.mark.django_db +def test_final_schedule_splitting_events( + make_organization, make_user_for_organization, make_on_call_shift, make_schedule +): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + user_a, user_b, user_c = (make_user_for_organization(organization, username=i) for i in "ABC") + + shifts = ( + # user, priority, start time (h), duration (hs) + (user_a, 1, 10, 10), # r1-1: 10-20 / A + (user_b, 1, 12, 4), # r1-2: 12-16 / B + (user_c, 2, 15, 3), # r2-1: 15-18 / C + ) + for user, priority, start_h, duration in shifts: + data = { + "start": start_date + timezone.timedelta(hours=start_h), + "rotation_start": start_date + timezone.timedelta(hours=start_h), + "duration": timezone.timedelta(hours=duration), + "priority_level": priority, + "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]]) + + returned_events = schedule.final_events("UTC", start_date, days=1) + + expected = ( + # start (h), duration (H), user, priority + (10, 5, "A", 1), # 10-15 A + (12, 3, "B", 1), # 12-15 B + (15, 3, "C", 2), # 15-18 C + (18, 2, "A", 1), # 18-20 A + ) + expected_events = [ + { + "end": start_date + timezone.timedelta(hours=start + duration), + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in returned_events + if not e["is_gap"] + ] + assert returned_events == expected_events + + @pytest.mark.django_db def test_preview_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): organization = make_organization()