Delete duplicate direct paging integrations (#3412)

# What this PR does

Deletes duplicate direct paging integrations (i.e. keeps only the first
direct paging integration per team).
Also adds a unique constraint that will make such duplicates impossible
at the DB level.

## Which issue(s) this PR fixes

Related to https://github.com/grafana/oncall-private/issues/2302

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Vadim Stepanov 2023-11-30 11:19:12 +00:00 committed by GitHub
parent efe274b465
commit 381a9ecf54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 96 additions and 30 deletions

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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}"

View file

@ -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

View file

@ -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,