Skip to content

Commit 35cc18e

Browse files
committed
fix:Report validation when saving with a duplicate name
1 parent 56fb4e5 commit 35cc18e

3 files changed

Lines changed: 113 additions & 43 deletions

File tree

apps/dashboard_reports/serializers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,20 @@ class Meta:
318318
},
319319
}
320320

321+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
322+
"""Ensure the user doesn't already have a filter set with this name."""
323+
request = self.context.get("request")
324+
user = getattr(request, "user", None)
325+
if user is None:
326+
return attrs
327+
name = attrs.get("name", self.instance.name if self.instance is not None else None)
328+
queryset = FilterSet.objects.filter(user=user, name=name)
329+
if self.instance is not None:
330+
queryset = queryset.exclude(pk=self.instance.pk)
331+
if queryset.exists():
332+
raise serializers.ValidationError({"name": "Report name already exists. Please choose a different name."})
333+
return attrs
334+
321335

322336
class DashboardTelemetrySerializer(serializers.ModelSerializer):
323337
"""Serializer for DashboardTelemetry"""

apps/dashboard_reports/viewsets/filter_sets.py

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
from typing import Any
33

44
from ansible_base.rest_pagination import DefaultPaginator
5-
from django.db import transaction
5+
from django.db import IntegrityError, transaction
66
from django.db.models import QuerySet
77
from drf_spectacular.utils import extend_schema, extend_schema_view
88
from rest_framework import status
9+
from rest_framework.exceptions import ValidationError
910
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin, UpdateModelMixin
1011
from rest_framework.permissions import IsAuthenticated
1112
from rest_framework.request import Request
@@ -73,47 +74,32 @@ def get_queryset(self) -> QuerySet[FilterSet]:
7374
"""Return filter sets belonging to the currently authenticated user."""
7475
return FilterSet.objects.filter(user=self.request.user)
7576

77+
def _clear_other_defaults(self, exclude_pk: int | None = None) -> None:
78+
"""Clear is_default on the user's other filter sets, optionally excluding one by pk."""
79+
queryset = FilterSet.objects.filter(user=self.request.user, is_default=True)
80+
if exclude_pk is not None:
81+
queryset = queryset.exclude(pk=exclude_pk)
82+
queryset.update(is_default=False)
83+
7684
def perform_create(self, serializer: FilterSetSerializer) -> None:
7785
"""Create a filter set, clearing any existing default if the new one is marked default."""
78-
with transaction.atomic():
79-
if serializer.validated_data.get("is_default", False):
80-
FilterSet.objects.filter(user=self.request.user, is_default=True).update(is_default=False)
81-
serializer.save(user=self.request.user)
82-
83-
def update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
84-
"""
85-
Updates the filter set. (PUT method)
86-
"""
87-
instance = self.get_object()
88-
serializer = self.get_serializer(instance, data=request.data, partial=False)
89-
serializer.is_valid(raise_exception=True)
90-
with transaction.atomic():
91-
validated = serializer.validated_data
92-
if validated.get("is_default", False):
93-
FilterSet.objects.filter(user=self.request.user, is_default=True).exclude(pk=instance.pk).update(
94-
is_default=False
95-
)
96-
serializer.save(user=self.request.user)
97-
98-
return Response(serializer.data, status=status.HTTP_200_OK)
99-
100-
def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
101-
"""
102-
Partially updates the filter set. (PATCH method)
103-
"""
104-
instance = self.get_object()
105-
serializer = self.get_serializer(instance, data=request.data, partial=True)
106-
serializer.is_valid(raise_exception=True)
107-
108-
with transaction.atomic():
109-
validated = serializer.validated_data
110-
if validated.get("is_default", False):
111-
FilterSet.objects.filter(user=self.request.user, is_default=True).exclude(pk=instance.pk).update(
112-
is_default=False
113-
)
114-
serializer.save()
115-
116-
return Response(serializer.data, status=status.HTTP_200_OK)
86+
try:
87+
with transaction.atomic():
88+
if serializer.validated_data.get("is_default", False):
89+
self._clear_other_defaults()
90+
serializer.save(user=self.request.user)
91+
except IntegrityError as exc:
92+
raise ValidationError({"is_default": "Unable to set this filter set as default."}) from exc
93+
94+
def perform_update(self, serializer: FilterSetSerializer) -> None:
95+
"""Update a filter set, clearing any existing default if the new one is marked default."""
96+
try:
97+
with transaction.atomic():
98+
if serializer.validated_data.get("is_default", False):
99+
self._clear_other_defaults(exclude_pk=serializer.instance.pk)
100+
serializer.save(user=self.request.user)
101+
except IntegrityError as exc:
102+
raise ValidationError({"is_default": "Unable to set this filter set as default."}) from exc
117103

118104
def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response:
119105
"""

tests/unit/dashboard_reports/test_filter_sets.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
- URL routing and router registration
1111
"""
1212

13+
from types import SimpleNamespace
14+
1315
import pytest
1416
from django.test import TestCase
1517
from django.urls import resolve, reverse
@@ -76,7 +78,10 @@ def test_id_is_read_only(self) -> None:
7678
assert FilterSetSerializer().fields["id"].read_only is True
7779

7880
def test_valid_payload_is_accepted(self) -> None:
79-
serializer = FilterSetSerializer(data={"name": "Work Filters", "filters": _FILTERS_A, "is_default": False})
81+
serializer = FilterSetSerializer(
82+
data={"name": "Work Filters", "filters": _FILTERS_A, "is_default": False},
83+
context={"request": SimpleNamespace(user=self.user)},
84+
)
8085
assert serializer.is_valid(), serializer.errors
8186

8287
def test_name_is_required(self) -> None:
@@ -90,7 +95,10 @@ def test_filters_is_required(self) -> None:
9095
assert "filters" in serializer.errors
9196

9297
def test_is_default_defaults_to_false_when_omitted(self) -> None:
93-
serializer = FilterSetSerializer(data={"name": "Work Filters", "filters": _FILTERS_A})
98+
serializer = FilterSetSerializer(
99+
data={"name": "Work Filters", "filters": _FILTERS_A},
100+
context={"request": SimpleNamespace(user=self.user)},
101+
)
94102
assert serializer.is_valid(), serializer.errors
95103
# When omitted, is_default is absent from validated_data (DRF applies the
96104
# default=False at save time, not in validated_data). Either absent or False is correct.
@@ -105,7 +113,37 @@ def test_maps_values_correctly(self) -> None:
105113

106114
def test_filters_accepts_arbitrary_json(self) -> None:
107115
complex_filters = {"organization_id": [1, 2, 3], "date_range": "custom", "labels": [10, 20]}
108-
serializer = FilterSetSerializer(data={"name": "Complex", "filters": complex_filters, "is_default": False})
116+
serializer = FilterSetSerializer(
117+
data={"name": "Complex", "filters": complex_filters, "is_default": False},
118+
context={"request": SimpleNamespace(user=self.user)},
119+
)
120+
assert serializer.is_valid(), serializer.errors
121+
122+
def test_validate_rejects_duplicate_name_for_same_user(self) -> None:
123+
_create_filter_set(self.user, name="Existing")
124+
serializer = FilterSetSerializer(
125+
data={"name": "Existing", "filters": _FILTERS_A, "is_default": False},
126+
context={"request": SimpleNamespace(user=self.user)},
127+
)
128+
assert not serializer.is_valid()
129+
assert "name" in serializer.errors
130+
131+
def test_validate_allows_same_name_for_different_user(self) -> None:
132+
other_user = _create_user("other")
133+
_create_filter_set(other_user, name="Shared Name")
134+
serializer = FilterSetSerializer(
135+
data={"name": "Shared Name", "filters": _FILTERS_A, "is_default": False},
136+
context={"request": SimpleNamespace(user=self.user)},
137+
)
138+
assert serializer.is_valid(), serializer.errors
139+
140+
def test_validate_allows_updating_instance_without_changing_name(self) -> None:
141+
instance = _create_filter_set(self.user, name="Unchanged")
142+
serializer = FilterSetSerializer(
143+
instance,
144+
data={"name": "Unchanged", "filters": _FILTERS_B, "is_default": False},
145+
context={"request": SimpleNamespace(user=self.user)},
146+
)
109147
assert serializer.is_valid(), serializer.errors
110148

111149

@@ -354,6 +392,22 @@ def test_create_without_filters_returns_400(self) -> None:
354392
response = self.client.post(url, {"name": "No Filters", "is_default": False}, format="json")
355393
assert response.status_code == status.HTTP_400_BAD_REQUEST
356394

395+
def test_create_with_duplicate_name_for_same_user_returns_400(self) -> None:
396+
_create_filter_set(self.user, name="Duplicate")
397+
url = reverse("v1:filter_sets-list")
398+
response = self.client.post(url, {"name": "Duplicate", "filters": _FILTERS_A, "is_default": False}, format="json")
399+
assert response.status_code == status.HTTP_400_BAD_REQUEST
400+
assert "name" in response.data
401+
402+
def test_create_with_duplicate_name_for_different_user_returns_201(self) -> None:
403+
other_user = _create_user("other")
404+
_create_filter_set(other_user, name="Shared Name")
405+
url = reverse("v1:filter_sets-list")
406+
response = self.client.post(
407+
url, {"name": "Shared Name", "filters": _FILTERS_A, "is_default": False}, format="json"
408+
)
409+
assert response.status_code == status.HTTP_201_CREATED
410+
357411

358412
# =============================================================================
359413
# PUT /filter-sets/{id} — full update
@@ -423,6 +477,22 @@ def test_update_does_not_change_owner(self) -> None:
423477
self.filter_set.refresh_from_db()
424478
assert self.filter_set.user == self.user
425479

480+
def test_update_rejects_rename_to_existing_name(self) -> None:
481+
"""PUT that renames to a name already used by another of the user's filter sets must fail."""
482+
_create_filter_set(self.user, name="Taken")
483+
url = reverse("v1:filter_sets-detail", kwargs={"pk": self.filter_set.pk})
484+
response = self.client.put(url, {"name": "Taken", "filters": _FILTERS_A, "is_default": False}, format="json")
485+
assert response.status_code == status.HTTP_400_BAD_REQUEST
486+
assert "name" in response.data
487+
488+
def test_update_allows_keeping_its_own_name(self) -> None:
489+
"""PUT that keeps the same name on the same instance must not be treated as a duplicate."""
490+
url = reverse("v1:filter_sets-detail", kwargs={"pk": self.filter_set.pk})
491+
response = self.client.put(
492+
url, {"name": "Original", "filters": _FILTERS_B, "is_default": False}, format="json"
493+
)
494+
assert response.status_code == status.HTTP_200_OK
495+
426496

427497
# =============================================================================
428498
# DELETE /filter-sets/{id} — destroy

0 commit comments

Comments
 (0)