Repository: python-discord/site

Issue:
IntegrityError: duplicate key value violates unique constraint "unique_active_infraction_per_type_per_user"
Sentry Issue: [SITE-22](https://sentry.io/organizations/python-discord/issues/2889692012/?referrer=github_integration)

```
UniqueViolation: duplicate key value violates unique constraint "unique_active_infraction_per_type_per_user"
DETAIL:  Key (user_id, type)=(938288196100423800, ban) already exists.

  File "django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)

IntegrityError: duplicate key value violates unique constraint "unique_active_infraction_per_type_per_user"
DETAIL:  Key (user_id, type)=(938288196100423800, ban) already exists.

(21 additional frame(s) were not displayed)
...
  File "django/db/backends/utils.py", line 66, in execute
    return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
  File "django/db/backends/utils.py", line 75, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "django/db/utils.py", line 90, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
```


Discussion:

Resolving patch:
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -156,13 +156,6 @@ class Meta:
             'hidden',
             'dm_sent'
         )
-        validators = [
-            UniqueTogetherValidator(
-                queryset=Infraction.objects.filter(active=True),
-                fields=['user', 'type', 'active'],
-                message='This user already has an active infraction of this type.',
-            )
-        ]
 
     def validate(self, attrs: dict) -> dict:
         """Validate data constraints for the given data and abort if it is invalid."""
diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py
--- a/pydis_site/apps/api/viewsets/bot/infraction.py
+++ b/pydis_site/apps/api/viewsets/bot/infraction.py
@@ -1,5 +1,6 @@
 from datetime import datetime
 
+from django.db import IntegrityError
 from django.db.models import QuerySet
 from django.http.request import HttpRequest
 from django_filters.rest_framework import DjangoFilterBackend
@@ -271,3 +272,20 @@ def partial_update_expanded(self, *args, **kwargs) -> Response:
         """
         self.serializer_class = ExpandedInfractionSerializer
         return self.partial_update(*args, **kwargs)
+
+    def create(self, request: HttpRequest, *args, **kwargs) -> Response:
+        """
+        Create an infraction for a target user.
+
+        Called by the Django Rest Framework in response to the corresponding HTTP request.
+        """
+        try:
+            return super().create(request, *args, **kwargs)
+        except IntegrityError:
+            raise ValidationError(
+                {
+                    'non_field_errors': [
+                        'This user already has an active infraction of this type.',
+                    ]
+                }
+            )


Test patch:
diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -3,6 +3,7 @@
 from unittest.mock import patch
 from urllib.parse import quote
 
+from django.db import transaction
 from django.db.utils import IntegrityError
 from django.urls import reverse
 
@@ -492,6 +493,7 @@ def test_returns_400_for_active_infraction_of_type_that_cannot_be_active(self):
         )
 
         for infraction_type, hidden in restricted_types:
+            # https://stackoverflow.com/a/23326971
             with self.subTest(infraction_type=infraction_type):
                 invalid_infraction = {
                     'user': self.user.id,
@@ -516,37 +518,38 @@ def test_returns_400_for_second_active_infraction_of_the_same_type(self):
 
         for infraction_type in active_infraction_types:
             with self.subTest(infraction_type=infraction_type):
-                first_active_infraction = {
-                    'user': self.user.id,
-                    'actor': self.user.id,
-                    'type': infraction_type,
-                    'reason': 'Take me on!',
-                    'active': True,
-                    'expires_at': '2019-10-04T12:52:00+00:00'
-                }
-
-                # Post the first active infraction of a type and confirm it's accepted.
-                first_response = self.client.post(url, data=first_active_infraction)
-                self.assertEqual(first_response.status_code, 201)
+                with transaction.atomic():
+                    first_active_infraction = {
+                        'user': self.user.id,
+                        'actor': self.user.id,
+                        'type': infraction_type,
+                        'reason': 'Take me on!',
+                        'active': True,
+                        'expires_at': '2019-10-04T12:52:00+00:00'
+                    }
 
-                second_active_infraction = {
-                    'user': self.user.id,
-                    'actor': self.user.id,
-                    'type': infraction_type,
-                    'reason': 'Take on me!',
-                    'active': True,
-                    'expires_at': '2019-10-04T12:52:00+00:00'
-                }
-                second_response = self.client.post(url, data=second_active_infraction)
-                self.assertEqual(second_response.status_code, 400)
-                self.assertEqual(
-                    second_response.json(),
-                    {
-                        'non_field_errors': [
-                            'This user already has an active infraction of this type.'
-                        ]
+                    # Post the first active infraction of a type and confirm it's accepted.
+                    first_response = self.client.post(url, data=first_active_infraction)
+                    self.assertEqual(first_response.status_code, 201)
+
+                    second_active_infraction = {
+                        'user': self.user.id,
+                        'actor': self.user.id,
+                        'type': infraction_type,
+                        'reason': 'Take on me!',
+                        'active': True,
+                        'expires_at': '2019-10-04T12:52:00+00:00'
                     }
-                )
+                    second_response = self.client.post(url, data=second_active_infraction)
+                    self.assertEqual(second_response.status_code, 400)
+                    self.assertEqual(
+                        second_response.json(),
+                        {
+                            'non_field_errors': [
+                                'This user already has an active infraction of this type.'
+                            ]
+                        }
+                    )
 
     def test_returns_201_for_second_active_infraction_of_different_type(self):
         """Test if the API accepts a second active infraction of a different type than the first."""
@@ -811,22 +814,6 @@ def test_is_valid_if_active_infraction_with_same_fields_exists(self):
 
         self.assertTrue(serializer.is_valid(), msg=serializer.errors)
 
-    def test_validation_error_if_active_duplicate(self):
-        self.create_infraction('ban', active=True)
-        instance = self.create_infraction('ban', active=False)
-
-        data = {'active': True}
-        serializer = InfractionSerializer(instance, data=data, partial=True)
-
-        if not serializer.is_valid():
-            self.assertIn('non_field_errors', serializer.errors)
-
-            code = serializer.errors['non_field_errors'][0].code
-            msg = f'Expected failure on unique validator but got {serializer.errors}'
-            self.assertEqual(code, 'unique', msg=msg)
-        else:  # pragma: no cover
-            self.fail('Validation unexpectedly succeeded.')
-
     def test_is_valid_for_new_active_infraction(self):
         self.create_infraction('ban', active=False)