diff --git a/CHANGELOG.md b/CHANGELOG.md index 11355f97..5b19daa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Delete duplicate direct paging integrations by @vadimkerr ([#3412](https://github.com/grafana/oncall/pull/3412)) + ## v1.3.65 (2023-11-29) ### Added diff --git a/engine/apps/alerts/migrations/0041_alertreceivechannel_unique_direct_paging_integration_per_team.py b/engine/apps/alerts/migrations/0041_alertreceivechannel_unique_direct_paging_integration_per_team.py new file mode 100644 index 00000000..a61c424c --- /dev/null +++ b/engine/apps/alerts/migrations/0041_alertreceivechannel_unique_direct_paging_integration_per_team.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.7 on 2023-11-28 10:45 +import logging + +from django.db import migrations, models +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +def delete_duplicate_direct_paging_integrations(apps, schema_editor): + AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") + + # get (organization_id, team_id) pairs for teams that have more than one direct paging integration + duplicate_rows = AlertReceiveChannel.objects.values_list( + "organization_id", "team_id" + ).annotate(count=models.Count("id")).filter(integration="direct_paging", deleted_at__isnull=True, count__gt=1) + + for organization_id, team_id, _ in duplicate_rows: + # get the first direct paging integration for the team (the one we want to keep) + first_direct_paging_integration = AlertReceiveChannel.objects.filter( + organization_id=organization_id, + team_id=team_id, + integration="direct_paging", + deleted_at__isnull=True, + ).order_by("id").first() + + if first_direct_paging_integration is None: + continue + + # delete all other direct paging integrations for the team (except the first one) + num_deleted = AlertReceiveChannel.objects.filter( + organization_id=organization_id, + team_id=team_id, + integration="direct_paging", + deleted_at__isnull=True, + ).exclude(id=first_direct_paging_integration.id).update(deleted_at=timezone.now()) + + logger.info( + f"Deleted {num_deleted} duplicate direct paging integrations for team ({organization_id}, {team_id}), " + f"keeping only one direct paging integration for team: {first_direct_paging_integration.id}." + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0040_alertreceivechannel_alert_group_labels_custom_and_more'), + ] + + operations = [ + migrations.RunPython(delete_duplicate_direct_paging_integrations, migrations.RunPython.noop), + migrations.AddConstraint( + model_name='alertreceivechannel', + constraint=models.UniqueConstraint(models.F('organization'), models.Case(models.When(team=None, then=0), default=models.F('team'), output_field=models.BigIntegerField()), models.Case(models.When(deleted_at__isnull=True, then=True), default=None), models.Case(models.When(integration='direct_paging', then=True), default=None), name='unique_direct_paging_integration_per_team'), + ), + ] diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index d1335cc7..22b7c6f2 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -8,7 +8,7 @@ from celery import uuid as celery_uuid from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models, transaction -from django.db.models import Q +from django.db.models import BigIntegerField, Case, F, Q, When from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -215,6 +215,21 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): alert_group_labels_template: str | None = models.TextField(null=True, default=None) """Stores a Jinja2 template for "advanced label templating" for alert group labels.""" + class Meta: + constraints = [ + # This constraint ensures that there's at most one active direct paging integration per team + # This should work with SQLite, PostgreSQL and MySQL >= 8.0.13. + # From the docs: Functional indexes are ignored with MySQL < 8.0.13 and MariaDB as neither supports them. + # https://docs.djangoproject.com/en/4.2/ref/models/constraints/#expressions + models.UniqueConstraint( + F("organization"), + Case(When(team=None, then=0), default=F("team"), output_field=BigIntegerField()), + Case(When(deleted_at__isnull=True, then=True), default=None), + Case(When(integration="direct_paging", then=True), default=None), + name="unique_direct_paging_integration_per_team", + ) + ] + def __str__(self): short_name_with_emojis = emojize(self.short_name, language="alias") return f"{self.pk}: {short_name_with_emojis}" diff --git a/engine/apps/alerts/tests/test_alert_receiver_channel.py b/engine/apps/alerts/tests/test_alert_receiver_channel.py index 7e38766a..d53513bc 100644 --- a/engine/apps/alerts/tests/test_alert_receiver_channel.py +++ b/engine/apps/alerts/tests/test_alert_receiver_channel.py @@ -2,6 +2,7 @@ from unittest import mock from unittest.mock import patch import pytest +from django.db import IntegrityError from django.urls import reverse from apps.alerts.models import AlertReceiveChannel @@ -229,3 +230,20 @@ def test_delete_duplicate_names(make_organization, make_alert_receive_channel): for _ in range(2): make_alert_receive_channel(organization, verbal_name="duplicate") organization.alert_receive_channels.all().delete() + + +@pytest.mark.django_db +def test_create_duplicate_direct_paging_integrations(make_organization, make_team, make_alert_receive_channel): + """Check that it's not possible to have more than one active direct paging integration per team.""" + + organization = make_organization() + team = make_team(organization) + make_alert_receive_channel(organization, team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + + with pytest.raises(IntegrityError): + arc = AlertReceiveChannel( + organization=organization, + team=team, + integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, + ) + super(AlertReceiveChannel, arc).save() # bypass the custom save method, so that IntegrityError is raised diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 4855a87c..8dac1b4b 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -852,35 +852,6 @@ def test_update_alert_receive_channels_direct_paging( assert response.json()["detail"] == AlertReceiveChannel.DuplicateDirectPagingError.DETAIL -@pytest.mark.django_db -def test_delete_alert_receive_channel_direct_paging_duplicate( - make_organization_and_user_with_plugin_token, make_team, make_alert_receive_channel, make_user_auth_headers -): - """Check that it's possible to delete direct paging integration even if there is a duplicate for the team.""" - organization, user, token = make_organization_and_user_with_plugin_token() - integration = make_alert_receive_channel( - organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=None - ) - - # Create a team, add direct paging integration to it, then delete the team. - # There will be 2 direct paging integrations for the team "No team" as a result. - team = make_team(organization) - make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=team) - team.delete() - assert ( - organization.alert_receive_channels.filter( - integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=None - ).count() - == 2 - ) - - client = APIClient() - url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": integration.public_primary_key}) - response = client.delete(url, **make_user_auth_headers(user, token)) - - assert response.status_code == status.HTTP_204_NO_CONTENT - - @pytest.mark.django_db def test_start_maintenance_integration( make_user_auth_headers,