diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2dbf2de..f61d3a85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,16 +23,26 @@ repos: - flake8-tidy-imports - repo: https://github.com/pre-commit/mirrors-eslint - rev: v7.21.0 + rev: v8.25.0 hooks: - id: eslint - entry: bash -c 'cd grafana-plugin && eslint --fix ${@/grafana-plugin\//}' -- + entry: bash -c 'cd grafana-plugin && eslint --max-warnings=0 --fix ${@/grafana-plugin\//}' -- types: [file] files: ^grafana-plugin/src/.*\.(js|jsx|ts|tsx)$ additional_dependencies: - - eslint@7.21.0 + - eslint@^8.25.0 - eslint-plugin-import@^2.25.4 - eslint-plugin-rulesdir@^0.2.1 + - "@grafana/eslint-config@^5.0.0" + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v2.7.1" + hooks: + - id: prettier + types_or: [css, javascript, jsx, ts, tsx, json] + files: ^grafana-plugin/src + additional_dependencies: + - prettier@^2.7.1 - repo: https://github.com/thibaudcolas/pre-commit-stylelint rev: v13.13.1 @@ -43,4 +53,6 @@ repos: files: ^grafana-plugin/src/.*\.css$ additional_dependencies: - stylelint@^13.13.1 + - stylelint-prettier@^2.0.0 - stylelint-config-standard@^22.0.0 + - stylelint-config-prettier@^9.0.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e27157..7c361a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,33 @@ # Change Log +## v1.0.46 (2022-10-28) + +- Bug fixes +- remove `POST /api/internal/v1/custom_buttons/{id}/action` endpoint + +## v1.0.45 (2022-10-27) + +- Bug fix to revert commit which removed unused engine code + +## v1.0.44 (2022-10-26) + +- Bug fix for an issue that was affecting phone verification + +## v1.0.43 (2022-10-25) + +- Bug fixes + +## v1.0.42 (2022-10-24) + +- Fix posting resolution notes to Slack + +## v1.0.41 (2022-10-24) + +- Add personal email notifications +- Bug fixes + ## v1.0.40 (2022-10-05) + - Improved database and celery backends support - Added script to import PagerDuty users to Grafana - Bug fixes diff --git a/README.md b/README.md index 51eaa6b1..08019328 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Developer-friendly incident response with brilliant Slack integration. We prepared multiple environments: [production](https://grafana.com/docs/grafana-cloud/oncall/open-source/#production-environment), [developer](DEVELOPER.md) and hobby: -1. Download docker-compose.yaml: +1. Download [`docker-compose.yml`](docker-compose.yml): ```bash curl -fsSL https://raw.githubusercontent.com/grafana/oncall/dev/docker-compose.yml -o docker-compose.yml @@ -31,9 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/grafana/oncall/dev/docker-compose.y ```bash echo "DOMAIN=http://localhost:8080 COMPOSE_PROFILES=with_grafana # Remove this line if you want to use existing grafana -SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long -RABBITMQ_PASSWORD=rabbitmq_secret_pw -MYSQL_PASSWORD=mysql_secret_pw" > .env +SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long" > .env ``` 3. Launch services: diff --git a/docker-compose-mysql-rabbitmq.yml b/docker-compose-mysql-rabbitmq.yml new file mode 100644 index 00000000..a77f5d25 --- /dev/null +++ b/docker-compose-mysql-rabbitmq.yml @@ -0,0 +1,162 @@ +version: "3.8" + +x-environment: + &oncall-environment + BASE_URL: $DOMAIN + SECRET_KEY: $SECRET_KEY + RABBITMQ_USERNAME: "rabbitmq" + RABBITMQ_PASSWORD: $RABBITMQ_PASSWORD + RABBITMQ_HOST: "rabbitmq" + RABBITMQ_PORT: "5672" + RABBITMQ_DEFAULT_VHOST: "/" + MYSQL_PASSWORD: $MYSQL_PASSWORD + MYSQL_DB_NAME: oncall_hobby + MYSQL_USER: ${MYSQL_USER:-root} + MYSQL_HOST: ${MYSQL_HOST:-mysql} + MYSQL_PORT: 3306 + REDIS_URI: redis://redis:6379/0 + DJANGO_SETTINGS_MODULE: settings.hobby + CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery" + CELERY_WORKER_CONCURRENCY: "1" + CELERY_WORKER_MAX_TASKS_PER_CHILD: "100" + CELERY_WORKER_SHUTDOWN_INTERVAL: "65m" + CELERY_WORKER_BEAT_ENABLED: "True" + +services: + engine: + image: grafana/oncall + restart: always + ports: + - "8080:8080" + command: > + sh -c "uwsgi --ini uwsgi.ini" + environment: *oncall-environment + depends_on: + mysql: + condition: service_healthy + oncall_db_migration: + condition: service_completed_successfully + rabbitmq: + condition: service_healthy + redis: + condition: service_started + + celery: + image: grafana/oncall + restart: always + command: sh -c "./celery_with_exporter.sh" + environment: *oncall-environment + depends_on: + mysql: + condition: service_healthy + oncall_db_migration: + condition: service_completed_successfully + rabbitmq: + condition: service_healthy + redis: + condition: service_started + + oncall_db_migration: + image: grafana/oncall + command: python manage.py migrate --noinput + environment: *oncall-environment + depends_on: + mysql: + condition: service_healthy + rabbitmq: + condition: service_healthy + + mysql: + image: mysql:5.7 + platform: linux/x86_64 + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + restart: always + expose: + - 3306 + volumes: + - dbdata:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: $MYSQL_PASSWORD + MYSQL_DATABASE: oncall_hobby + deploy: + resources: + limits: + memory: 500m + cpus: '0.5' + healthcheck: + test: "mysql -uroot -p$MYSQL_PASSWORD oncall_hobby -e 'select 1'" + timeout: 20s + retries: 10 + + redis: + image: redis + restart: always + expose: + - 6379 + deploy: + resources: + limits: + memory: 100m + cpus: '0.1' + + rabbitmq: + image: "rabbitmq:3.7.15-management" + restart: always + hostname: rabbitmq + volumes: + - rabbitmqdata:/var/lib/rabbitmq + environment: + RABBITMQ_DEFAULT_USER: "rabbitmq" + RABBITMQ_DEFAULT_PASS: $RABBITMQ_PASSWORD + RABBITMQ_DEFAULT_VHOST: "/" + deploy: + resources: + limits: + memory: 1000m + cpus: '0.5' + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 30s + retries: 3 + + mysql_to_create_grafana_db: + image: mysql:5.7 + platform: linux/x86_64 + command: bash -c "mysql -h ${MYSQL_HOST:-mysql} -uroot -p${MYSQL_PASSWORD:?err} -e 'CREATE DATABASE IF NOT EXISTS grafana CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'" + depends_on: + mysql: + condition: service_healthy + profiles: + - with_grafana + + grafana: + image: "grafana/grafana:9.0.0-beta3" + restart: always + ports: + - "3000:3000" + environment: + GF_DATABASE_TYPE: mysql + GF_DATABASE_HOST: ${MYSQL_HOST:-mysql} + GF_DATABASE_USER: ${MYSQL_USER:-root} + GF_DATABASE_PASSWORD: ${MYSQL_PASSWORD:?err} + GF_SECURITY_ADMIN_USER: ${GRAFANA_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin} + GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app + GF_INSTALL_PLUGINS: grafana-oncall-app + deploy: + resources: + limits: + memory: 500m + cpus: '0.5' + depends_on: + mysql_to_create_grafana_db: + condition: service_completed_successfully + mysql: + condition: service_healthy + profiles: + - with_grafana + +volumes: + dbdata: + rabbitmqdata: diff --git a/docker-compose.yml b/docker-compose.yml index a77f5d25..070f6f51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,18 +2,10 @@ version: "3.8" x-environment: &oncall-environment + DATABASE_TYPE: sqlite3 + BROKER_TYPE: redis BASE_URL: $DOMAIN SECRET_KEY: $SECRET_KEY - RABBITMQ_USERNAME: "rabbitmq" - RABBITMQ_PASSWORD: $RABBITMQ_PASSWORD - RABBITMQ_HOST: "rabbitmq" - RABBITMQ_PORT: "5672" - RABBITMQ_DEFAULT_VHOST: "/" - MYSQL_PASSWORD: $MYSQL_PASSWORD - MYSQL_DB_NAME: oncall_hobby - MYSQL_USER: ${MYSQL_USER:-root} - MYSQL_HOST: ${MYSQL_HOST:-mysql} - MYSQL_PORT: 3306 REDIS_URI: redis://redis:6379/0 DJANGO_SETTINGS_MODULE: settings.hobby CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery" @@ -31,104 +23,54 @@ services: command: > sh -c "uwsgi --ini uwsgi.ini" environment: *oncall-environment + volumes: + - oncall_data:/var/lib/oncall depends_on: - mysql: - condition: service_healthy oncall_db_migration: condition: service_completed_successfully - rabbitmq: - condition: service_healthy redis: - condition: service_started + condition: service_healthy celery: image: grafana/oncall restart: always command: sh -c "./celery_with_exporter.sh" environment: *oncall-environment + volumes: + - oncall_data:/var/lib/oncall depends_on: - mysql: - condition: service_healthy oncall_db_migration: condition: service_completed_successfully - rabbitmq: - condition: service_healthy redis: - condition: service_started + condition: service_healthy oncall_db_migration: image: grafana/oncall command: python manage.py migrate --noinput environment: *oncall-environment - depends_on: - mysql: - condition: service_healthy - rabbitmq: - condition: service_healthy - - mysql: - image: mysql:5.7 - platform: linux/x86_64 - command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci - restart: always - expose: - - 3306 volumes: - - dbdata:/var/lib/mysql - environment: - MYSQL_ROOT_PASSWORD: $MYSQL_PASSWORD - MYSQL_DATABASE: oncall_hobby - deploy: - resources: - limits: - memory: 500m - cpus: '0.5' - healthcheck: - test: "mysql -uroot -p$MYSQL_PASSWORD oncall_hobby -e 'select 1'" - timeout: 20s - retries: 10 + - oncall_data:/var/lib/oncall + depends_on: + redis: + condition: service_healthy redis: image: redis restart: always expose: - 6379 - deploy: - resources: - limits: - memory: 100m - cpus: '0.1' - - rabbitmq: - image: "rabbitmq:3.7.15-management" - restart: always - hostname: rabbitmq volumes: - - rabbitmqdata:/var/lib/rabbitmq - environment: - RABBITMQ_DEFAULT_USER: "rabbitmq" - RABBITMQ_DEFAULT_PASS: $RABBITMQ_PASSWORD - RABBITMQ_DEFAULT_VHOST: "/" + - redis_data:/data deploy: resources: limits: - memory: 1000m + memory: 500m cpus: '0.5' healthcheck: - test: rabbitmq-diagnostics -q ping - interval: 30s - timeout: 30s - retries: 3 - - mysql_to_create_grafana_db: - image: mysql:5.7 - platform: linux/x86_64 - command: bash -c "mysql -h ${MYSQL_HOST:-mysql} -uroot -p${MYSQL_PASSWORD:?err} -e 'CREATE DATABASE IF NOT EXISTS grafana CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'" - depends_on: - mysql: - condition: service_healthy - profiles: - - with_grafana + test: ["CMD", "redis-cli", "ping"] + timeout: 5s + interval: 5s + retries: 10 grafana: image: "grafana/grafana:9.0.0-beta3" @@ -136,27 +78,21 @@ services: ports: - "3000:3000" environment: - GF_DATABASE_TYPE: mysql - GF_DATABASE_HOST: ${MYSQL_HOST:-mysql} - GF_DATABASE_USER: ${MYSQL_USER:-root} - GF_DATABASE_PASSWORD: ${MYSQL_PASSWORD:?err} GF_SECURITY_ADMIN_USER: ${GRAFANA_USER:-admin} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin} GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app GF_INSTALL_PLUGINS: grafana-oncall-app + volumes: + - grafana_data:/var/lib/grafana deploy: resources: limits: memory: 500m cpus: '0.5' - depends_on: - mysql_to_create_grafana_db: - condition: service_completed_successfully - mysql: - condition: service_healthy profiles: - with_grafana volumes: - dbdata: - rabbitmqdata: + grafana_data: + oncall_data: + redis_data: diff --git a/docs/sources/integrations/available-integrations/add-alertmanager.md b/docs/sources/integrations/available-integrations/add-alertmanager.md index e3a80c1c..74661625 100644 --- a/docs/sources/integrations/available-integrations/add-alertmanager.md +++ b/docs/sources/integrations/available-integrations/add-alertmanager.md @@ -9,30 +9,30 @@ keywords: - on-call - Alertmanager - Prometheus -title: Connect Alert Manager to Grafana OnCall +title: Connect Alertmanager to Grafana OnCall canonical: "https://grafana.com/docs/oncall/latest/integrations/available-integrations/add-alertmanager/" weight: 300 --- -# Connect AlertManager to Grafana OnCall +# Connect Alertmanager to Grafana OnCall -The AlertManager integration for Grafana OnCall handles alerts sent by client applications such as the Prometheus server. +The Alertmanager integration for Grafana OnCall handles alerts sent by client applications such as the Prometheus server. -Grafana OnCall provides grouping abilities when processing alerts from Alert Manager, including initial deduplicating, grouping, and routing the alerts to Grafana OnCall. +Grafana OnCall provides grouping abilities when processing alerts from Alertmanager, including initial deduplicating, grouping, and routing the alerts to Grafana OnCall. -## Configure AlertManager integration for Grafana OnCall +## Configure Alertmanager integration for Grafana OnCall You must have an Admin role to create integrations in Grafana OnCall. 1. In the **Integrations** tab, click **+ New integration for receiving alerts**. -2. Select **AlertManager** from the list of available integrations. +2. Select **Alertmanager** from the list of available integrations. 3. Follow the instructions in the **How to connect** window to get your unique integration URL and identify next steps. -## Configure AlertManager +## Configure Alertmanager Update the `receivers` section of your Alertmanager configuration to use a unique integration URL: @@ -48,11 +48,11 @@ receivers: send_resolved: true ``` -## Configure grouping with AlertManager and Grafana OnCall +## Configure grouping with Alertmanager and Grafana OnCall -You can use the alert grouping mechanics of AlertManager and Grafana OnCall to configure your alert grouping preferences. +You can use the alert grouping mechanics of Alertmanager and Grafana OnCall to configure your alert grouping preferences. -AlertManager offers three alert grouping options: +Alertmanager offers three alert grouping options: - `group_by` provides two options, `instance` or `job`. - `group_wait` sets the length of time to initially wait before sending a notification for a particular group of alerts. For example, `group_wait` can be set to 45s. @@ -61,7 +61,7 @@ AlertManager offers three alert grouping options: - `group_interval` sets the length of time to wait before sending notifications about new alerts that have been added to a group of alerts that have been previously alerted on. This setting is usually set to five minutes or more. - During high alert volume periods, AlertManager will send alerts at each `group_interval`, which can mean a lot of distraction. Grafana OnCall grouping will help manage this in the following ways: + During high alert volume periods, Alertmanager will send alerts at each `group_interval`, which can mean a lot of distraction. Grafana OnCall grouping will help manage this in the following ways: - Grafana OnCall groups alerts based on the first label of each alert. diff --git a/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py b/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py index edc89dd4..b3364810 100644 --- a/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py @@ -49,8 +49,10 @@ class AlertGroupTelegramRenderer(AlertGroupBaseRenderer): status_verbose = self.alert_group.get_resolve_text() elif self.alert_group.acknowledged: status_verbose = self.alert_group.get_acknowledge_text() - - text = f"{status_emoji} #{self.alert_group.inside_organization_number}, {title}\n" + # First line in the invisible link with id of organization. + # It is needed to add info about organization to the telegram message for the oncall-gateway. + text = f"" + text += f"{status_emoji} #{self.alert_group.inside_organization_number}, {title}\n" text += f"{status_verbose}, alerts: {alerts_count_str}\n" text += f"Source: {self.alert_group.channel.short_name}\n" text += f"{self.alert_group.web_link}" diff --git a/engine/apps/alerts/incident_log_builder/incident_log_builder.py b/engine/apps/alerts/incident_log_builder/incident_log_builder.py index 11285ad1..3d982d4d 100644 --- a/engine/apps/alerts/incident_log_builder/incident_log_builder.py +++ b/engine/apps/alerts/incident_log_builder/incident_log_builder.py @@ -596,7 +596,7 @@ class IncidentLogBuilder: except ValueError: pass else: - result += f"send {backend.label.lower()} message to {user_verbal}" + result += f"send {backend.label.lower() if backend else ''} message to {user_verbal}" if not result: result += f"inviting {user_verbal} but notification channel is unspecified" return result diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index 7e362c72..b3355b51 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -232,21 +232,13 @@ class Alert(models.Model): return distinction - @property - def skip_signal(self): - try: - _ = self.migrator_lock - return True - except Alert.migrator_lock.RelatedObjectDoesNotExist: - return False - def listen_for_alert_model_save(sender, instance, created, *args, **kwargs): AlertGroup = apps.get_model("alerts", "AlertGroup") """ Here we invoke AlertShootingStep by model saving action. """ - if created and instance.group.maintenance_uuid is None and not instance.skip_signal: + if created and instance.group.maintenance_uuid is None: # RFCT - why additinal save ? instance.save() diff --git a/engine/apps/alerts/tasks/notify_group.py b/engine/apps/alerts/tasks/notify_group.py index a3de0b9f..70529596 100644 --- a/engine/apps/alerts/tasks/notify_group.py +++ b/engine/apps/alerts/tasks/notify_group.py @@ -37,7 +37,9 @@ def notify_group_task(alert_group_pk, escalation_policy_snapshot_order=None): escalation_policy_step = escalation_policy_snapshot.step usergroup = escalation_policy_snapshot.notify_to_group - usergroup_users = usergroup.get_users_from_members_for_organization(organization) + usergroup_users = [] + if usergroup is not None: + usergroup_users = usergroup.get_users_from_members_for_organization(organization) if len(usergroup_users) == 0: log_record = AlertGroupLogRecord( diff --git a/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py b/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py index eae19ef3..a2d8fa9b 100644 --- a/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py @@ -6,6 +6,7 @@ import pytz from django.utils import timezone from apps.alerts.tasks.notify_ical_schedule_shift import notify_ical_schedule_shift +from apps.schedules.ical_utils import memoized_users_in_ical from apps.schedules.models import OnCallScheduleICal ICAL_DATA = """ @@ -80,6 +81,8 @@ def test_next_shift_notification_long_shifts( organization, _, _, _ = make_organization_and_user_with_slack_identities() make_user(organization=organization, username="user1") make_user(organization=organization, username="user2") + # clear users pks <-> organization cache (persisting between tests) + memoized_users_in_ical.cache_clear() ical_schedule = make_schedule( organization, diff --git a/engine/apps/api/tests/test_custom_button.py b/engine/apps/api/tests/test_custom_button.py index d957b9a1..2d519d83 100644 --- a/engine/apps/api/tests/test_custom_button.py +++ b/engine/apps/api/tests/test_custom_button.py @@ -447,39 +447,6 @@ def test_custom_button_delete_permissions( 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_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), - ], -) -def test_custom_button_action_permissions( - make_organization_and_user_with_plugin_token, - make_custom_action, - make_user_auth_headers, - role, - expected_status, -): - organization, user, token = make_organization_and_user_with_plugin_token(role) - custom_button = make_custom_action(organization=organization) - client = APIClient() - - url = reverse("api-internal:custom_button-action", kwargs={"pk": custom_button.public_primary_key}) - - with patch( - "apps.api.views.custom_button.CustomButtonView.action", - return_value=Response( - status=status.HTTP_200_OK, - ), - ): - response = client.post(url, format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == expected_status - - @pytest.mark.django_db def test_get_custom_button_from_other_team_with_flag( make_organization_and_user_with_plugin_token, diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 13ef0825..4c562307 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -134,6 +134,84 @@ def test_get_list_schedules( assert response.json() == expected_payload +@pytest.mark.django_db +def test_get_list_schedules_by_type( + schedule_internal_api_setup, make_escalation_chain, make_escalation_policy, make_user_auth_headers +): + user, token, calendar_schedule, ical_schedule, web_schedule, slack_channel = schedule_internal_api_setup + client = APIClient() + + # setup escalation chain linked to web schedule + escalation_chain = make_escalation_chain(user.organization) + make_escalation_policy( + escalation_chain=escalation_chain, + escalation_policy_step=EscalationPolicy.STEP_NOTIFY_SCHEDULE, + notify_schedule=web_schedule, + ) + + expected_payload = [ + { + "id": calendar_schedule.public_primary_key, + "type": 0, + "team": None, + "name": "test_calendar_schedule", + "time_zone": "UTC", + "slack_channel": None, + "user_group": None, + "warnings": [], + "ical_url_overrides": None, + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + "number_of_escalation_chains": 0, + }, + { + "id": ical_schedule.public_primary_key, + "type": 1, + "team": None, + "name": "test_ical_schedule", + "ical_url_primary": ICAL_URL, + "ical_url_overrides": None, + "slack_channel": None, + "user_group": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + "number_of_escalation_chains": 0, + }, + { + "id": web_schedule.public_primary_key, + "type": 2, + "time_zone": "UTC", + "team": None, + "name": "test_web_schedule", + "slack_channel": None, + "user_group": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + "number_of_escalation_chains": 1, + }, + ] + + for expected, schedule_type in enumerate(("api", "ical", "web")): + url = reverse("api-internal:schedule-list") + "?type={}".format(schedule_type) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == [expected_payload[expected]] + + @pytest.mark.django_db def test_get_detail_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers): user, token, calendar_schedule, _, _, _ = schedule_internal_api_setup diff --git a/engine/apps/api/views/custom_button.py b/engine/apps/api/views/custom_button.py index 8d2a8082..09228d27 100644 --- a/engine/apps/api/views/custom_button.py +++ b/engine/apps/api/views/custom_button.py @@ -1,17 +1,12 @@ from django.core.exceptions import ObjectDoesNotExist -from rest_framework import status -from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.alerts.models import AlertGroup, CustomButton -from apps.alerts.tasks.custom_button_result import custom_button_result -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin, IsAdminOrEditor +from apps.alerts.models import CustomButton +from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin from apps.api.serializers.custom_button import CustomButtonSerializer from apps.auth_token.auth import PluginAuthentication -from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin from common.insight_log import EntityEvent, write_resource_insight_log @@ -21,7 +16,6 @@ class CustomButtonView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): permission_classes = (IsAuthenticated, ActionPermission) action_permissions = { IsAdmin: MODIFY_ACTIONS, - IsAdminOrEditor: ("action",), AnyRole: READ_ACTIONS, } @@ -85,19 +79,3 @@ class CustomButtonView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): event=EntityEvent.DELETED, ) instance.delete() - - @action(detail=True, methods=["post"]) - def action(self, request, pk): - alert_group_id = request.query_params.get("alert_group", None) - if alert_group_id is not None: - custom_button = self.get_object() - try: - alert_group = AlertGroup.unarchived_objects.get( - public_primary_key=alert_group_id, channel=custom_button.alert_receive_channel - ) - custom_button_result.apply_async((custom_button.pk, alert_group.pk, self.request.user.pk)) - except AlertGroup.DoesNotExist: - raise BadRequest(detail="AlertGroup does not exist or archived") - return Response(status=status.HTTP_200_OK) - else: - raise BadRequest(detail="AlertGroup is required") diff --git a/engine/apps/api/views/organization.py b/engine/apps/api/views/organization.py index 34580734..9545ffb3 100644 --- a/engine/apps/api/views/organization.py +++ b/engine/apps/api/views/organization.py @@ -59,7 +59,9 @@ class GetTelegramVerificationCode(APIView): telegram_client = TelegramClient() bot_username = telegram_client.api_client.username bot_link = f"https://t.me/{bot_username}" - return Response({"telegram_code": str(new_code.uuid), "bot_link": bot_link}, status=status.HTTP_200_OK) + return Response( + {"telegram_code": str(new_code.uuid_with_org_id), "bot_link": bot_link}, status=status.HTTP_200_OK + ) class GetChannelVerificationCode(APIView): diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 9c52a69c..5a2d96aa 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -24,7 +24,7 @@ from apps.api.serializers.schedule_polymorphic import ( from apps.auth_token.auth import PluginAuthentication from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME from apps.auth_token.models import ScheduleExportAuthToken -from apps.schedules.models import OnCallSchedule +from apps.schedules.models import OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb from apps.slack.models import SlackChannel from apps.slack.tasks import update_slack_user_group_for_schedules from common.api_helpers.exceptions import BadRequest, Conflict @@ -42,6 +42,8 @@ EVENTS_FILTER_BY_ROTATION = "rotation" EVENTS_FILTER_BY_OVERRIDE = "override" EVENTS_FILTER_BY_FINAL = "final" +SCHEDULE_TYPE_TO_CLASS = {"api": OnCallScheduleCalendar, "ical": OnCallScheduleICal, "web": OnCallScheduleWeb} + class ScheduleView( TeamFilteringMixin, @@ -123,6 +125,7 @@ class ScheduleView( def get_queryset(self): is_short_request = self.request.query_params.get("short", "false") == "true" + filter_by_type = self.request.query_params.get("type") organization = self.request.auth.organization queryset = OnCallSchedule.objects.filter(organization=organization, team=self.request.user.current_team).defer( # avoid requesting large text fields which are not used when listing schedules @@ -134,6 +137,8 @@ class ScheduleView( if not is_short_request: queryset = self._annotate_queryset(queryset) queryset = self.serializer_class.setup_eager_loading(queryset) + if filter_by_type is not None and filter_by_type in SCHEDULE_TYPE_TO_CLASS: + queryset = queryset.filter().instance_of(SCHEDULE_TYPE_TO_CLASS[filter_by_type]) return queryset def perform_create(self, serializer): diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index cf37b9e7..c27a713d 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -374,7 +374,9 @@ class UserView( bot_username = telegram_client.api_client.username bot_link = f"https://t.me/{bot_username}" - return Response({"telegram_code": str(new_code.uuid), "bot_link": bot_link}, status=status.HTTP_200_OK) + return Response( + {"telegram_code": str(new_code.uuid_with_org_id), "bot_link": bot_link}, status=status.HTTP_200_OK + ) @action(detail=True, methods=["post"]) def unlink_slack(self, request, pk): diff --git a/engine/apps/migration_tool/__init__.py b/engine/apps/migration_tool/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/engine/apps/migration_tool/constants.py b/engine/apps/migration_tool/constants.py deleted file mode 100644 index 4ab4377d..00000000 --- a/engine/apps/migration_tool/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -# amixr api url -REQUEST_URL = "https://amixr.io/api/v1" - -# migration status -NOT_STARTED = "not_started" -IN_PROGRESS = "in_progress" -FINISHED = "finished" diff --git a/engine/apps/migration_tool/migrations/0001_squashed_initial.py b/engine/apps/migration_tool/migrations/0001_squashed_initial.py deleted file mode 100644 index 3772704d..00000000 --- a/engine/apps/migration_tool/migrations/0001_squashed_initial.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.2.5 on 2022-05-31 14:46 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('alerts', '0001_squashed_initial'), - ] - - operations = [ - migrations.CreateModel( - name='AmixrMigrationTaskStatus', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('task_id', models.CharField(db_index=True, max_length=500)), - ('name', models.CharField(max_length=500)), - ('started_at', models.DateTimeField(auto_now_add=True)), - ('is_finished', models.BooleanField(default=False)), - ], - ), - migrations.CreateModel( - name='LockedAlert', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('alert', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='migrator_lock', to='alerts.alert')), - ], - ), - ] diff --git a/engine/apps/migration_tool/migrations/0002_amixrmigrationtaskstatus_organization.py b/engine/apps/migration_tool/migrations/0002_amixrmigrationtaskstatus_organization.py deleted file mode 100644 index 8480acdd..00000000 --- a/engine/apps/migration_tool/migrations/0002_amixrmigrationtaskstatus_organization.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.2.5 on 2022-05-31 14:46 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('user_management', '0001_squashed_initial'), - ('migration_tool', '0001_squashed_initial'), - ] - - operations = [ - migrations.AddField( - model_name='amixrmigrationtaskstatus', - name='organization', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='migration_tasks', to='user_management.organization'), - ), - ] diff --git a/engine/apps/migration_tool/migrations/__init__.py b/engine/apps/migration_tool/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/engine/apps/migration_tool/models/__init__.py b/engine/apps/migration_tool/models/__init__.py deleted file mode 100644 index c7478eab..00000000 --- a/engine/apps/migration_tool/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .amixr_migration_task_status import AmixrMigrationTaskStatus # noqa: F401 -from .locked_alert import LockedAlert # noqa: F401 diff --git a/engine/apps/migration_tool/models/amixr_migration_task_status.py b/engine/apps/migration_tool/models/amixr_migration_task_status.py deleted file mode 100644 index 543013d7..00000000 --- a/engine/apps/migration_tool/models/amixr_migration_task_status.py +++ /dev/null @@ -1,27 +0,0 @@ -from celery import uuid as celery_uuid -from django.db import models - - -class AmixrMigrationTaskStatusQuerySet(models.QuerySet): - def get_migration_task_id(self, organization_id, name): - migrate_schedules_task_id = celery_uuid() - self.model(organization_id=organization_id, name=name, task_id=migrate_schedules_task_id).save() - return migrate_schedules_task_id - - -class AmixrMigrationTaskStatus(models.Model): - objects = AmixrMigrationTaskStatusQuerySet.as_manager() - - task_id = models.CharField(max_length=500, db_index=True) - name = models.CharField(max_length=500) - organization = models.ForeignKey( - to="user_management.Organization", - related_name="migration_tasks", - on_delete=models.deletion.CASCADE, - ) - started_at = models.DateTimeField(auto_now_add=True) - is_finished = models.BooleanField(default=False) - - def update_status_to_finished(self): - self.is_finished = True - self.save(update_fields=["is_finished"]) diff --git a/engine/apps/migration_tool/models/locked_alert.py b/engine/apps/migration_tool/models/locked_alert.py deleted file mode 100644 index 8771c6ce..00000000 --- a/engine/apps/migration_tool/models/locked_alert.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.db import models - - -class LockedAlert(models.Model): - alert = models.OneToOneField("alerts.Alert", on_delete=models.CASCADE, related_name="migrator_lock") diff --git a/engine/apps/migration_tool/tasks.py b/engine/apps/migration_tool/tasks.py deleted file mode 100644 index 23ceff21..00000000 --- a/engine/apps/migration_tool/tasks.py +++ /dev/null @@ -1,612 +0,0 @@ -import logging - -from celery.utils.log import get_task_logger -from django.apps import apps -from django.conf import settings -from django.db import transaction -from django.utils import timezone -from rest_framework import exceptions - -from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, ResolutionNote -from apps.migration_tool.models import AmixrMigrationTaskStatus, LockedAlert -from apps.migration_tool.utils import convert_string_to_datetime, get_data_with_respect_to_pagination -from apps.public_api.serializers import PersonalNotificationRuleSerializer -from common.custom_celery_tasks import shared_dedicated_queue_retry_task - -logger = get_task_logger(__name__) -logger.setLevel(logging.DEBUG) - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def start_migration_from_old_amixr(api_token, organization_id, user_id): - logger.info(f"Start migration task from amixr for organization {organization_id}") - users = get_users(organization_id, api_token) - - migrate_schedules_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=migrate_schedules.name - ) - migrate_schedules.apply_async( - (api_token, organization_id, user_id, users), - task_id=migrate_schedules_task_id, - countdown=5, - ) - - start_migration_user_data_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=start_migration_user_data.name - ) - start_migration_user_data.apply_async( - (api_token, organization_id, users), - task_id=start_migration_user_data_task_id, - ) - logger.info(f"Start 'start_migration_from_old_amixr' task for organization {organization_id}") - - -def get_users(organization_id, api_token): - Organization = apps.get_model("user_management", "Organization") - organization = Organization.objects.get(pk=organization_id) - # get all users from old amixr - old_users = get_data_with_respect_to_pagination(api_token, "users") - old_users_emails = [old_user["email"] for old_user in old_users] - # find users in Grafana OnCall by email - grafana_users = organization.users.filter(email__in=old_users_emails).values("email", "id") - - grafana_users_dict = { - gu["email"]: { - "id": gu["id"], - } - for gu in grafana_users - } - - users = {} - for old_user in old_users: - if old_user["email"] in grafana_users_dict: - users[old_user["id"]] = grafana_users_dict[old_user["email"]] - users[old_user["id"]]["old_verified_phone_number"] = old_user.get("verified_phone_number") - users[old_user["id"]]["old_public_primary_key"] = old_user["id"] - - # Example result: - # users = { - # "OLD_PUBLIC_PK": { - # "id": 1, # user pk in OnCall db - # "old_verified_phone_number": "1234", - # "old_public_primary_key": "OLD_PUBLIC_PK", - # }, - # ... - # } - return users - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def migrate_schedules(api_token, organization_id, user_id, users): - logger.info(f"Started migration schedules for organization {organization_id}") - OnCallScheduleICal = apps.get_model("schedules", "OnCallScheduleICal") - Organization = apps.get_model("user_management", "Organization") - organization = Organization.objects.get(pk=organization_id) - - schedules = get_data_with_respect_to_pagination(api_token, "schedules") - existing_schedules_names = set(organization.oncall_schedules.values_list("name", flat=True)) - created_schedules = {} - for schedule in schedules: - if not schedule["ical_url"] or schedule["name"] in existing_schedules_names: - continue - - new_schedule = OnCallScheduleICal( - organization=organization, - name=schedule["name"], - ical_url_primary=schedule["ical_url"], - team_id=None, - ) - - new_schedule.save() - - created_schedules[schedule["id"]] = { - "id": new_schedule.pk, - } - # Example result: - # created_schedules = { - # "OLD_PUBLIC_PK": { - # "id": 1, # schedule pk in OnCall db - # }, - # ... - # } - - migrate_integrations_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=migrate_integrations.name - ) - migrate_integrations.apply_async( - (api_token, organization_id, user_id, created_schedules, users), task_id=migrate_integrations_task_id - ) - - current_task_id = migrate_schedules.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished migration schedules for organization {organization_id}") - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def migrate_integrations(api_token, organization_id, user_id, created_schedules, users): - logger.info(f"Started migration integrations for organization {organization_id}") - Organization = apps.get_model("user_management", "Organization") - organization = Organization.objects.get(pk=organization_id) - - integrations = get_data_with_respect_to_pagination(api_token, "integrations") - - existing_integrations_names = set(organization.alert_receive_channels.values_list("verbal_name", flat=True)) - - for integration in integrations: - if integration["name"] in existing_integrations_names: - continue - - try: - integration_type = [ - key - for key, value in AlertReceiveChannel.INTEGRATIONS_TO_REVERSE_URL_MAP.items() - if value == integration["type"] - ][0] - except IndexError: - continue - if integration_type not in AlertReceiveChannel.WEB_INTEGRATION_CHOICES: - continue - - new_integration = AlertReceiveChannel.create( - organization=organization, - verbal_name=integration["name"], - integration=integration_type, - author_id=user_id, - slack_title_template=integration["templates"]["slack"]["title"], - slack_message_template=integration["templates"]["slack"]["message"], - slack_image_url_template=integration["templates"]["slack"]["image_url"], - sms_title_template=integration["templates"]["sms"]["title"], - phone_call_title_template=integration["templates"]["phone_call"]["title"], - web_title_template=integration["templates"]["web"]["title"], - web_message_template=integration["templates"]["web"]["message"], - web_image_url_template=integration["templates"]["web"]["image_url"], - email_title_template=integration["templates"]["email"]["title"], - email_message_template=integration["templates"]["email"]["message"], - telegram_title_template=integration["templates"]["telegram"]["title"], - telegram_message_template=integration["templates"]["telegram"]["message"], - telegram_image_url_template=integration["templates"]["telegram"]["image_url"], - grouping_id_template=integration["templates"]["grouping_key"], - resolve_condition_template=integration["templates"]["resolve_signal"], - acknowledge_condition_template=integration["templates"]["acknowledge_signal"], - ) - # collect integration data in a dict - integration_data = { - "id": new_integration.pk, - "verbal_name": new_integration.verbal_name, - "old_public_primary_key": integration["id"], - } - - migrate_routes_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=migrate_routes.name - ) - migrate_routes.apply_async( - (api_token, organization_id, users, created_schedules, integration_data), - task_id=migrate_routes_task_id, - countdown=3, - ) - - current_task_id = migrate_integrations.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished migration integrations for organization {organization_id}") - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def migrate_routes(api_token, organization_id, users, created_schedules, integration_data): - logger.info(f"Start migration routes for organization {organization_id}") - AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") - ChannelFilter = apps.get_model("alerts", "ChannelFilter") - Organization = apps.get_model("user_management", "Organization") - organization = Organization.objects.get(pk=organization_id) - - integration = AlertReceiveChannel.objects.filter(pk=integration_data["id"]).first() - if integration: - url = "routes?integration_id={}".format(integration_data["old_public_primary_key"]) - routes = get_data_with_respect_to_pagination(api_token, url) - - default_route = integration.channel_filters.get(is_default=True) - existing_chain_names = set(organization.escalation_chains.values_list("name", flat=True)) - existing_route_filtering_term = set(integration.channel_filters.values_list("filtering_term", flat=True)) - - for route in routes: - is_default_route = route["is_the_last_route"] - filtering_term = route["routing_regex"] - - if is_default_route: - escalation_chain_name = f"{integration_data['verbal_name'][:90]} - default" - else: - if filtering_term in existing_route_filtering_term: - continue - escalation_chain_name = f"{integration_data['verbal_name']} - {filtering_term}"[:100] - - if escalation_chain_name in existing_chain_names: - escalation_chain = organization.escalation_chains.get(name=escalation_chain_name) - else: - escalation_chain = organization.escalation_chains.create(name=escalation_chain_name) - - if is_default_route: - new_route = default_route - new_route.escalation_chain = escalation_chain - new_route.save(update_fields=["escalation_chain"]) - else: - new_route = ChannelFilter( - alert_receive_channel_id=integration_data["id"], - escalation_chain_id=escalation_chain.pk, - filtering_term=filtering_term, - order=route["position"], - ) - new_route.save() - - route_data = { - "id": new_route.pk, - "old_public_primary_key": route["id"], - "escalation_chain": { - "id": escalation_chain.pk, - }, - } - - migrate_escalation_policies_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=migrate_escalation_policies.name - ) - migrate_escalation_policies.apply_async( - (api_token, organization_id, users, created_schedules, route_data), - task_id=migrate_escalation_policies_task_id, - countdown=2, - ) - - start_migration_alert_groups_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=start_migration_alert_groups.name - ) - start_migration_alert_groups.apply_async( - (api_token, organization_id, users, integration_data, route_data), - task_id=start_migration_alert_groups_task_id, - countdown=10, - ) - - current_task_id = migrate_routes.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished migration routes for organization {organization_id}") - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def migrate_escalation_policies(api_token, organization_id, users, created_schedules, route_data): - logger.info(f"Start migration escalation policies for organization {organization_id}") - EscalationChain = apps.get_model("alerts", "EscalationChain") - EscalationPolicy = apps.get_model("alerts", "EscalationPolicy") - - escalation_chain = EscalationChain.objects.filter(pk=route_data["escalation_chain"]["id"]).first() - if escalation_chain and not escalation_chain.escalation_policies.exists(): - - url = "escalation_policies?route_id={}".format(route_data["old_public_primary_key"]) - escalation_policies = get_data_with_respect_to_pagination(api_token, url) - - for escalation_policy in escalation_policies: - try: - step_type = [ - key - for key, value in EscalationPolicy.PUBLIC_STEP_CHOICES_MAP.items() - if value == escalation_policy["type"] and key in EscalationPolicy.PUBLIC_STEP_CHOICES - ][0] - except IndexError: - continue - - if step_type in EscalationPolicy.DEFAULT_TO_IMPORTANT_STEP_MAPPING and escalation_policy.get("important"): - step_type = EscalationPolicy.DEFAULT_TO_IMPORTANT_STEP_MAPPING[step_type] - - notify_to_users_queue = [] - - if step_type == EscalationPolicy.STEP_NOTIFY_USERS_QUEUE: - notify_to_users_queue = [ - users[user_old_public_pk]["id"] - for user_old_public_pk in escalation_policy.get("persons_to_notify_next_each_time", []) - if user_old_public_pk in users - ] - elif step_type in [ - EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS, - EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT, - ]: - notify_to_users_queue = [ - users[user_old_public_pk]["id"] - for user_old_public_pk in escalation_policy.get("persons_to_notify", []) - if user_old_public_pk in users - ] - - if step_type == EscalationPolicy.STEP_NOTIFY_IF_TIME: - notify_from_time = timezone.datetime.strptime( - escalation_policy.get("notify_if_time_from"), "%H:%M:%SZ" - ).time() - notify_to_time = timezone.datetime.strptime( - escalation_policy.get("notify_if_time_to"), "%H:%M:%SZ" - ).time() - else: - notify_from_time, notify_to_time = None, None - duration = escalation_policy.get("duration") - wait_delay = timezone.timedelta(seconds=duration) if duration else None - - schedule_id = escalation_policy.get("notify_on_call_from_schedule") - - notify_schedule_id = created_schedules.get(schedule_id, {}).get("id") if schedule_id else None - - new_escalation_policy = EscalationPolicy( - step=step_type, - order=escalation_policy["position"], - escalation_chain=escalation_chain, - notify_schedule_id=notify_schedule_id, - wait_delay=wait_delay, - from_time=notify_from_time, - to_time=notify_to_time, - ) - - new_escalation_policy.save() - if notify_to_users_queue: - new_escalation_policy.notify_to_users_queue.set(notify_to_users_queue) - - current_task_id = migrate_escalation_policies.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished migration escalation policies for organization {organization_id}") - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def start_migration_alert_groups(api_token, organization_id, users, integration_data, route_data): - logger.info(f"Start migration alert groups for organization {organization_id}") - ChannelFilter = apps.get_model("alerts", "ChannelFilter") - - url = "incidents?route_id={}".format(route_data["old_public_primary_key"]) - alert_groups = get_data_with_respect_to_pagination(api_token, url) - - route = ChannelFilter.objects.filter(pk=route_data["id"]).first() - - if route and not route.alert_groups.exists(): - for alert_group in alert_groups: - - migrate_alert_group_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=migrate_alert_group.name - ) - migrate_alert_group.apply_async( - (api_token, organization_id, users, integration_data, route_data, alert_group), - task_id=migrate_alert_group_task_id, - ) - - current_task_id = start_migration_alert_groups.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished 'start_migration_alert_groups' for organization {organization_id}") - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def migrate_alert_group(api_token, organization_id, users, integration_data, route_data, alert_group_to_migrate): - logger.info(f"Start migration alert_group {alert_group_to_migrate['id']} for organization {organization_id}") - integration = AlertReceiveChannel.objects.get(pk=integration_data["id"]) - resolve_by_user_id = None - acknowledged_by_user_id = None - - if alert_group_to_migrate["resolved_by_user"]: - resolve_by_user_id = users.get(alert_group_to_migrate["resolved_by_user"], {}).get("id") - if alert_group_to_migrate["acknowledged_by_user"]: - acknowledged_by_user_id = users.get(alert_group_to_migrate["acknowledged_by_user"], {}).get("id") - - new_group = AlertGroup.all_objects.create( - channel=integration, - channel_filter_id=route_data["id"], - resolved=True, - resolved_by=alert_group_to_migrate["resolved_by"], - resolved_by_user_id=resolve_by_user_id, - resolved_at=alert_group_to_migrate.get("resolved_at") or timezone.now(), - acknowledged=alert_group_to_migrate["acknowledged"], - acknowledged_by=alert_group_to_migrate["acknowledged_by"], - acknowledged_by_user_id=acknowledged_by_user_id, - acknowledged_at=alert_group_to_migrate.get("acknowledged_at"), - ) - - new_group.started_at = convert_string_to_datetime(alert_group_to_migrate["created_at"]) - new_group.save(update_fields=["started_at"]) - - alert_group_data = { - "id": new_group.pk, - "old_public_primary_key": alert_group_to_migrate["id"], - } - - start_migration_alerts_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=start_migration_alerts.name - ) - start_migration_alerts.apply_async( - (api_token, organization_id, alert_group_data), - task_id=start_migration_alerts_task_id, - ) - - start_migration_logs_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=start_migration_logs.name - ) - start_migration_logs.apply_async( - (api_token, organization_id, users, alert_group_data), - task_id=start_migration_logs_task_id, - countdown=5, - ) - - current_task_id = migrate_alert_group.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished migration alert_group {alert_group_to_migrate['id']} for organization {organization_id}") - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def start_migration_alerts(api_token, organization_id, alert_group_data): - logger.info( - f"Start migration alerts for alert_group {alert_group_data['old_public_primary_key']} " - f"for organization {organization_id}" - ) - AlertGroup = apps.get_model("alerts", "AlertGroup") - alert_group = AlertGroup.all_objects.get(pk=alert_group_data["id"]) - if not alert_group.alerts.exists(): - - url = "alerts?incident_id={}".format(alert_group_data["old_public_primary_key"]) - alerts = get_data_with_respect_to_pagination(api_token, url) - - for alert in alerts: - migrate_alerts_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=migrate_alert.name - ) - migrate_alert.apply_async( - (organization_id, alert_group_data, alert), - task_id=migrate_alerts_task_id, - ) - - current_task_id = start_migration_alerts.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info( - f"Finished 'start_migration_alerts' for alert_group {alert_group_data['old_public_primary_key']} " - f"for organization {organization_id}" - ) - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def migrate_alert(organization_id, alert_group_data, alert): - logger.info(f"Start migration alert {alert['id']} for organization {organization_id}") - with transaction.atomic(): - new_alert = Alert( - title=alert["title"], - message=alert["message"], - image_url=alert["image_url"], - link_to_upstream_details=alert["link_to_upstream_details"], - group_id=alert_group_data["id"], - integration_unique_data=alert["payload"], - raw_request_data=alert["payload"], - ) - new_alert.save() - LockedAlert.objects.create(alert=new_alert) - new_alert.created_at = convert_string_to_datetime(alert["created_at"]) - new_alert.save(update_fields=["created_at"]) - - current_task_id = migrate_alert.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished migration alert {alert['id']} for organization {organization_id}") - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def start_migration_logs(api_token, organization_id, users, alert_group_data): - logger.info(f"Start migration logs for alert_group {alert_group_data['id']} for organization {organization_id}") - url = "incident_logs?incident_id={}".format(alert_group_data["old_public_primary_key"]) - alert_group_logs = get_data_with_respect_to_pagination(api_token, url) - - for log in alert_group_logs: - migrate_logs_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=migrate_log.name - ) - migrate_log.apply_async( - (organization_id, users, alert_group_data, log), - task_id=migrate_logs_task_id, - ) - - current_task_id = start_migration_logs.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info( - f"Finished 'start_migration_logs' for alert_group {alert_group_data['id']} for organization {organization_id}" - ) - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def migrate_log(organization_id, users, alert_group_data, log): - logger.info(f"Start migration log for alert_group {alert_group_data['id']} for organization {organization_id}") - log_author_id = users.get(log["author"], {}).get("id") - new_resolution_note = ResolutionNote( - author_id=log_author_id, - message_text=log["text"], - alert_group_id=alert_group_data["id"], - ) - new_resolution_note.save() - new_resolution_note.created_at = convert_string_to_datetime(log["created_at"]) - new_resolution_note.save(update_fields=["created_at"]) - - current_task_id = migrate_log.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def start_migration_user_data(api_token, organization_id, users): - logger.info(f"Start migration user data for organization {organization_id}") - for user in users: - user_data = users[user] - migrate_user_data_task_id = AmixrMigrationTaskStatus.objects.get_migration_task_id( - organization_id=organization_id, name=migrate_user_data.name - ) - migrate_user_data.apply_async( - (api_token, organization_id, user_data), - task_id=migrate_user_data_task_id, - ) - - current_task_id = start_migration_user_data.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished 'start_migration_user_data' task for organization {organization_id}") - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=0 if settings.DEBUG else None -) -def migrate_user_data(api_token, organization_id, user_to_migrate): - logger.info(f"Start migration user {user_to_migrate['id']} for organization {organization_id}") - User = apps.get_model("user_management", "User") - UserNotificationPolicy = apps.get_model("base", "UserNotificationPolicy") - user = User.objects.filter(pk=user_to_migrate["id"], organization_id=organization_id).first() - - if user: - if not user.verified_phone_number and user_to_migrate["old_verified_phone_number"]: - user.save_verified_phone_number(user_to_migrate["old_verified_phone_number"]) - - url = "personal_notification_rules?user_id={}".format(user_to_migrate["old_public_primary_key"]) - user_notification_policies = get_data_with_respect_to_pagination(api_token, url) - - notification_policies_to_create = [] - existing_notification_policies_ids = list(user.notification_policies.all().values_list("pk", flat=True)) - - for notification_policy in user_notification_policies: - - try: - step, notification_channel = PersonalNotificationRuleSerializer._type_to_step_and_notification_channel( - notification_policy["type"], - ) - except exceptions.ValidationError: - continue - - new_notification_policy = UserNotificationPolicy( - user=user, - important=notification_policy["important"], - step=step, - order=notification_policy["position"], - ) - if step == UserNotificationPolicy.Step.NOTIFY: - new_notification_policy.notify_by = notification_channel - - if step == UserNotificationPolicy.Step.WAIT: - duration = notification_policy.get("duration") - wait_delay = timezone.timedelta(seconds=duration) if duration else UserNotificationPolicy.FIVE_MINUTES - new_notification_policy.wait_delay = wait_delay - - notification_policies_to_create.append(new_notification_policy) - - UserNotificationPolicy.objects.bulk_create(notification_policies_to_create, batch_size=5000) - user.notification_policies.filter(pk__in=existing_notification_policies_ids).delete() - - current_task_id = migrate_user_data.request.id - AmixrMigrationTaskStatus.objects.get(task_id=current_task_id).update_status_to_finished() - logger.info(f"Finished migration user {user_to_migrate['id']} for organization {organization_id}") diff --git a/engine/apps/migration_tool/urls.py b/engine/apps/migration_tool/urls.py deleted file mode 100644 index 8aa874d1..00000000 --- a/engine/apps/migration_tool/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from common.api_helpers.optional_slash_router import optional_slash_path - -from .views.customers_migration_tool import MigrateAPIView, MigrationPlanAPIView, MigrationStatusAPIView - -app_name = "migration-tool" - - -urlpatterns = [ - optional_slash_path("amixr_migration_plan", MigrationPlanAPIView.as_view(), name="amixr_migration_plan"), - optional_slash_path("migrate_from_amixr", MigrateAPIView.as_view(), name="migrate_from_amixr"), - optional_slash_path("amixr_migration_status", MigrationStatusAPIView.as_view(), name="amixr_migration_status"), -] diff --git a/engine/apps/migration_tool/utils.py b/engine/apps/migration_tool/utils.py deleted file mode 100644 index 0faa4efe..00000000 --- a/engine/apps/migration_tool/utils.py +++ /dev/null @@ -1,35 +0,0 @@ -import requests -from django.utils import timezone - -from apps.migration_tool.constants import REQUEST_URL - - -class APIResponseException(Exception): - pass - - -def get_data_with_respect_to_pagination(api_token, endpoint): - def fetch(url): - response = requests.get(url, headers={"AUTHORIZATION": api_token}) - if response.status_code != 200: - raise APIResponseException(f"Status code: {response.status_code}, Data: {response.content}") - return response.json() - - data = fetch(f"{REQUEST_URL}/{endpoint}") - results = data["results"] - - while data["next"]: - data = fetch(data["next"]) - - new_results = data["results"] - results.extend(new_results) - - return results - - -def convert_string_to_datetime(dt_str): - try: - dt = timezone.datetime.strptime(dt_str, "%Y-%m-%dT%X.%f%z") - except ValueError: - dt = timezone.datetime.strptime(dt_str, "%Y-%m-%dT%XZ") - return dt diff --git a/engine/apps/migration_tool/views/__init__.py b/engine/apps/migration_tool/views/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/engine/apps/migration_tool/views/customers_migration_tool.py b/engine/apps/migration_tool/views/customers_migration_tool.py deleted file mode 100644 index d34e18c0..00000000 --- a/engine/apps/migration_tool/views/customers_migration_tool.py +++ /dev/null @@ -1,186 +0,0 @@ -import logging - -import requests -from rest_framework import status -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.alerts.models import AlertReceiveChannel -from apps.api.permissions import IsAdmin, MethodPermission -from apps.auth_token.auth import PluginAuthentication -from apps.migration_tool.constants import FINISHED, IN_PROGRESS, NOT_STARTED, REQUEST_URL -from apps.migration_tool.tasks import start_migration_from_old_amixr -from apps.migration_tool.utils import get_data_with_respect_to_pagination -from common.api_helpers.exceptions import BadRequest - -logger = logging.getLogger(__name__) - - -class MigrationPlanAPIView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, MethodPermission) - - method_permissions = {IsAdmin: ("POST",)} - - def post(self, request): - api_token = request.data.get("token", None) - if api_token is None: - raise BadRequest(detail="API token is required") - - organization = request.auth.organization - if organization.is_amixr_migration_started: - raise BadRequest(detail="Migration from Amixr has already been started") - - # check token - response = requests.get(f"{REQUEST_URL}/users", headers={"AUTHORIZATION": api_token}) - if response.status_code == status.HTTP_403_FORBIDDEN: - raise BadRequest(detail="Invalid token") - - # Just not to re-make the frontend... - USERS_NOT_TO_MIGRATE_KEY = ( - "Users WON'T be migrated (couldn't find those users in the Grafana Cloud, ask " - "them to sign up if you want their data to be migrated and re-build the migration plan)" - ) - - USERS_TO_MIGRATE = "Users will be migrated" - INTEGRATIONS_TO_MIGRATE = "Integrations to migrate" - INTEGRATIONS_COUNT = "Integrations count" - ROUTES_COUNT = "Routes count" - ESCALATIONS_POLICIES_COUNT = "Escalation policies count" - CALENDARS_COUNT = "Calendars count" - - migration_plan = { - USERS_TO_MIGRATE: [], - USERS_NOT_TO_MIGRATE_KEY: [], - INTEGRATIONS_TO_MIGRATE: [], - INTEGRATIONS_COUNT: 0, - ROUTES_COUNT: 0, - ESCALATIONS_POLICIES_COUNT: 0, - CALENDARS_COUNT: 0, - } - logger.info(f"migration plan for organization {organization.pk}: get users") - users = get_data_with_respect_to_pagination(api_token, "users") - logger.info(f"migration plan for organization {organization.pk}: got users") - org_users = organization.users.values_list("email", flat=True) - for user in users: - if user["email"] in org_users: - migration_plan[USERS_TO_MIGRATE].append(user["email"]) - else: - migration_plan[USERS_NOT_TO_MIGRATE_KEY].append(user["email"]) - - logger.info(f"migration plan for organization {organization.pk}: get integrations") - integrations = get_data_with_respect_to_pagination(api_token, "integrations") - logger.info(f"migration plan for organization {organization.pk}: got integrations") - existing_integrations_names = set(organization.alert_receive_channels.values_list("verbal_name", flat=True)) - - integrations_to_migrate_public_pk = [] - - for integration in integrations: - if integration["name"] in existing_integrations_names: - continue - - try: - integration_type = [ - key - for key, value in AlertReceiveChannel.INTEGRATIONS_TO_REVERSE_URL_MAP.items() - if value == integration["type"] - ][0] - except IndexError: - continue - if integration_type not in AlertReceiveChannel.WEB_INTEGRATION_CHOICES: - continue - - migration_plan[INTEGRATIONS_TO_MIGRATE].append(integration["name"]) - integrations_to_migrate_public_pk.append(integration["id"]) - - migration_plan[INTEGRATIONS_COUNT] = len(migration_plan[INTEGRATIONS_TO_MIGRATE]) - - routes_to_migrate_public_pk = [] - logger.info(f"migration plan for organization {organization.pk}: get routes") - routes = get_data_with_respect_to_pagination(api_token, "routes") - logger.info(f"migration plan for organization {organization.pk}: got routes") - - for route in routes: - if route["integration_id"] in integrations_to_migrate_public_pk: - migration_plan[ROUTES_COUNT] += 1 - routes_to_migrate_public_pk.append(route["id"]) - - logger.info(f"migration plan for organization {organization.pk}: get escalation_policies") - escalation_policies = get_data_with_respect_to_pagination(api_token, "escalation_policies") - logger.info(f"migration plan for organization {organization.pk}: got escalation_policies") - - for escalation_policy in escalation_policies: - if escalation_policy["route_id"] in routes_to_migrate_public_pk: - migration_plan[ESCALATIONS_POLICIES_COUNT] += 1 - - logger.info(f"migration plan for organization {organization.pk}: get schedules") - schedules = get_data_with_respect_to_pagination(api_token, "schedules") - logger.info(f"migration plan for organization {organization.pk}: got schedules") - - existing_schedules_names = set(organization.oncall_schedules.values_list("name", flat=True)) - for schedule in schedules: - if not schedule["ical_url"] or schedule["name"] in existing_schedules_names: - continue - migration_plan[CALENDARS_COUNT] += 1 - - return Response(migration_plan) - - -class MigrateAPIView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) - - def post(self, request): - api_token = request.data.get("token", None) - - if api_token is None: - raise BadRequest(detail="API token is required") - - organization = request.auth.organization - if organization.is_amixr_migration_started: - raise BadRequest(detail="Migration from Amixr has already been started") - # check token - response = requests.get(f"{REQUEST_URL}/users", headers={"AUTHORIZATION": api_token}) - if response.status_code == status.HTTP_403_FORBIDDEN: - raise BadRequest(detail="Invalid token") - - organization.is_amixr_migration_started = True - organization.save(update_fields=["is_amixr_migration_started"]) - - organization_id = organization.pk - user_id = request.user.pk - # start migration process - start_migration_from_old_amixr.delay(api_token=api_token, organization_id=organization_id, user_id=user_id) - return Response(status=status.HTTP_200_OK) - - -class MigrationStatusAPIView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) - - def get(self, request): - organization = request.auth.organization - migration_status = self.get_migration_status(organization) - endpoints_list = self.get_endpoints_list(organization) - return Response( - {"migration_status": migration_status, "endpoints_list": endpoints_list}, status=status.HTTP_200_OK - ) - - def get_migration_status(self, organization): - migration_status = NOT_STARTED - if organization.is_amixr_migration_started: - unfinished_tasks_exist = organization.migration_tasks.filter(is_finished=False).exists() - if unfinished_tasks_exist: - migration_status = IN_PROGRESS - else: - migration_status = FINISHED - return migration_status - - def get_endpoints_list(self, organization): - integrations = organization.alert_receive_channels.filter(team_id__isnull=True) - endpoints_list = [] - for integration in integrations: - integration_endpoint = f"{integration.verbal_name}, new endpoint: {integration.integration_url}" - endpoints_list.append(integration_endpoint) - return endpoints_list diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index c22a14ad..e7ee417d 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -358,7 +358,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): author_verbal = resolution_note.author_verbal(mention=True) resolution_note_text_block = { "type": "section", - "text": {"type": "plain_text", "text": resolution_note.text, "emoji": True}, + "text": {"type": "mrkdwn", "text": resolution_note.text}, } blocks.append(resolution_note_text_block) context_block = { diff --git a/engine/apps/telegram/models/verification/channel.py b/engine/apps/telegram/models/verification/channel.py index ef8e8b77..8d80f03a 100644 --- a/engine/apps/telegram/models/verification/channel.py +++ b/engine/apps/telegram/models/verification/channel.py @@ -23,26 +23,39 @@ class TelegramChannelVerificationCode(models.Model): def is_active(self) -> bool: return self.datetime + timezone.timedelta(days=1) < timezone.now() + @property + def uuid_with_org_id(self) -> str: + return f"{self.organization.public_primary_key}_{self.uuid}" + + @classmethod + def uuid_without_org_id(cls, verification_code: str) -> str: + try: + return verification_code.split("_")[1] + except IndexError: + raise ValidationError("Invalid verification code format") + @classmethod def verify_channel_and_discussion_group( cls, - uuid_code: str, + verification_code: str, channel_chat_id: int, channel_name: str, discussion_group_chat_id: int, discussion_group_name: str, ) -> Tuple[Optional[TelegramToOrganizationConnector], bool]: try: - verification_code = cls.objects.get(uuid=uuid_code) + uuid_code = cls.uuid_without_org_id(verification_code) + + code_instance = cls.objects.get(uuid=uuid_code) # see if a organization has other channels connected # if it is the first channel, make it default for the organization - connector_exists = verification_code.organization.telegram_channel.exists() + connector_exists = code_instance.organization.telegram_channel.exists() connector, created = TelegramToOrganizationConnector.objects.get_or_create( channel_chat_id=channel_chat_id, defaults={ - "organization": verification_code.organization, + "organization": code_instance.organization, "channel_name": channel_name, "discussion_group_chat_id": discussion_group_chat_id, "discussion_group_name": discussion_group_name, @@ -51,14 +64,14 @@ class TelegramChannelVerificationCode(models.Model): ) write_chatops_insight_log( - author=verification_code.author, + author=code_instance.author, event_name=ChatOpsEvent.CHANNEL_CONNECTED, chatops_type=ChatOpsType.TELEGRAM, channel_name=channel_name, ) if not connector_exists: write_chatops_insight_log( - author=verification_code.author, + author=code_instance.author, event_name=ChatOpsEvent.DEFAULT_CHANNEL_CHANGED, chatops_type=ChatOpsType.TELEGRAM, prev_channel=None, diff --git a/engine/apps/telegram/models/verification/personal.py b/engine/apps/telegram/models/verification/personal.py index 43823312..299c9993 100644 --- a/engine/apps/telegram/models/verification/personal.py +++ b/engine/apps/telegram/models/verification/personal.py @@ -21,13 +21,26 @@ class TelegramVerificationCode(models.Model): def is_active(self) -> bool: return self.datetime + timezone.timedelta(days=1) < timezone.now() + @property + def uuid_with_org_id(self) -> str: + return f"{self.user.organization.public_primary_key}_{self.uuid}" + + @classmethod + def uuid_without_org_id(cls, verification_code: str) -> str: + try: + return verification_code.split("_")[1] + except IndexError: + raise ValidationError("Invalid verification code format") + @classmethod def verify_user( - cls, uuid_code: str, telegram_chat_id: int, telegram_nick_name: str + cls, verification_code: str, telegram_chat_id: int, telegram_nick_name: str ) -> Tuple[Optional[TelegramToUserConnector], bool]: try: - verification_code = cls.objects.get(uuid=uuid_code) - user = verification_code.user + uuid_code = cls.uuid_without_org_id(verification_code) + code_instance = cls.objects.get(uuid=uuid_code) + + user = code_instance.user connector, created = TelegramToUserConnector.objects.get_or_create( user=user, defaults={"telegram_nick_name": telegram_nick_name, "telegram_chat_id": telegram_chat_id} diff --git a/engine/apps/telegram/renderers/keyboard.py b/engine/apps/telegram/renderers/keyboard.py index ed13cd9f..997f5473 100644 --- a/engine/apps/telegram/renderers/keyboard.py +++ b/engine/apps/telegram/renderers/keyboard.py @@ -83,7 +83,10 @@ class TelegramKeyboardRenderer: callback_data_args = [self.alert_group.pk, action.value] if action_data is not None: callback_data_args.append(action_data) - + # Add org id with 'x-oncall-org-id' prefix to callback data. + # It's a workaroung to pass org_id to the oncall-gateway while proxying requests. + # TODO: switch to json str instead of ':' separated string. + callback_data_args.append(f"x-oncall-org-id{self.alert_group.channel.organization.public_primary_key}") button = InlineKeyboardButton(text=text, callback_data=CallbackQueryFactory.encode_data(*callback_data_args)) return button diff --git a/engine/apps/telegram/tests/test_keyboard_renderer.py b/engine/apps/telegram/tests/test_keyboard_renderer.py index a3e614be..50d769d8 100644 --- a/engine/apps/telegram/tests/test_keyboard_renderer.py +++ b/engine/apps/telegram/tests/test_keyboard_renderer.py @@ -46,12 +46,31 @@ def test_actions_keyboard_alerting(make_organization, make_alert_receive_channel keyboard = renderer.render_actions_keyboard() expected_keyboard = [ - [InlineKeyboardButton(text="Acknowledge", callback_data=f"{alert_group.pk}:acknowledge")], - [InlineKeyboardButton(text="Resolve", callback_data=f"{alert_group.pk}:resolve")], [ - InlineKeyboardButton(text="🔕 forever", callback_data=f"{alert_group.pk}:silence"), - InlineKeyboardButton(text="... for 1h", callback_data=f"{alert_group.pk}:silence:3600"), - InlineKeyboardButton(text="... for 4h", callback_data=f"{alert_group.pk}:silence:14400"), + InlineKeyboardButton( + text="Acknowledge", + callback_data=f"{alert_group.pk}:acknowledge:x-oncall-org-id{organization.public_primary_key}", + ) + ], + [ + InlineKeyboardButton( + text="Resolve", + callback_data=f"{alert_group.pk}:resolve:x-oncall-org-id{organization.public_primary_key}", + ) + ], + [ + InlineKeyboardButton( + text="🔕 forever", + callback_data=f"{alert_group.pk}:silence:x-oncall-org-id{organization.public_primary_key}", + ), + InlineKeyboardButton( + text="... for 1h", + callback_data=f"{alert_group.pk}:silence:3600:x-oncall-org-id{organization.public_primary_key}", + ), + InlineKeyboardButton( + text="... for 4h", + callback_data=f"{alert_group.pk}:silence:14400:x-oncall-org-id{organization.public_primary_key}", + ), ], ] @@ -75,8 +94,18 @@ def test_actions_keyboard_acknowledged( keyboard = renderer.render_actions_keyboard() expected_keyboard = [ - [InlineKeyboardButton(text="Unacknowledge", callback_data=f"{alert_group.pk}:unacknowledge")], - [InlineKeyboardButton(text="Resolve", callback_data=f"{alert_group.pk}:resolve")], + [ + InlineKeyboardButton( + text="Unacknowledge", + callback_data=f"{alert_group.pk}:unacknowledge:x-oncall-org-id{organization.public_primary_key}", + ) + ], + [ + InlineKeyboardButton( + text="Resolve", + callback_data=f"{alert_group.pk}:resolve:x-oncall-org-id{organization.public_primary_key}", + ) + ], ] assert are_keyboards_equal(keyboard.inline_keyboard, expected_keyboard) is True @@ -99,7 +128,12 @@ def test_actions_keyboard_resolved( keyboard = renderer.render_actions_keyboard() expected_keyboard = [ - [InlineKeyboardButton(text="Unresolve", callback_data=f"{alert_group.pk}:unresolve")], + [ + InlineKeyboardButton( + text="Unresolve", + callback_data=f"{alert_group.pk}:unresolve:x-oncall-org-id{organization.public_primary_key}", + ) + ], ] assert are_keyboards_equal(keyboard.inline_keyboard, expected_keyboard) is True @@ -122,9 +156,24 @@ def test_actions_keyboard_silenced( keyboard = renderer.render_actions_keyboard() expected_keyboard = [ - [InlineKeyboardButton(text="Acknowledge", callback_data=f"{alert_group.pk}:acknowledge")], - [InlineKeyboardButton(text="Resolve", callback_data=f"{alert_group.pk}:resolve")], - [InlineKeyboardButton(text="Unsilence", callback_data=f"{alert_group.pk}:unsilence")], + [ + InlineKeyboardButton( + text="Acknowledge", + callback_data=f"{alert_group.pk}:acknowledge:x-oncall-org-id{organization.public_primary_key}", + ) + ], + [ + InlineKeyboardButton( + text="Resolve", + callback_data=f"{alert_group.pk}:resolve:x-oncall-org-id{organization.public_primary_key}", + ) + ], + [ + InlineKeyboardButton( + text="Unsilence", + callback_data=f"{alert_group.pk}:unsilence:x-oncall-org-id{organization.public_primary_key}", + ) + ], ] assert are_keyboards_equal(keyboard.inline_keyboard, expected_keyboard) is True diff --git a/engine/apps/telegram/tests/test_message_renderer.py b/engine/apps/telegram/tests/test_message_renderer.py index 44c1248e..7d800975 100644 --- a/engine/apps/telegram/tests/test_message_renderer.py +++ b/engine/apps/telegram/tests/test_message_renderer.py @@ -71,9 +71,8 @@ def test_alert_group_message(make_organization, make_alert_receive_channel, make renderer = TelegramMessageRenderer(alert_group=alert_group) text = renderer.render_alert_group_message() - assert text == ( - f"🔴 #{alert_group.inside_organization_number}, {alert_receive_channel.config.tests['telegram']['title']}\n" + f"🔴 #{alert_group.inside_organization_number}, {alert_receive_channel.config.tests['telegram']['title']}\n" "Alerting, alerts: 1\n" "Source: Test integration - Grafana\n" f"{alert_group.web_link}\n\n" @@ -157,7 +156,7 @@ def test_personal_message( text = renderer.render_personal_message() assert text == ( - f"🟠 #{alert_group.inside_organization_number}, {alert_receive_channel.config.tests['telegram']['title']}\n" + f"🟠 #{alert_group.inside_organization_number}, {alert_receive_channel.config.tests['telegram']['title']}\n" f"Acknowledged by {user_name}, alerts: 1\n" "Source: Test integration - Grafana\n" f"{alert_group.web_link}\n\n" diff --git a/engine/apps/telegram/tests/test_models.py b/engine/apps/telegram/tests/test_models.py index 173ac1a1..0a9497e4 100644 --- a/engine/apps/telegram/tests/test_models.py +++ b/engine/apps/telegram/tests/test_models.py @@ -17,7 +17,7 @@ def test_user_verification_handler_process_update_another_account_already_linked user_2 = make_user_for_organization(organization) code = make_telegram_verification_code(user_2) - connector, created = TelegramVerificationCode.verify_user(code.uuid, chat_id, "nickname") + connector, created = TelegramVerificationCode.verify_user(code.uuid_with_org_id, chat_id, "nickname") assert created assert connector.telegram_chat_id == chat_id @@ -38,7 +38,7 @@ def test_user_verification_handler_process_update_user_already_linked( other_chat_id = 321 code = make_telegram_verification_code(user_1) - connector, created = TelegramVerificationCode.verify_user(code.uuid, other_chat_id, "nickname") + connector, created = TelegramVerificationCode.verify_user(code.uuid_with_org_id, other_chat_id, "nickname") assert created is False assert connector.user == user_1 diff --git a/engine/apps/telegram/updates/update_handlers/button_press.py b/engine/apps/telegram/updates/update_handlers/button_press.py index 55a8580e..6afa11a5 100644 --- a/engine/apps/telegram/updates/update_handlers/button_press.py +++ b/engine/apps/telegram/updates/update_handlers/button_press.py @@ -70,7 +70,7 @@ class ButtonPressHandler(UpdateHandler): action_name = args[1] action = Action(action_name) - action_data = args[2] if len(args) >= 3 else None + action_data = args[2] if len(args) >= 3 and not args[2].startswith("x-oncall-org-id") else None return ActionContext(alert_group=alert_group, action=action, action_data=action_data) diff --git a/engine/apps/telegram/updates/update_handlers/start_message.py b/engine/apps/telegram/updates/update_handlers/start_message.py index f70ead7d..9acd0be6 100644 --- a/engine/apps/telegram/updates/update_handlers/start_message.py +++ b/engine/apps/telegram/updates/update_handlers/start_message.py @@ -1,15 +1,10 @@ from apps.telegram.client import TelegramClient -from apps.telegram.models import TelegramToUserConnector from apps.telegram.updates.update_handlers.update_handler import UpdateHandler START_TEXT = """Hi! This is Grafana OnCall notification bot. You can connect your Grafana OnCall account to Telegram on user settings page. """ -START_TEXT_FOR_CONNECTED_USER = """Hi! -This is Grafana OnCall notification bot. Your Telegram account is connected to user {username} -""" - class StartMessageHandler(UpdateHandler): def matches(self) -> bool: @@ -24,12 +19,5 @@ class StartMessageHandler(UpdateHandler): return is_from_private_chat and is_start_message def process_update(self) -> None: - connector = TelegramToUserConnector.objects.filter(telegram_chat_id=self.update.effective_user.id).first() telegram_client = TelegramClient() - - if connector is not None: - user = connector.user - text = START_TEXT_FOR_CONNECTED_USER.format(username=user.username) - telegram_client.send_raw_message(chat_id=self.update.effective_user.id, text=text) - else: - telegram_client.send_raw_message(chat_id=self.update.effective_user.id, text=START_TEXT) + telegram_client.send_raw_message(chat_id=self.update.effective_user.id, text=START_TEXT) diff --git a/engine/apps/telegram/updates/update_handlers/verification/channel.py b/engine/apps/telegram/updates/update_handlers/verification/channel.py index edd9f787..07debaa5 100644 --- a/engine/apps/telegram/updates/update_handlers/verification/channel.py +++ b/engine/apps/telegram/updates/update_handlers/verification/channel.py @@ -73,7 +73,7 @@ class ChannelVerificationCodeHandler(UpdateHandler): return connector, created = TelegramChannelVerificationCode.verify_channel_and_discussion_group( - uuid_code=verification_code, + verification_code=verification_code, channel_chat_id=channel_chat_id, channel_name=channel_name, discussion_group_chat_id=discussion_group_chat_id, diff --git a/engine/apps/telegram/updates/update_handlers/verification/personal.py b/engine/apps/telegram/updates/update_handlers/verification/personal.py index 6f17603f..0f30dfc5 100644 --- a/engine/apps/telegram/updates/update_handlers/verification/personal.py +++ b/engine/apps/telegram/updates/update_handlers/verification/personal.py @@ -33,7 +33,7 @@ class PersonalVerificationCodeHandler(UpdateHandler): verification_code = text if is_verification_message(text) else text.split()[1] connector, created = TelegramVerificationCode.verify_user( - uuid_code=verification_code, telegram_chat_id=user.id, telegram_nick_name=nickname + verification_code=verification_code, telegram_chat_id=user.id, telegram_nick_name=nickname ) if created: diff --git a/engine/apps/telegram/utils.py b/engine/apps/telegram/utils.py index 9568666e..18640c58 100644 --- a/engine/apps/telegram/utils.py +++ b/engine/apps/telegram/utils.py @@ -1,11 +1,11 @@ import re from typing import List, Union -UUID4_REGEX = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +TELEGRAM_VERIFICATION_CODE_REGEX = "^[A-Z0-9]*_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" def is_verification_message(text: str) -> bool: - return bool(re.match(UUID4_REGEX, text)) + return bool(re.match(TELEGRAM_VERIFICATION_CODE_REGEX, text)) class CallbackQueryFactory: diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index a6f76102..2561e3cb 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -245,6 +245,11 @@ class Organization(MaintainableObject): def web_link(self): return urljoin(self.grafana_url, "a/grafana-oncall-app/") + @property + def web_link_with_id(self): + # It's a workaround to pass org id to the oncall gateway while proxying telegram requests + return urljoin(self.grafana_url, f"a/grafana-oncall-app/?x-oncall-org-id={self.public_primary_key}") + def __str__(self): return f"{self.pk}: {self.org_title}" diff --git a/engine/engine/urls.py b/engine/engine/urls.py index e8080a7f..aeedbd4e 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -37,7 +37,6 @@ urlpatterns = [ path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")), path("twilioapp/", include("apps.twilioapp.urls")), path("api/v1/", include("apps.public_api.urls", namespace="api-public")), - path("api/internal/v1/", include("apps.migration_tool.urls", namespace="migration-tool")), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) if settings.FEATURE_SLACK_INTEGRATION_ENABLED: diff --git a/engine/settings/base.py b/engine/settings/base.py index 92e759ed..6235c3e3 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -201,7 +201,6 @@ INSTALLED_APPS = [ "apps.public_api", "apps.grafana_plugin", "apps.grafana_plugin_management", - "apps.migration_tool", "corsheaders", "debug_toolbar", "social_django", diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index 87f1dd00..9bfe88c0 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -9,18 +9,7 @@ module.exports = { '^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils', }, rules: { - 'no-unused-vars': ['warn', { vars: 'all', args: 'after-used', ignoreRestSiblings: false }], - 'react/prop-types': 'warn', - 'react/display-name': 'warn', - 'react/jsx-key': 'warn', - 'react-hooks/exhaustive-deps': 'off', - 'react/no-unescaped-entities': 'warn', - 'react/jsx-no-target-blank': 'warn', - 'react-hooks/exhaustive-deps': 'warn', - 'no-restricted-imports': 'warn', eqeqeq: 'warn', - 'no-duplicate-imports': 'error', - 'rulesdir/no-relative-import-paths': ['error', { allowSameFolder: true }], 'import/order': [ 'error', { @@ -47,5 +36,33 @@ module.exports = { 'newlines-between': 'always', }, ], + 'no-unused-vars': [ + 'warn', + { + vars: 'all', + args: 'after-used', + argsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + 'no-duplicate-imports': 'error', + 'no-restricted-imports': 'warn', + 'react/display-name': 'warn', + /** + * It appears as though the react/prop-types rule has a bug in it + * when your props extend an interface + * https://github.com/jsx-eslint/eslint-plugin-react/issues/3325 + */ + 'react/prop-types': 'off', + 'react/jsx-key': 'warn', + 'react/jsx-no-target-blank': 'warn', + 'react/no-unescaped-entities': 'off', + /** + * TODO: react-hooks/exhaustive-deps is temporarily disabled + * this will be turned back on, and the warnings fixed, in a forthcoming PR + */ + 'react-hooks/exhaustive-deps': 'off', + 'rulesdir/no-relative-import-paths': ['error', { allowSameFolder: true }], }, }; diff --git a/grafana-plugin/.stylelintrc b/grafana-plugin/.stylelintrc index 7cda5c91..c37d4c08 100644 --- a/grafana-plugin/.stylelintrc +++ b/grafana-plugin/.stylelintrc @@ -1,12 +1,14 @@ { - "extends": "stylelint-config-standard", - "rules": { - "block-no-empty": [true,{ "severity": "warning"}], + "extends": ["stylelint-config-standard", "stylelint-prettier/recommended"], + "plugins": ["stylelint-prettier"], + "rules": { + "block-no-empty": [true, { "severity": "warning" }], "selector-pseudo-class-no-unknown": [ true, { "ignorePseudoClasses": ["global"] } - ] + ], + "prettier/prettier": true } } diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md deleted file mode 100644 index ebfa6fb2..00000000 --- a/grafana-plugin/CHANGELOG.md +++ /dev/null @@ -1,165 +0,0 @@ -# Change Log - -## v1.0.39 (2022-10-03) - -- Fix issue in v1.0.38 blocking the creation of schedules and webhooks in the UI - -## v1.0.38 (2022-09-30) - -- Fix exception handling for adding resolution notes when slack and oncall users are out of sync. -- Fix all day events showing as having gaps in slack notifications -- Improve plugin configuration error message readability -- Add `telegram` key to `permalinks` property in `AlertGroup` public API response schema - -## v1.0.37 (2022-09-21) - -- Improve API token creation form -- Fix alert group bulk action bugs -- Add `permalinks` property to `AlertGroup` public API response schema -- Scheduling system bug fixes -- Public API bug fixes - -## v1.0.36 (2022-09-12) - -- Alpha web schedules frontend/backend updates -- Bug fixes - -## v1.0.35 (2022-09-07) - -- Bug fixes - -## v1.0.34 (2022-09-06) - -- Fix schedule notification spam - -## v1.0.33 (2022-09-06) - -- Add raw alert view -- Add GitHub star button for OSS installations -- Restore alert group search functionality -- Bug fixes - -## v1.0.32 (2022-09-01) - -- Bug fixes - -## v1.0.31 (2022-09-01) - -- Bump celery version -- Fix oss to cloud connection - -## v1.0.30 (2022-08-31) - -- Bug fix: check user notification policy before access - -## v1.0.29 (2022-08-31) - -- Add arm64 docker image - -## v1.0.28 (2022-08-31) - -- Bug fixes - -## v1.0.27 (2022-08-30) - -- Bug fixes - -## v1.0.26 (2022-08-26) - -- Insight log's format fixes -- Remove UserNotificationPolicy auto-recreating - -## v1.0.25 (2022-08-24) - -- Bug fixes - -## v1.0.24 (2022-08-24) - -- Insight logs -- Default DATA_UPLOAD_MAX_MEMORY_SIZE to 1mb - -## v1.0.23 (2022-08-23) - -- Bug fixes - -## 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 - -## 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 outgoing webhook instance - -## 1.0.0 (2022-06-14) - -- First Public Release - -## 0.0.71 (2022-06-06) - -- Initial Commit Release diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md new file mode 120000 index 00000000..04c99a55 --- /dev/null +++ b/grafana-plugin/CHANGELOG.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 021f0224..9398e5c9 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "Grafana OnCall Plugin", "scripts": { - "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx ./src", - "lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --quiet ./src", + "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src", + "lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src", "stylelint": "stylelint ./src/**/*.css", "stylelint:fix": "stylelint --fix ./src/**/*.css", "build": "grafana-toolkit plugin:build", @@ -52,6 +52,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@grafana/data": "^9.1.1", + "@grafana/eslint-config": "^5.0.0", "@grafana/runtime": "^9.1.1", "@grafana/toolkit": "^9.1.1", "@grafana/ui": "^9.1.1", @@ -67,8 +68,13 @@ "@types/react-router-dom": "^5.3.3", "@types/react-test-renderer": "^17.0.2", "@types/throttle-debounce": "^5.0.0", + "@typescript-eslint/eslint-plugin": "^5.40.1", "copy-webpack-plugin": "^11.0.0", "dompurify": "^2.3.12", + "eslint": "^8.25.0", + "eslint-plugin-jsdoc": "^39.3.14", + "eslint-plugin-react": "^7.31.10", + "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-rulesdir": "^0.2.1", "jest": "^27.5.1", "jest-environment-jsdom": "^27.5.1", @@ -78,8 +84,11 @@ "plop": "^2.7.4", "postcss-loader": "^7.0.1", "react-test-renderer": "^17.0.2", + "stylelint-config-prettier": "^9.0.3", + "stylelint-prettier": "^2.0.0", "ts-jest": "^27.1.3", "ts-loader": "^9.3.1", + "typescript": "4.6.4", "webpack-bundle-analyzer": "^4.6.1" }, "engines": { diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index 4e3f6467..ed69df65 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo } from 'react'; import { AppRootProps } from '@grafana/data'; -import { Button, HorizontalGroup, LinkButton, VerticalGroup } from '@grafana/ui'; +import { Button, HorizontalGroup, LinkButton } from '@grafana/ui'; import dayjs from 'dayjs'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; @@ -30,8 +30,6 @@ dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrAfter); dayjs.extend(isoWeek); -// dayjs().weekday(0); - import './style/vars.css'; import './style/index.css'; diff --git a/grafana-plugin/src/README.md b/grafana-plugin/src/README.md index 66e4b915..b3c76bb1 100644 --- a/grafana-plugin/src/README.md +++ b/grafana-plugin/src/README.md @@ -3,6 +3,7 @@ Developer-Friendly Alert Management with Brilliant Slack Integration + - Connect monitoring systems - Collect and analyze data - On-call rotation @@ -10,5 +11,6 @@ with Brilliant Slack Integration - Never miss alerts with calls and SMS ## Documentation + - [On Github](http://github.com/grafana/oncall) - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.helper.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.helper.tsx index e2e01b17..d7bad53f 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.helper.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.helper.tsx @@ -10,7 +10,3 @@ export function getLabelFromTemplateName(templateName: string, group: any) { } return arrayWithNeededValues.join(' '); } - -export function includeTemplateGroup(groupName: string) { - return true; -} diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index eaecbbfd..782ea07a 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -8,8 +8,7 @@ import cn from 'classnames/bind'; import { omit } from 'lodash-es'; import { templatesToRender, Template } from 'components/AlertTemplates/AlertTemplatesForm.config'; -import { getLabelFromTemplateName, includeTemplateGroup } from 'components/AlertTemplates/AlertTemplatesForm.helper'; -import Collapse from 'components/Collapse/Collapse'; +import { getLabelFromTemplateName } from 'components/AlertTemplates/AlertTemplatesForm.helper'; import Block from 'components/GBlock/Block'; import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor'; import SourceCode from 'components/SourceCode/SourceCode'; @@ -41,7 +40,6 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { const { onUpdateTemplates, templates, - errors, alertReceiveChannelId, alertGroupId, demoAlertEnabled, @@ -53,6 +51,8 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { const [tempValues, setTempValues] = useState<{ [key: string]: string | null; }>({}); + const [activeGroup, setActiveGroup] = useState(); + const [activeTemplate, setActiveTemplate] = useState