diff --git a/.drone.yml b/.drone.yml index 21678bc6..956acfef 100644 --- a/.drone.yml +++ b/.drone.yml @@ -53,7 +53,7 @@ steps: - name: Lint Backend image: python:3.11.4 environment: - DJANGO_SETTINGS_MODULE: settings.ci-test + DJANGO_SETTINGS_MODULE: settings.ci_test commands: - pip install $(grep "pre-commit==" engine/requirements-dev.txt) - pre-commit run isort --all-files @@ -64,7 +64,7 @@ steps: image: python:3.11.4 environment: RABBITMQ_URI: amqp://rabbitmq:rabbitmq@rabbit_test:5672 - DJANGO_SETTINGS_MODULE: settings.ci-test + DJANGO_SETTINGS_MODULE: settings.ci_test SLACK_CLIENT_OAUTH_ID: 1 commands: - apt-get update && apt-get install -y netcat-traditional @@ -386,4 +386,4 @@ name: cloud_access_policy_token --- kind: signature -hmac: 7bf9c1d378bf2a93cb758436de78878f9a49a8501b5d1b199c412198439d3593 +hmac: c3043848d6057dfa6fb59d49459af1cbc0d013a697fd84a1329c444a6beb8ce1 diff --git a/.github/actions/install-frontend-dependencies/action.yml b/.github/actions/install-frontend-dependencies/action.yml new file mode 100644 index 00000000..12904632 --- /dev/null +++ b/.github/actions/install-frontend-dependencies/action.yml @@ -0,0 +1,38 @@ +name: "Install frontend dependencies" +description: "Setup node + install frontend dependencies" +inputs: + working-directory: + description: "Relative path to oncall/grafana-plugin directory" + required: false + default: "." +runs: + using: "composite" + steps: + - name: Determine grafana-plugin directory location + id: grafana-plugin-directory + shell: bash + run: echo "grafana-plugin-directory=${{ inputs.working-directory }}/grafana-plugin" >> $GITHUB_OUTPUT + - name: Determine yarn.lock location + id: yarn-lock-location + shell: bash + # yamllint disable rule:line-length + run: echo "yarn-lock-location=${{ steps.grafana-plugin-directory.outputs.grafana-plugin-directory }}/yarn.lock" >> $GITHUB_OUTPUT + # yamllint enable rule:line-length + - uses: actions/setup-node@v3 + with: + node-version: 18.16.0 + cache: "yarn" + cache-dependency-path: ${{ steps.yarn-lock-location.outputs.yarn-lock-location }} + - name: Use cached frontend dependencies + id: cache-frontend-dependencies + uses: actions/cache@v3 + with: + path: ${{ inputs.working-directory }}/grafana-plugin/node_modules + # yamllint disable rule:line-length + key: ${{ runner.os }}-frontend-node-modules-${{ hashFiles(steps.yarn-lock-location.outputs.yarn-lock-location) }} + # yamllint enable rule:line-length + - name: Install frontend dependencies + if: steps.cache-frontend-dependencies.outputs.cache-hit != 'true' + shell: bash + working-directory: ${{ steps.grafana-plugin-directory.outputs.grafana-plugin-directory }} + run: yarn install --frozen-lockfile --prefer-offline --network-timeout 500000 diff --git a/.github/actions/setup-python/action.yml b/.github/actions/setup-python/action.yml new file mode 100644 index 00000000..54d9fbb0 --- /dev/null +++ b/.github/actions/setup-python/action.yml @@ -0,0 +1,27 @@ +name: "Setup Python" +description: "Setup Python + optionally install dependencies from a set of requirements file(s)" +inputs: + install-dependencies: + description: "Whether to install dependencies from the Python requirements file(s)" + required: false + default: "true" + python-requirements-paths: + description: "The path(s) to the Python requirements file(s) to install" + required: false + default: "engine/requirements.txt engine/requirements-dev.txt" +runs: + using: "composite" + steps: + - name: Setup Python + id: setup-python + uses: actions/setup-python@v4 + with: + python-version: "3.11.4" + cache: "pip" + cache-dependency-path: ${{ inputs.python-requirements-paths }} + - name: Install Python dependencies + if: ${{ inputs.install-dependencies == 'true' }} + shell: bash + run: | + pip install uv + uv pip sync --system ${{ inputs.python-requirements-paths }} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index adccedcf..b996a7f6 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -59,11 +59,8 @@ jobs: config: ./dev/kind.yml install_only: true - - uses: actions/setup-node@v3 - with: - node-version: 18.16.0 - cache: "yarn" - cache-dependency-path: grafana-plugin/yarn.lock + - name: Install frontend dependencies + uses: ./.github/actions/install-frontend-dependencies - name: Install Tilt run: | @@ -76,18 +73,6 @@ jobs: curl -fsSL https://github.com/tilt-dev/ctlptl/releases/download/v$CTLPTL_VERSION/$CTLPTL_FILE_NAME | \ tar -xzv -C /usr/local/bin ctlptl - - name: Use cached frontend dependencies - id: cache-frontend-dependencies - uses: actions/cache@v3 - with: - path: grafana-plugin/node_modules - key: ${{ runner.os }}-frontend-node-modules-${{ hashFiles('grafana-plugin/yarn.lock') }} - - - name: Install frontend dependencies - if: steps.cache-frontend-dependencies.outputs.cache-hit != 'true' - working-directory: grafana-plugin - run: yarn install --frozen-lockfile --prefer-offline --network-timeout 500000 - - name: Use cached plugin frontend build id: cache-plugin-frontend uses: actions/cache@v3 diff --git a/.github/workflows/expensive-e2e-tests.yml b/.github/workflows/expensive-e2e-tests.yml index 5bda45e3..16090caf 100644 --- a/.github/workflows/expensive-e2e-tests.yml +++ b/.github/workflows/expensive-e2e-tests.yml @@ -44,7 +44,7 @@ jobs: post-status-to-slack: runs-on: ubuntu-latest needs: end-to-end-tests - if: failure + if: failure() steps: # Useful references # https://stackoverflow.com/questions/59073850/github-actions-get-url-of-test-build diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 188a22d3..2a9f3397 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -11,6 +11,12 @@ name: Linting and Tests # https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue#triggering-merge-group-checks-with-github-actions merge_group: +env: + DJANGO_SETTINGS_MODULE: settings.ci_test + DATABASE_HOST: localhost + RABBITMQ_URI: amqp://rabbitmq:rabbitmq@localhost:5672 + SLACK_CLIENT_OAUTH_ID: 1 + concurrency: # Cancel any running workflow for the same branch when new commits are pushed. # We group both by ref_name (available when CI is triggered by a push to a branch/tag) @@ -23,52 +29,24 @@ jobs: name: "Lint entire project" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - name: Checkout project + uses: actions/checkout@v3 + - name: Setup Python + uses: ./.github/actions/setup-python with: - python-version: "3.11.4" - cache: "pip" - cache-dependency-path: | - engine/requirements.txt - engine/requirements-dev.txt - # following 2 steps - need to install the frontend dependencies for the eslint/prettier/stylelint steps - - uses: actions/setup-node@v3 - with: - node-version: 18.16.0 - cache: "yarn" - cache-dependency-path: grafana-plugin/yarn.lock - - name: Use cached frontend dependencies - id: cache-frontend-dependencies - uses: actions/cache@v3 - with: - path: grafana-plugin/node_modules - key: ${{ runner.os }}-frontend-node-modules-${{ hashFiles('grafana-plugin/yarn.lock') }} + install-dependencies: "false" - name: Install frontend dependencies - if: steps.cache-frontend-dependencies.outputs.cache-hit != 'true' - working-directory: grafana-plugin - run: yarn install --frozen-lockfile --prefer-offline --network-timeout 500000 + uses: ./.github/actions/install-frontend-dependencies - uses: pre-commit/action@v3.0.0 lint-test-and-build-frontend: name: "Lint, test, and build frontend" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: 18.16.0 - cache: "yarn" - cache-dependency-path: grafana-plugin/yarn.lock - - name: Use cached frontend dependencies - id: cache-frontend-dependencies - uses: actions/cache@v3 - with: - path: grafana-plugin/node_modules - key: ${{ runner.os }}-frontend-node-modules-${{ hashFiles('grafana-plugin/yarn.lock') }} + - name: Checkout project + uses: actions/checkout@v3 - name: Install frontend dependencies - if: steps.cache-frontend-dependencies.outputs.cache-hit != 'true' - working-directory: grafana-plugin - run: yarn install --frozen-lockfile --prefer-offline --network-timeout 500000 + uses: ./.github/actions/install-frontend-dependencies - name: Build, lint and test frontend working-directory: grafana-plugin run: yarn lint && yarn test && yarn build @@ -93,11 +71,6 @@ jobs: lint-migrations-backend-mysql-rabbitmq: name: "Lint database migrations" runs-on: ubuntu-latest - env: - DATABASE_HOST: localhost - RABBITMQ_URI: amqp://rabbitmq:rabbitmq@localhost:5672 - DJANGO_SETTINGS_MODULE: settings.ci-test - SLACK_CLIENT_OAUTH_ID: 1 services: rabbit_test: image: rabbitmq:3.12.0 @@ -114,21 +87,15 @@ jobs: ports: - 3306:3306 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.11.4" - cache: "pip" - cache-dependency-path: | - engine/requirements.txt - engine/requirements-dev.txt + - name: Checkout project + uses: actions/checkout@v3 + - name: Setup Python + uses: ./.github/actions/setup-python - name: Lint migrations working-directory: engine # makemigrations --check = Exit with a non-zero status if model changes are missing migrations # and don't actually write them. run: | - pip install uv - uv pip sync --system requirements.txt requirements-dev.txt python manage.py makemigrations --check python manage.py lintmigrations @@ -136,7 +103,8 @@ jobs: name: "Helm Chart Unit Tests" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout project + uses: actions/checkout@v3 - uses: azure/setup-helm@v3 with: version: v3.8.0 @@ -152,10 +120,6 @@ jobs: matrix: rbac_enabled: ["True", "False"] env: - DJANGO_SETTINGS_MODULE: settings.ci-test - DATABASE_HOST: localhost - RABBITMQ_URI: amqp://rabbitmq:rabbitmq@localhost:5672 - SLACK_CLIENT_OAUTH_ID: 1 ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }} services: rabbit_test: @@ -173,21 +137,13 @@ jobs: ports: - 3306:3306 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.11.4" - cache: "pip" - cache-dependency-path: | - engine/requirements.txt - engine/requirements-dev.txt + - name: Checkout project + uses: actions/checkout@v3 + - name: Setup Python + uses: ./.github/actions/setup-python - name: Unit Test Backend working-directory: engine - run: | - apt-get update && apt-get install -y netcat-traditional - pip install uv - uv pip sync --system requirements.txt requirements-dev.txt - ./wait_for_test_mysql_start.sh && pytest -x + run: ./wait_for_test_mysql_start.sh && pytest -x unit-test-backend-postgresql-rabbitmq: name: "Backend Tests: PostgreSQL + RabbitMQ (RBAC enabled: ${{ matrix.rbac_enabled }})" @@ -197,10 +153,6 @@ jobs: rbac_enabled: ["True", "False"] env: DATABASE_TYPE: postgresql - DATABASE_HOST: localhost - RABBITMQ_URI: amqp://rabbitmq:rabbitmq@localhost:5672 - DJANGO_SETTINGS_MODULE: settings.ci-test - SLACK_CLIENT_OAUTH_ID: 1 ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }} services: rabbit_test: @@ -224,20 +176,13 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.11.4" - cache: "pip" - cache-dependency-path: | - engine/requirements.txt - engine/requirements-dev.txt + - name: Checkout project + uses: actions/checkout@v3 + - name: Setup Python + uses: ./.github/actions/setup-python - name: Unit Test Backend working-directory: engine - run: | - pip install uv - uv pip sync --system requirements.txt requirements-dev.txt - pytest -x + run: pytest -x unit-test-backend-sqlite-redis: name: "Backend Tests: SQLite + Redis (RBAC enabled: ${{ matrix.rbac_enabled }})" @@ -249,8 +194,6 @@ jobs: DATABASE_TYPE: sqlite3 BROKER_TYPE: redis REDIS_URI: redis://localhost:6379 - DJANGO_SETTINGS_MODULE: settings.ci-test - SLACK_CLIENT_OAUTH_ID: 1 ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }} services: redis_test: @@ -263,57 +206,39 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.11.4" - cache: "pip" - cache-dependency-path: | - engine/requirements.txt - engine/requirements-dev.txt + - name: Checkout project + uses: actions/checkout@v3 + - name: Setup Python + uses: ./.github/actions/setup-python - name: Unit Test Backend working-directory: engine - run: | - apt-get update && apt-get install -y netcat-traditional - pip install uv - uv pip sync --system requirements.txt requirements-dev.txt - pytest -x + run: pytest -x unit-test-migrators: name: "Unit tests - Migrators" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - name: Checkout project + uses: actions/checkout@v3 + - name: Setup Python + uses: ./.github/actions/setup-python with: - python-version: "3.11.4" - cache: "pip" - cache-dependency-path: tools/migrators/requirements.txt + python-requirements-paths: tools/migrators/requirements.txt - name: Unit Test Migrators working-directory: tools/migrators - run: | - pip install uv - uv pip sync --system requirements.txt - pytest -x + run: pytest -x mypy: name: "mypy" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.11.4" - cache: "pip" - cache-dependency-path: | - engine/requirements.txt - engine/requirements-dev.txt + - name: Checkout project + uses: actions/checkout@v3 + - name: Setup Python + uses: ./.github/actions/setup-python - name: mypy Static Type Checking working-directory: engine - run: | - pip install uv - uv pip sync --system requirements.txt requirements-dev.txt - mypy . + run: mypy . end-to-end-tests: name: Standard e2e tests diff --git a/Makefile b/Makefile index 46ee7d89..fa4f5a1a 100644 --- a/Makefile +++ b/Makefile @@ -108,10 +108,10 @@ define run_ui_docker_command $(call run_docker_compose_command,run --rm oncall_ui sh -c '$(1)') endef -# always use settings.ci-test django settings file when running the tests +# always use settings.ci_test django settings file when running the tests # if we use settings.dev it's very possible that some fail just based on the settings alone define run_backend_tests - $(call run_engine_docker_command,pytest --ds=settings.ci-test $(1)) + $(call run_engine_docker_command,pytest --ds=settings.ci_test $(1)) endef .PHONY: local/up diff --git a/docs/sources/configure/escalation-chains-and-routes/index.md b/docs/sources/configure/escalation-chains-and-routes/index.md index da799119..8db2359f 100644 --- a/docs/sources/configure/escalation-chains-and-routes/index.md +++ b/docs/sources/configure/escalation-chains-and-routes/index.md @@ -83,8 +83,10 @@ from an on-call schedule. * `Notify all users from a team` - send a notification to all users in a team. * `Resolve incident automatically` - resolve the alert group right now with status `Resolved automatically`. -* `Notify whole slack channel` - send a notification to the users in the slack channel. -* `Notify Slack User Group` - send a notification to each member of a slack user group. +* `Notify whole slack channel` - send a notification to the users in the slack channel. These users will be notified +via the method configured in their user profile. +* `Notify Slack User Group` - send a notification to each member of a slack user group. These users will be notified +via the method configured in their user profile. * `Trigger outgoing webhook` - trigger an [outgoing webhook]. * `Notify users one by one (round robin)` - each notification will be sent to a group of users one by one, in sequential order in [round robin fashion](https://en.wikipedia.org/wiki/Round-robin_item_allocation). diff --git a/engine/apps/alerts/tasks/notify_all.py b/engine/apps/alerts/tasks/notify_all.py index 0b29837c..30b7c1d4 100644 --- a/engine/apps/alerts/tasks/notify_all.py +++ b/engine/apps/alerts/tasks/notify_all.py @@ -22,7 +22,13 @@ def notify_all_task(alert_group_pk, escalation_policy_snapshot_order=None): escalation_snapshot = alert_group.escalation_snapshot try: - escalation_policy_snapshot = escalation_snapshot.escalation_policies_snapshots[escalation_policy_snapshot_order] + # escalation_policy_snapshot_order refers to order as defined in the policy, + # which is unique but not necessarily sequential and may not start from zero + escalation_policy_snapshot = [ + policy + for policy in escalation_snapshot.escalation_policies_snapshots + if policy.order == escalation_policy_snapshot_order + ][0] except IndexError: escalation_policy_snapshot = None diff --git a/engine/apps/alerts/tasks/notify_group.py b/engine/apps/alerts/tasks/notify_group.py index efaee18d..0d95e528 100644 --- a/engine/apps/alerts/tasks/notify_group.py +++ b/engine/apps/alerts/tasks/notify_group.py @@ -34,7 +34,13 @@ def notify_group_task(alert_group_pk, escalation_policy_snapshot_order=None): escalation_snapshot = alert_group.escalation_snapshot try: - escalation_policy_snapshot = escalation_snapshot.escalation_policies_snapshots[escalation_policy_snapshot_order] + # escalation_policy_snapshot_order refers to order as defined in the policy, + # which is unique but not necessarily sequential and may not start from zero + escalation_policy_snapshot = [ + policy + for policy in escalation_snapshot.escalation_policies_snapshots + if policy.order == escalation_policy_snapshot_order + ][0] except IndexError: escalation_policy_snapshot = None diff --git a/engine/apps/alerts/tests/test_notify_all.py b/engine/apps/alerts/tests/test_notify_all.py new file mode 100644 index 00000000..a513472f --- /dev/null +++ b/engine/apps/alerts/tests/test_notify_all.py @@ -0,0 +1,78 @@ +from unittest.mock import patch + +import pytest + +from apps.alerts.models import EscalationPolicy +from apps.alerts.tasks.notify_all import notify_all_task +from apps.base.models.user_notification_policy import UserNotificationPolicy + + +@pytest.mark.django_db +def test_notify_all( + make_organization, + make_slack_team_identity, + make_user, + make_user_notification_policy, + make_escalation_chain, + make_escalation_policy, + make_channel_filter, + make_alert_receive_channel, + make_alert_group, +): + organization = make_organization() + slack_team_identity = make_slack_team_identity() + organization.slack_team_identity = slack_team_identity + organization.save() + + user = make_user(organization=organization) + make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.SMS, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + + escalation_chain = make_escalation_chain(organization) + channel_filter = make_channel_filter( + alert_receive_channel, + escalation_chain=escalation_chain, + notify_in_slack=True, + slack_channel_id="slack-channel-id", + ) + # note this is the only escalation step, with order=1 + notify_all = make_escalation_policy( + order=1, + escalation_chain=channel_filter.escalation_chain, + escalation_policy_step=EscalationPolicy.STEP_FINAL_NOTIFYALL, + ) + alert_group = make_alert_group(alert_receive_channel=alert_receive_channel, channel_filter=channel_filter) + # build escalation snapshot + alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot() + alert_group.save() + + with patch( + "apps.slack.models.SlackTeamIdentity.get_users_from_slack_conversation_for_organization" + ) as mock_get_users: + mock_get_users.return_value = [user] + with patch("apps.alerts.tasks.notify_all.notify_user_task") as mock_notify_user_task: + notify_all_task(alert_group.pk, escalation_policy_snapshot_order=1) + + alert_group.refresh_from_db() + + # check triggered log + log_record = alert_group.log_records.last() + assert log_record.type == log_record.TYPE_ESCALATION_TRIGGERED + assert log_record.author == user + assert log_record.escalation_policy == notify_all + assert log_record.escalation_policy_step == EscalationPolicy.STEP_FINAL_NOTIFYALL + + # check user is notified + mock_notify_user_task.apply_async.assert_called_once_with( + args=(user.pk, alert_group.pk), + kwargs={"reason": "notifying everyone in the channel", "prevent_posting_to_thread": True}, + countdown=0, + ) + + escalation_snapshot = alert_group.escalation_snapshot + assert escalation_snapshot is not None + assert escalation_snapshot.escalation_policies_snapshots[0].notify_to_users_queue == [user] diff --git a/engine/apps/alerts/tests/test_notify_group.py b/engine/apps/alerts/tests/test_notify_group.py new file mode 100644 index 00000000..e03acebb --- /dev/null +++ b/engine/apps/alerts/tests/test_notify_group.py @@ -0,0 +1,84 @@ +from unittest.mock import patch + +import pytest + +from apps.alerts.models import EscalationPolicy +from apps.alerts.tasks.notify_group import notify_group_task +from apps.base.models.user_notification_policy import UserNotificationPolicy + + +@pytest.mark.django_db +def test_notify_group( + make_organization, + make_slack_team_identity, + make_user, + make_user_notification_policy, + make_escalation_chain, + make_escalation_policy, + make_channel_filter, + make_slack_user_group, + make_alert_receive_channel, + make_alert_group, +): + organization = make_organization() + slack_team_identity = make_slack_team_identity() + organization.slack_team_identity = slack_team_identity + organization.save() + + user = make_user(organization=organization) + # remove default email escalation policies + user.notification_policies.all().delete() + make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.SMS, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + + escalation_chain = make_escalation_chain(organization) + channel_filter = make_channel_filter( + alert_receive_channel, + escalation_chain=escalation_chain, + notify_in_slack=True, + slack_channel_id="slack-channel-id", + ) + usergroup = make_slack_user_group(slack_team_identity) + # note this is the only escalation step, with order=1 + notify_group = make_escalation_policy( + order=1, + escalation_chain=channel_filter.escalation_chain, + escalation_policy_step=EscalationPolicy.STEP_NOTIFY_GROUP, + notify_to_group=usergroup, + ) + alert_group = make_alert_group(alert_receive_channel=alert_receive_channel, channel_filter=channel_filter) + # build escalation snapshot + alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot() + alert_group.save() + + with patch("apps.slack.models.SlackUserGroup.get_users_from_members_for_organization") as mock_get_users: + mock_get_users.return_value = [user] + with patch("apps.alerts.tasks.notify_group.notify_user_task") as mock_notify_user_task: + notify_group_task(alert_group.pk, escalation_policy_snapshot_order=1) + + alert_group.refresh_from_db() + + # check triggered log + log_record = alert_group.log_records.last() + assert log_record.type == log_record.TYPE_ESCALATION_TRIGGERED + assert log_record.escalation_policy == notify_group + assert log_record.escalation_policy_step == EscalationPolicy.STEP_NOTIFY_GROUP + assert log_record.step_specific_info == {"usergroup_handle": usergroup.handle} + + # check user is notified + mock_notify_user_task.apply_async.assert_called_once_with( + args=(user.pk, alert_group.pk), + kwargs={ + "reason": f"Membership in User Group", + "prevent_posting_to_thread": True, + "important": False, + }, + ) + + escalation_snapshot = alert_group.escalation_snapshot + assert escalation_snapshot is not None + assert escalation_snapshot.escalation_policies_snapshots[0].notify_to_users_queue == [user] diff --git a/engine/apps/api/tests/test_user_groups.py b/engine/apps/api/tests/test_user_groups.py index 28176b56..c24bb3f7 100644 --- a/engine/apps/api/tests/test_user_groups.py +++ b/engine/apps/api/tests/test_user_groups.py @@ -72,3 +72,30 @@ def test_usergroup_permissions( response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == expected_status + + +@pytest.mark.django_db +def test_get_usergroup( + make_slack_team_identity, + make_slack_user_group, + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, +): + team_identity = make_slack_team_identity() + user_group = make_slack_user_group( + slack_team_identity=team_identity, name="Test User Group", handle="test-user-group" + ) + + organization = make_organization(slack_team_identity=team_identity) + _, token = make_token_for_organization(organization=organization) + user = make_user_for_organization(organization=organization) + + client = APIClient() + url = reverse("api-internal:user_group-detail", kwargs={"pk": user_group.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + expected_data = {"id": user_group.public_primary_key, "name": "Test User Group", "handle": "test-user-group"} + + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_data diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 1c6ce144..57a10ecf 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -638,6 +638,7 @@ class AlertGroupView( choices=[display_name for _, display_name in AlertGroup.SILENCE_DELAY_OPTIONS] ), }, + many=True, ) ) @action(methods=["get"], detail=False) diff --git a/engine/apps/api/views/user_group.py b/engine/apps/api/views/user_group.py index 296fcf1e..31ccfea8 100644 --- a/engine/apps/api/views/user_group.py +++ b/engine/apps/api/views/user_group.py @@ -6,9 +6,12 @@ from apps.api.permissions import RBACPermission from apps.api.serializers.user_group import UserGroupSerializer from apps.auth_token.auth import PluginAuthentication from apps.slack.models import SlackUserGroup +from common.api_helpers.mixins import PublicPrimaryKeyMixin -class UserGroupViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +class UserGroupViewSet( + PublicPrimaryKeyMixin[SlackUserGroup], mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): authentication_classes = (PluginAuthentication,) permission_classes = (IsAuthenticated, RBACPermission) serializer_class = UserGroupSerializer diff --git a/engine/apps/grafana_plugin/tests/test_app_config.py b/engine/apps/grafana_plugin/tests/test_app_config.py index d7aa5e71..80ba9f26 100644 --- a/engine/apps/grafana_plugin/tests/test_app_config.py +++ b/engine/apps/grafana_plugin/tests/test_app_config.py @@ -19,6 +19,7 @@ app_name = "grafana_plugin" ) @patch.object(sys, "exit") @override_settings(LICENSE=settings.OPEN_SOURCE_LICENSE_NAME) +@override_settings(IS_OPEN_SOURCE=True) @override_settings(SELF_HOSTED_SETTINGS={"GRAFANA_API_URL": None}) @pytest.mark.django_db def test_it_crashes_the_app_if_the_env_var_is_not_present_for_oss_installations_and_an_org_does_not_exist( diff --git a/engine/apps/mobile_app/tests/test_alert_rendering.py b/engine/apps/mobile_app/tests/test_alert_rendering.py index 6b8c39d9..f96df843 100644 --- a/engine/apps/mobile_app/tests/test_alert_rendering.py +++ b/engine/apps/mobile_app/tests/test_alert_rendering.py @@ -8,7 +8,7 @@ from apps.mobile_app.backend import MobileAppBackend MAX_ALERT_TITLE_LENGTH = 200 -# this is a dirty hack to get around EXTRA_MESSAGING_BACKENDS being set in settings/ci-test.py +# this is a dirty hack to get around EXTRA_MESSAGING_BACKENDS being set in settings/ci_test.py # we can't simply change the value because 100s of tests fail as they rely on the value being set to a specific value 🫠 # see where this value is used in the unitest.mock.patch calls down below for more context backend = MobileAppBackend(notification_channel_id=5) diff --git a/engine/apps/slack/alert_group_slack_service.py b/engine/apps/slack/alert_group_slack_service.py index dfdd7018..9bb9510b 100644 --- a/engine/apps/slack/alert_group_slack_service.py +++ b/engine/apps/slack/alert_group_slack_service.py @@ -3,6 +3,7 @@ import typing from apps.slack.client import SlackClient from apps.slack.errors import ( + SlackAPICantUpdateMessageError, SlackAPIChannelArchivedError, SlackAPIChannelInactiveError, SlackAPIChannelNotFoundError, @@ -56,6 +57,7 @@ class AlertGroupSlackService: raise except ( SlackAPIMessageNotFoundError, + SlackAPICantUpdateMessageError, SlackAPIChannelInactiveError, SlackAPITokenError, SlackAPIChannelNotFoundError, diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index af291729..df45602f 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -9,6 +9,7 @@ from django.utils.text import Truncator from apps.api.permissions import RBACPermission from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE, DIVIDER from apps.slack.errors import ( + SlackAPICantUpdateMessageError, SlackAPIChannelArchivedError, SlackAPIChannelInactiveError, SlackAPIChannelNotFoundError, @@ -43,6 +44,7 @@ logger.setLevel(logging.DEBUG) RESOLUTION_NOTE_EXCEPTIONS = ( SlackAPIChannelNotFoundError, SlackAPIMessageNotFoundError, + SlackAPICantUpdateMessageError, SlackAPIChannelArchivedError, SlackAPIInvalidAuthError, SlackAPITokenError, diff --git a/engine/apps/user_management/migrations/0022_alter_team_unique_together.py b/engine/apps/user_management/migrations/0022_alter_team_unique_together.py new file mode 100644 index 00000000..d3eca7bd --- /dev/null +++ b/engine/apps/user_management/migrations/0022_alter_team_unique_together.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.11 on 2024-05-24 16:22 +import logging + +from django.db import migrations, models +import django_migration_linter as linter + +logger = logging.getLogger(__name__) + + +def clean_up_duplicated_teams(apps, schema_editor): + Team = apps.get_model("user_management", "Team") + User = apps.get_model("user_management", "User") + + # get (organization_id, team_id) pairs for duplicated teams + duplicate_rows = Team.objects.values_list( + "organization_id", "team_id" + ).annotate(count=models.Count("id")).filter(count__gt=1) + + for organization_id, team_id, _ in duplicate_rows: + # keep first team + first_team = Team.objects.filter( + organization_id=organization_id, + team_id=team_id, + ).order_by("id").first() + + # migrate resources associated to duplicated entries + duplicated_teams = Team.objects.filter( + organization_id=organization_id, + team_id=team_id, + ).exclude(id=first_team.id) + + for team in duplicated_teams: + # if there is anything to migrate, do it here + team.escalation_chains.update(team=first_team) + team.alert_receive_channels.exclude(integration="direct_paging").update(team=first_team) + team.custom_on_call_shifts.update(team=first_team) + team.oncall_schedules.update(team=first_team) + team.webhooks.update(team=first_team) + User.objects.filter(organization_id=organization_id, current_team=team).update(current_team=first_team) + + # delete duplicated teams + num_deleted, _ = duplicated_teams.delete() + logger.info( + f"Deleted {num_deleted} duplicate teams for ({organization_id}, {team_id}), " + f"keeping team with id: {first_team.id}." + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0021_user_google_calendar_settings'), + ] + + operations = [ + linter.IgnoreMigration(), # removing duplicated teams is ok, no way to revert this + migrations.RunPython(clean_up_duplicated_teams, migrations.RunPython.noop), + migrations.AlterUniqueTogether( + name='team', + unique_together={('organization', 'team_id')}, + ), + ] diff --git a/engine/apps/user_management/models/team.py b/engine/apps/user_management/models/team.py index ff651a9d..50d8dd69 100644 --- a/engine/apps/user_management/models/team.py +++ b/engine/apps/user_management/models/team.py @@ -52,7 +52,8 @@ class TeamManager(models.Manager["Team"]): for team in grafana_teams.values() if team["id"] not in existing_team_ids ) - organization.teams.bulk_create(teams_to_create, batch_size=5000) + # create entries, ignore failed insertions if team_id already exists in the organization + organization.teams.bulk_create(teams_to_create, batch_size=5000, ignore_conflicts=True) # create missing direct paging integrations AlertReceiveChannel.objects.create_missing_direct_paging_integrations(organization) @@ -123,3 +124,6 @@ class Team(models.Model): # If is_sharing_resources_to_all is False only team members and admins can access it and it's resources # if it's True every oncall organization user can access it is_sharing_resources_to_all = models.BooleanField(default=False) + + class Meta: + unique_together = ("organization", "team_id") diff --git a/engine/apps/user_management/tests/test_team.py b/engine/apps/user_management/tests/test_team.py new file mode 100644 index 00000000..de50a264 --- /dev/null +++ b/engine/apps/user_management/tests/test_team.py @@ -0,0 +1,14 @@ +import pytest +from django.db.utils import IntegrityError + + +@pytest.mark.django_db +def test_team_uniqueness(make_organization): + organization = make_organization() + + # Create a team + organization.teams.create(name="Team 1", team_id=1) + + # Try to create another team with the same team_id + with pytest.raises(IntegrityError): + organization.teams.create(name="Team 2", team_id=1) diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index 3128a39a..15cbd4bf 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -53,6 +53,12 @@ def generate_public_primary_key_for_webhook(): return new_public_primary_key +class WebhookSession(requests.Session): + def send(self, request, **kwargs): + parse_url(request.url) # validate URL on every redirect + return super().send(request, **kwargs) + + class WebhookQueryset(models.QuerySet): def delete(self): self.update(deleted_at=timezone.now(), name=F("name") + "_deleted_" + F("public_primary_key")) @@ -276,21 +282,15 @@ class Webhook(models.Model): raise InvalidWebhookTrigger(e.fallback_message) def make_request(self, url, request_kwargs): - if self.http_method == "GET": - r = requests.get(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs) - elif self.http_method == "POST": - r = requests.post(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs) - elif self.http_method == "PUT": - r = requests.put(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs) - elif self.http_method == "DELETE": - r = requests.delete(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs) - elif self.http_method == "OPTIONS": - r = requests.options(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs) - elif self.http_method == "PATCH": - r = requests.patch(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs) - else: + if self.http_method not in ("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"): raise ValueError(f"Unsupported http method: {self.http_method}") - return r + + with WebhookSession() as session: + response = session.request( + self.http_method, url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs + ) + + return response # Insight logs @property diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 3ef949a9..381b79eb 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -11,6 +11,7 @@ from apps.alerts.models import AlertGroupExternalID, AlertGroupLogRecord, Escala from apps.base.models import UserNotificationPolicyLogRecord from apps.public_api.serializers import IncidentSerializer from apps.webhooks.models import Webhook +from apps.webhooks.models.webhook import WebhookSession from apps.webhooks.tasks import execute_webhook, send_webhook_event from apps.webhooks.tasks.trigger_webhook import NOT_FROM_SELECTED_INTEGRATION from settings.base import WEBHOOK_RESPONSE_LIMIT @@ -141,10 +142,10 @@ def test_execute_webhook_integration_filter_not_matching( ) webhook.filtered_integrations.add(other_alert_receive_channel) - with patch("apps.webhooks.models.webhook.requests") as mock_requests: + with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request: execute_webhook(webhook.pk, alert_group.pk, None, None) - assert not mock_requests.post.called + assert not mock_request.called # no response is created for the webhook assert webhook.responses.count() == 0 # check log should exist @@ -166,10 +167,10 @@ def test_execute_webhook_integration_filter_matching( ) webhook.filtered_integrations.add(alert_receive_channel) - with patch("apps.webhooks.models.webhook.requests") as mock_requests: + with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request: execute_webhook(webhook.pk, alert_group.pk, None, None) - assert not mock_requests.post.called + assert not mock_request.called # no response is created for the webhook assert webhook.responses.count() == 0 # check log should exist @@ -235,10 +236,13 @@ def test_execute_webhook_ok( httpretty.register_uri(httpretty.POST, templated_url, responses=[mock_response]) with patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8"): - with patch("apps.webhooks.models.webhook.requests", wraps=requests) as mock_requests: + with patch( + "apps.webhooks.models.webhook.WebhookSession.request", wraps=WebhookSession().request + ) as mock_request: execute_webhook(webhook.pk, alert_group.pk, user.pk, None) - mock_requests.post.assert_called_once_with( + mock_request.assert_called_once_with( + "POST", templated_url, timeout=TIMEOUT, headers={"some-header": alert_group.public_primary_key}, @@ -310,11 +314,10 @@ def test_execute_webhook_via_escalation_ok( mock_response = MockResponse() with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname: mock_gethostbyname.return_value = "8.8.8.8" - with patch("apps.webhooks.models.webhook.requests") as mock_requests: - mock_requests.post.return_value = mock_response + with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request: execute_webhook(webhook.pk, alert_group.pk, user.pk, escalation_policy.pk) - assert mock_requests.post.called + assert mock_request.called # check log record log_record = alert_group.log_records.last() assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_WEBHOOK_TRIGGERED @@ -377,11 +380,10 @@ def test_execute_webhook_ok_forward_all( mock_response = MockResponse() with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname: mock_gethostbyname.return_value = "8.8.8.8" - with patch("apps.webhooks.models.webhook.requests") as mock_requests: - mock_requests.post.return_value = mock_response + with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request: execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_ACKNOWLEDGE) - assert mock_requests.post.called + assert mock_request.called expected_data = { "event": { "type": "acknowledge", @@ -423,12 +425,13 @@ def test_execute_webhook_ok_forward_all( "alert_group_resolved_by": None, } expected_call = call( + "POST", "https://something/{}/".format(alert_group.public_primary_key), timeout=TIMEOUT, headers={}, json=expected_data, ) - assert mock_requests.post.call_args == expected_call + assert mock_request.call_args == expected_call # check logs log = webhook.responses.all()[0] assert log.trigger_type == Webhook.TRIGGER_ACKNOWLEDGE @@ -485,11 +488,10 @@ def test_execute_webhook_ok_forward_all_resolved( mock_response = MockResponse() with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname: mock_gethostbyname.return_value = "8.8.8.8" - with patch("apps.webhooks.models.webhook.requests") as mock_requests: - mock_requests.post.return_value = mock_response + with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request: execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_RESOLVE) - assert mock_requests.post.called + assert mock_request.called expected_data = { "event": { "type": "resolve", @@ -535,12 +537,13 @@ def test_execute_webhook_ok_forward_all_resolved( }, } expected_call = call( + "POST", "https://something/{}/".format(alert_group.public_primary_key), timeout=TIMEOUT, headers={}, json=expected_data, ) - assert mock_requests.post.call_args == expected_call + assert mock_request.call_args == expected_call # check logs log = webhook.responses.all()[0] assert log.trigger_type == Webhook.TRIGGER_RESOLVE @@ -610,19 +613,19 @@ def test_execute_webhook_using_responses_data( mock_response = MockResponse() with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname: mock_gethostbyname.return_value = "8.8.8.8" - with patch("apps.webhooks.models.webhook.requests") as mock_requests: - mock_requests.post.return_value = mock_response + with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request: execute_webhook(webhook.pk, alert_group.pk, user.pk, None) - assert mock_requests.post.called + assert mock_request.called expected_data = {"value": "updated"} expected_call = call( + "POST", "https://something/third-party-id/", timeout=TIMEOUT, headers={}, json=expected_data, ) - assert mock_requests.post.call_args == expected_call + assert mock_request.call_args == expected_call # check logs log = webhook.responses.all()[0] assert log.status_code == 200 @@ -646,10 +649,10 @@ def test_execute_webhook_trigger_false( trigger_template="{{ integration_id == 'the-integration' }}", ) - with patch("apps.webhooks.models.webhook.requests") as mock_requests: + with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request: execute_webhook(webhook.pk, alert_group.pk, None, None) - assert not mock_requests.post.called + assert not mock_request.called # no response is created for the webhook assert webhook.responses.count() == 0 # check log should exist @@ -709,10 +712,10 @@ def test_execute_webhook_errors( with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname: # make it a valid URL when resolving name mock_gethostbyname.return_value = "8.8.8.8" - with patch("apps.webhooks.models.webhook.requests") as mock_requests: + with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request: execute_webhook(webhook.pk, alert_group.pk, None, None) - assert not mock_requests.post.called + assert not mock_request.called log = webhook.responses.all()[0] assert log.status_code is None assert log.content is None @@ -755,17 +758,17 @@ def test_response_content_limit( mock_response = MockResponse(content="A" * content_length) with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname: mock_gethostbyname.return_value = "8.8.8.8" - with patch("apps.webhooks.models.webhook.requests") as mock_requests: - mock_requests.post.return_value = mock_response + with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request: execute_webhook(webhook.pk, alert_group.pk, user.pk, None) - assert mock_requests.post.called + assert mock_request.called expected_call = call( + "POST", "https://test/", timeout=TIMEOUT, headers={}, ) - assert mock_requests.post.call_args == expected_call + assert mock_request.call_args == expected_call # check logs log = webhook.responses.all()[0] assert log.status_code == 200 @@ -774,13 +777,13 @@ def test_response_content_limit( @patch("apps.webhooks.tasks.trigger_webhook.execute_webhook", wraps=execute_webhook) -@patch("apps.webhooks.models.webhook.requests") +@patch("apps.webhooks.models.webhook.WebhookSession.request") @patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8") @pytest.mark.django_db @pytest.mark.parametrize("exception", [requests.exceptions.ConnectTimeout, requests.exceptions.ReadTimeout]) def test_manually_retried_exceptions( _mock_gethostbyname, - mock_requests, + mock_request, spy_execute_webhook, make_organization, make_user_for_organization, @@ -789,7 +792,7 @@ def test_manually_retried_exceptions( make_custom_webhook, exception, ): - mock_requests.post.side_effect = exception("foo bar") + mock_request.side_effect = exception("foo bar") organization = make_organization() user = make_user_for_organization(organization) @@ -810,12 +813,12 @@ def test_manually_retried_exceptions( # should retry execute_webhook(*execute_webhook_args) - mock_requests.post.assert_called_once_with("https://test/", timeout=TIMEOUT, headers={}) + mock_request.assert_called_once_with("POST", "https://test/", timeout=TIMEOUT, headers={}) spy_execute_webhook.apply_async.assert_called_once_with( execute_webhook_args, kwargs={"trigger_type": None, "manual_retry_num": 1}, countdown=10 ) - mock_requests.reset_mock() + mock_request.reset_mock() spy_execute_webhook.reset_mock() # should stop retrying after 3 attempts without raising issue @@ -824,16 +827,16 @@ def test_manually_retried_exceptions( except Exception: pytest.fail() - mock_requests.post.assert_called_once_with("https://test/", timeout=TIMEOUT, headers={}) + mock_request.assert_called_once_with("POST", "https://test/", timeout=TIMEOUT, headers={}) spy_execute_webhook.apply_async.assert_not_called() -@patch("apps.webhooks.models.webhook.requests.post", return_value=MockResponse()) +@patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=MockResponse()) @patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8") @pytest.mark.django_db def test_execute_webhook_integration_config( _, - mock_requests_post, + mock_request, make_organization, make_user_for_organization, make_alert_receive_channel, @@ -879,14 +882,15 @@ def test_execute_webhook_integration_config( ) as mock_on_webhook_response_created: execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED) - assert mock_requests_post.called + assert mock_request.called # check external ID - assert mock_requests_post.call_args[0][0] == "https://something/test123" - assert mock_requests_post.call_args[1]["json"]["external_id"] == "test123" + assert mock_request.call_args[0][0] == "POST" + assert mock_request.call_args[0][1] == "https://something/test123" + assert mock_request.call_args[1]["json"]["external_id"] == "test123" # check additional webhook data - assert mock_requests_post.call_args[1]["json"]["additional_field"] == "additional_value" + assert mock_request.call_args[1]["json"]["additional_field"] == "additional_value" mock_additional_webhook_data.assert_called_once_with(source_alert_receive_channel) # check on_webhook_response_created is called diff --git a/engine/apps/webhooks/tests/test_webhook.py b/engine/apps/webhooks/tests/test_webhook.py index 91822f37..7e86dc02 100644 --- a/engine/apps/webhooks/tests/test_webhook.py +++ b/engine/apps/webhooks/tests/test_webhook.py @@ -1,5 +1,6 @@ from unittest.mock import call, patch +import httpretty import pytest from django.conf import settings from requests.auth import HTTPBasicAuth @@ -225,13 +226,11 @@ def test_check_trigger_template_ok(make_organization, make_custom_webhook): def test_make_request(make_organization, make_custom_webhook): organization = make_organization() - with patch("apps.webhooks.models.webhook.requests") as mock_requests: + with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request: for method in ("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"): webhook = make_custom_webhook(organization=organization, http_method=method) webhook.make_request("url", {"foo": "bar"}) - expected_call = getattr(mock_requests, method.lower()) - assert expected_call.called - assert expected_call.call_args == call("url", timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, foo="bar") + assert mock_request.call_args == call(method, "url", timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, foo="bar") # invalid webhook = make_custom_webhook(organization=organization, http_method="NOT") @@ -239,6 +238,20 @@ def test_make_request(make_organization, make_custom_webhook): webhook.make_request("url", {"foo": "bar"}) +@httpretty.activate(verbose=True, allow_net_connect=False) +@pytest.mark.django_db +def test_make_request_bad_redirect(make_organization, make_custom_webhook): + organization = make_organization() + webhook = make_custom_webhook(organization=organization, http_method="POST") + + url = "http://example.com" + response = httpretty.Response(body="Redirect", status=302, location="127.0.0.1") + httpretty.register_uri(httpretty.POST, url, responses=[response]) + + with pytest.raises(InvalidWebhookUrl): + webhook.make_request(url, {}) + + @pytest.mark.django_db def test_escaping_payload_with_double_quotes(make_organization, make_custom_webhook): organization = make_organization() diff --git a/engine/requirements.in b/engine/requirements.in index af198397..4426371b 100644 --- a/engine/requirements.in +++ b/engine/requirements.in @@ -48,7 +48,7 @@ prometheus_client==0.16.0 psutil==5.9.4 psycopg2==2.9.3 pymdown-extensions==10.0 -PyMySQL==1.1.0 +PyMySQL==1.1.1 python-telegram-bot==13.13 recurring-ical-events==2.1.0 redis==5.0.1 diff --git a/engine/requirements.txt b/engine/requirements.txt index 8c910230..a096e2f2 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -349,7 +349,7 @@ pyjwt==2.8.0 # twilio pymdown-extensions==10.0 # via -r requirements.in -pymysql==1.1.0 +pymysql==1.1.1 # via -r requirements.in pyopenssl==23.2.0 # via django-sns-view diff --git a/engine/settings/ci-test.py b/engine/settings/ci_test.py similarity index 100% rename from engine/settings/ci-test.py rename to engine/settings/ci_test.py diff --git a/grafana-plugin/src/assets/style/vars.css b/grafana-plugin/src/assets/style/vars.css index fbd53fca..199730ed 100644 --- a/grafana-plugin/src/assets/style/vars.css +++ b/grafana-plugin/src/assets/style/vars.css @@ -7,12 +7,6 @@ --always-gray: #ccccdc; --title-marginBottom: 16px; --opacity: 0.5; - --tag-danger: #e02f44; - --tag-warning: #c69b06; - --tag-primary: #299c46; - --tag-secondary: #464c54; - --tag-secondary-transparent: rgba(204, 204, 220, 0.07); - --tag-border-link: rgba(56, 113, 220, 0.2); } .theme-light { @@ -32,25 +26,12 @@ --oncall-icon-stroke-color: #fff; --hover-selected: #f4f5f5; --background-canvas: #f4f5f5; - --background-primary: #fff; --background-secondary: #f4f5f5; --border-medium-color: rgba(36, 41, 46, 0.3); --border-medium: 1px solid rgba(36, 41, 46, 0.3); --border-strong: 1px solid rgba(36, 41, 46, 0.4); --border-weak: 1px solid rgba(36, 41, 46, 0.12); --shadows-z3: 0 13px 20px 1px rgba(24, 26, 27, 0.18); - --tag-background-primary: rgba(50, 116, 217, 0.15); - --tag-border-primary: rgb(136, 174, 233); - --tag-text-primary: rgb(26, 71, 139); - --tag-background-warning: rgba(255, 120, 10, 0.15); - --tag-border-warning: rgb(255, 176, 112); - --tag-text-warning: rgb(163, 73, 0); - --tag-background-success: rgba(86, 166, 75, 0.15); - --tag-border-success: rgb(148, 203, 140); - --tag-text-success: rgb(50, 96, 43); - --tag-background-danger: rgba(224, 47, 68, 0.15); - --tag-border-danger: rgb(237, 136, 148); - --tag-text-danger: rgb(147, 22, 37); --button-background: rgba(36, 41, 46, 0.08); --button-hover-background: rgba(36, 41, 46, 0.15); --box-background: rgba(244, 245, 245); @@ -79,25 +60,12 @@ --hover-selected-hardcoded: #34363d; --oncall-icon-stroke-color: #181b1f; --background-canvas: #111217; - --background-primary: #181b1f; --background-secondary: #22252b; --border-medium-color: rgba(204, 204, 220, 0.15); --border-medium: 1px solid rgba(204, 204, 220, 0.15); --border-strong: 1px solid rgba(204, 204, 220, 0.25); --border-weak: 1px solid rgba(204, 204, 220, 0.07); --shadows-z3: 0 8px 24px rgb(1, 4, 9); - --tag-background-primary: rgba(87, 148, 242, 0.15); - --tag-border-primary: rgb(13, 72, 163); - --tag-text-primary: rgb(158, 193, 247); - --tag-background-warning: rgba(255, 152, 48, 0.15); - --tag-border-warning: rgb(150, 75, 0); - --tag-text-warning: rgb(255, 190, 124); - --tag-background-success: rgba(115, 191, 105, 0.15); - --tag-border-success: rgb(49, 100, 43); - --tag-text-success: rgb(165, 214, 159); - --tag-background-danger: rgba(242, 73, 92, 0.15); - --tag-border-danger: rgb(151, 11, 27); - --tag-text-danger: rgb(247, 144, 156); --box-background: rgba(10, 10, 10, 0.4); --working-hours-shades-color: rgba(17, 18, 23, 0.15); --working-hours-shades-color-light: rgba(17, 18, 23, 0.1); diff --git a/grafana-plugin/src/components/CardButton/CardButton.tsx b/grafana-plugin/src/components/CardButton/CardButton.tsx index f3087e4e..c7a3303e 100644 --- a/grafana-plugin/src/components/CardButton/CardButton.tsx +++ b/grafana-plugin/src/components/CardButton/CardButton.tsx @@ -28,8 +28,8 @@ export const CardButton: FC = (props) => { className={cx(styles.root, { [styles.rootSelected]: selected })} data-testid="test__cardButton" > -
{icon}
-
+
{icon}
+
{description} {title} diff --git a/grafana-plugin/src/components/Collapse/Collapse.tsx b/grafana-plugin/src/components/Collapse/Collapse.tsx index 7595634c..6d5a296d 100644 --- a/grafana-plugin/src/components/Collapse/Collapse.tsx +++ b/grafana-plugin/src/components/Collapse/Collapse.tsx @@ -50,7 +50,7 @@ export const Collapse: FC = (props) => { data-testid="test__toggle" > -
{label}
+
{label}
{isOpen && (
diff --git a/grafana-plugin/src/components/GList/GList.tsx b/grafana-plugin/src/components/GList/GList.tsx index c26bb87b..4fcdf7c6 100644 --- a/grafana-plugin/src/components/GList/GList.tsx +++ b/grafana-plugin/src/components/GList/GList.tsx @@ -64,7 +64,7 @@ export const GList = (props: GListProps) => { } return ( -
+
{items ? ( items.map((item) => (
(props: key: 'check', title: ( 0 && rowSelection.selectedRowKeys.length === data?.length} /> @@ -124,7 +124,7 @@ export const GTable = (props: render: (item: any) => { return ( @@ -136,7 +136,7 @@ export const GTable = (props: }, [rowSelection, columnsProp, data]); return ( -
+
expandable={expandable} rowKey={rowKey} @@ -148,7 +148,7 @@ export const GTable = (props: {...restProps} /> {pagination && ( -
+
)} diff --git a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx b/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx index 9cadb0e5..1d50da08 100644 --- a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx +++ b/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx @@ -120,7 +120,7 @@ const IntegrationCollapsibleTreeItem: React.FC<{ return (
+ {elementPosition} ); diff --git a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx index 25bee117..885b8f00 100644 --- a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx +++ b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx @@ -35,7 +35,7 @@ export const IntegrationInputField: React.FC = ({ return (
-
{renderInputField()}
+
{renderInputField()}
diff --git a/grafana-plugin/src/components/Integrations/IntegrationBlock.tsx b/grafana-plugin/src/components/Integrations/IntegrationBlock.tsx index 4e19558e..b9c68052 100644 --- a/grafana-plugin/src/components/Integrations/IntegrationBlock.tsx +++ b/grafana-plugin/src/components/Integrations/IntegrationBlock.tsx @@ -41,7 +41,7 @@ export const IntegrationBlock: React.FC = ({ )} {content && ( -
+
{content}
)} diff --git a/grafana-plugin/src/components/Integrations/IntegrationBlockItem.tsx b/grafana-plugin/src/components/Integrations/IntegrationBlockItem.tsx index 13c3b81f..26849a84 100644 --- a/grafana-plugin/src/components/Integrations/IntegrationBlockItem.tsx +++ b/grafana-plugin/src/components/Integrations/IntegrationBlockItem.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; @@ -12,8 +12,8 @@ export const IntegrationBlockItem: React.FC = (props) const styles = useStyles2(getStyles); return ( -
-
{props.children}
+
+
{props.children}
); }; diff --git a/grafana-plugin/src/components/Integrations/IntegrationTemplateBlock.tsx b/grafana-plugin/src/components/Integrations/IntegrationTemplateBlock.tsx index 2280e192..d25ea77a 100644 --- a/grafana-plugin/src/components/Integrations/IntegrationTemplateBlock.tsx +++ b/grafana-plugin/src/components/Integrations/IntegrationTemplateBlock.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, InlineLabel, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; @@ -41,11 +41,11 @@ export const IntegrationTemplateBlock: React.FC = } return ( -
+
{label} -
+
{renderInput()} {isTemplateEditable && ( <> diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx index 28a1ba09..d7318e86 100644 --- a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx @@ -1,6 +1,6 @@ import React, { FC, useCallback, useState } from 'react'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup, useStyles2 } from '@grafana/ui'; import { Block } from 'components/GBlock/Block'; @@ -33,9 +33,9 @@ export const NewScheduleSelector: FC = ({ onHide, onCr return ( -
+
- + @@ -53,7 +53,7 @@ export const NewScheduleSelector: FC = ({ onHide, onCr - + @@ -69,7 +69,7 @@ export const NewScheduleSelector: FC = ({ onHide, onCr - + diff --git a/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers.tsx b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers.tsx index a9a4432a..b6f808a5 100644 --- a/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers.tsx +++ b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers.tsx @@ -8,7 +8,7 @@ export function getWrongTeamResponseInfo(response): Partial { if (response) { if (response.status === 404) { return { isNotFoundError: true }; - } else if (response.status === 403 && response.data.error_code === 'wrong_team') { + } else if (response.status === 403 && response.data?.error_code === 'wrong_team') { let res = response.data; if (res.owner_team) { return { isWrongTeamError: true, switchToTeam: { name: res.owner_team.name, id: res.owner_team.id } }; diff --git a/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx index 8d9409e9..814f8e9d 100644 --- a/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx +++ b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { VerticalGroup, useStyles2 } from '@grafana/ui'; @@ -52,9 +52,9 @@ export const PageErrorHandlingWrapper = function ({ const { wrongTeamNoPermissions } = errorData; return ( -
+
- + 403 {wrongTeamNoPermissions && ( diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index 098f040f..c977a767 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -383,13 +383,12 @@ class _EscalationPolicy extends React.Component { return ( - + allowClear disabled={isDisabled} items={userGroupStore.items} fetchItemsFn={userGroupStore.updateItems} - fetchItemFn={() => undefined} - // TODO: fetchItemFn + fetchItemFn={userGroupStore.fetchItemById} getSearchResult={userGroupStore.getSearchResult} displayField="name" valueField="id" diff --git a/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx b/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx index 0983e9fc..ca2c23b9 100644 --- a/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx +++ b/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx @@ -2,9 +2,20 @@ import React, { FC, ReactNode } from 'react'; interface RenderConditionallyProps { shouldRender?: boolean; - children: ReactNode; + children?: ReactNode; + render?: () => ReactNode; backupChildren?: ReactNode; } -export const RenderConditionally: FC = ({ shouldRender, children, backupChildren = null }) => - shouldRender ? <>{children} : <>{backupChildren}; +export const RenderConditionally: FC = ({ + shouldRender, + children, + render, + backupChildren = null, +}) => { + if (render) { + return shouldRender ? <>{render()} : <>{backupChildren}; + } + + return shouldRender ? <>{children} : <>{backupChildren}; +}; diff --git a/grafana-plugin/src/components/ScheduleBorderedAvatar/ScheduleBorderedAvatar.tsx b/grafana-plugin/src/components/ScheduleBorderedAvatar/ScheduleBorderedAvatar.tsx index ccc8b722..ffa20a57 100644 --- a/grafana-plugin/src/components/ScheduleBorderedAvatar/ScheduleBorderedAvatar.tsx +++ b/grafana-plugin/src/components/ScheduleBorderedAvatar/ScheduleBorderedAvatar.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { useStyles2 } from '@grafana/ui'; interface ScheduleBorderedAvatarProps { @@ -20,13 +20,13 @@ export const ScheduleBorderedAvatar = function ({ }: ScheduleBorderedAvatarProps) { const styles = useStyles2(getStyles); - return
{renderSVG()}
; + return
{renderSVG()}
; function renderAvatarIcon() { return ( <> -
{renderAvatar()}
-
{renderIcon()}
+
{renderAvatar()}
+
{renderIcon()}
); } diff --git a/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.tsx b/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.tsx index 92c87ddb..c43ff373 100644 --- a/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.tsx +++ b/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { InlineSwitch, useStyles2 } from '@grafana/ui'; @@ -35,7 +35,7 @@ export const ScheduleFilters = (props: SchedulesFiltersProps) => { ); return ( -
+
= observer(({ schedule }) return ( <> -
+
{relatedScheduleEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && ( = observer(({ schedule }) content={} >
- + Quality: {getScheduleQualityString(quality.total_score)}
diff --git a/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx b/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx index b64e86a6..43e9f3cd 100644 --- a/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx +++ b/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx @@ -30,12 +30,12 @@ export const ScheduleQualityDetails: FC = ({ qualit const warningComments = comments.filter((c) => c.type === 'warning'); return ( -
-
+
+
- + Schedule quality:{' '} - + {getScheduleQualityString(score)} @@ -53,10 +53,10 @@ export const ScheduleQualityDetails: FC = ({ qualit <> {/* Show Info comments */} {infoComments?.length > 0 && ( -
-
+
+
-
+
{infoComments.map((comment, index) => ( {comment.text} @@ -69,10 +69,10 @@ export const ScheduleQualityDetails: FC = ({ qualit {/* Show Warning comments afterwards */} {warningComments?.length > 0 && ( -
-
+
+
-
+
Rotation structure issues {warningComments.map((comment, index) => ( @@ -87,13 +87,13 @@ export const ScheduleQualityDetails: FC = ({ qualit )} {overloaded_users?.length > 0 && ( -
-
+
+
-
+
Overloaded users {overloaded_users.map((overloadedUser, index) => ( - + {overloadedUser.username} (+{overloadedUser.score}% avg) ))} @@ -115,7 +115,7 @@ export const ScheduleQualityDetails: FC = ({ qualit - + Calculation methodology @@ -126,7 +126,7 @@ export const ScheduleQualityDetails: FC = ({ qualit /> {expanded && ( - + The next 52 weeks (~1 year) are taken into account when generating the quality report. Refer to the{' '} = ({ classNa const classList = [styles.bar, className || '']; return ( -
+
{!numTotalSteps &&
} {renderSteps(numTotalSteps, completed)}
diff --git a/grafana-plugin/src/components/SourceCode/SourceCode.tsx b/grafana-plugin/src/components/SourceCode/SourceCode.tsx index 56357f11..96158c52 100644 --- a/grafana-plugin/src/components/SourceCode/SourceCode.tsx +++ b/grafana-plugin/src/components/SourceCode/SourceCode.tsx @@ -54,7 +54,7 @@ export const SourceCode: FC = ({ {showClipboardIconOnly ? ( = ({ /> ) : (
); }, - expandedRowClassName: (_record, index) => (index % 2 === 0 ? cx(styles.rowEven) : ''), + expandedRowClassName: (_record, index) => (index % 2 === 0 ? styles.rowEven : ''), } : null; }, [expandable]); @@ -61,11 +61,11 @@ export const GTable: FC = (props) => { columns={columns} data={data} expandable={expandableFn} - rowClassName={(_record, index) => (index % 2 === 0 ? cx(styles.rowEven) : '')} + rowClassName={(_record, index) => (index % 2 === 0 ? styles.rowEven : '')} {...restProps} /> {pagination && ( -
+
)} diff --git a/grafana-plugin/src/components/Tag/Tag.tsx b/grafana-plugin/src/components/Tag/Tag.tsx index 3dc9feb4..9934c18f 100644 --- a/grafana-plugin/src/components/Tag/Tag.tsx +++ b/grafana-plugin/src/components/Tag/Tag.tsx @@ -2,7 +2,7 @@ import React, { FC } from 'react'; import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; +import { useStyles2, useTheme2 } from '@grafana/ui'; import { bem, getLabelCss } from 'styles/utils.styles'; interface TagProps { @@ -30,6 +30,7 @@ export enum TagColor { export const Tag: FC = (props) => { const { color, children, className, onClick, size = 'medium' } = props; + const theme = useTheme2(); const styles = useStyles2(getStyles); @@ -49,7 +50,7 @@ export const Tag: FC = (props) => { styles[color] : css` background-color: ${color}; - color: text; + color: ${theme.colors.primary.contrastText}; `; } diff --git a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx index 858af3dc..888170e2 100644 --- a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx +++ b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx @@ -46,7 +46,7 @@ export const TooltipBadge: FC = (props) => { placement={placement || 'bottom-start'} interactive content={ -
+
{tooltipTitle} {tooltipContent && {tooltipContent}} diff --git a/grafana-plugin/src/components/Tutorial/Tutorial.tsx b/grafana-plugin/src/components/Tutorial/Tutorial.tsx index f6bb5ee2..0ad6a337 100644 --- a/grafana-plugin/src/components/Tutorial/Tutorial.tsx +++ b/grafana-plugin/src/components/Tutorial/Tutorial.tsx @@ -26,10 +26,10 @@ export const Tutorial: FC = (props) => { const styles = useStyles2(getStyles); return ( - -
{title}
-
-
+ +
{title}
+
+
@@ -38,7 +38,7 @@ export const Tutorial: FC = (props) => { Add integration with a monitoring system
-
+
@@ -47,7 +47,7 @@ export const Tutorial: FC = (props) => { Setup escalation chain to handle notifications
-
+
@@ -56,7 +56,7 @@ export const Tutorial: FC = (props) => { Connect to your chat workspace
-
+
@@ -65,7 +65,7 @@ export const Tutorial: FC = (props) => { Add your team calendar to define an on-call rotation.
-
+
@@ -81,7 +81,7 @@ export const Tutorial: FC = (props) => { const Arrow = () => { const styles = useStyles2(getStyles); return ( -
+
diff --git a/grafana-plugin/src/components/Unauthorized/Unauthorized.tsx b/grafana-plugin/src/components/Unauthorized/Unauthorized.tsx index 58c02971..cdb4ed25 100644 --- a/grafana-plugin/src/components/Unauthorized/Unauthorized.tsx +++ b/grafana-plugin/src/components/Unauthorized/Unauthorized.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { VerticalGroup, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'grafana/app/core/core'; @@ -16,9 +16,9 @@ export const Unauthorized: FC = ({ requiredUserAction: { permission, fall const styles = useStyles2(getStyles); return ( -
+
- + 403 diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index a521a738..42373d38 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -95,14 +95,14 @@ export const UserGroups = (props: UserGroupsProps) => { }; const renderItem = (item: Item, index: number) => ( -
  • +
  • {renderUser(item.data)} {!disabled && ( -
    +
    @@ -114,7 +114,7 @@ export const UserGroups = (props: UserGroupsProps) => { ); return ( -
    +
    {!disabled && ( { renderItem={renderItem} axis="y" lockAxis="y" - helperClass={cx(styles.sortable)} + helperClass={styles.sortable} items={items} onSortEnd={onSortEnd} handleAddGroup={handleAddUserGroup} @@ -178,7 +178,7 @@ export const SortableList = SortableContainer( }, [items]); return ( -