From c4c749d1e473d8db05cc528dbd4f43c7c3bc4884 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Tue, 9 Sep 2025 22:08:30 +0200 Subject: [PATCH] Issues API See #146 --- issues/api_views.py | 50 ++++++++++++++++++++++++++++- issues/test_api.py | 76 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 issues/test_api.py diff --git a/issues/api_views.py b/issues/api_views.py index 1dbdb8c..ca27d41 100644 --- a/issues/api_views.py +++ b/issues/api_views.py @@ -1,13 +1,61 @@ +from django.shortcuts import get_object_or_404 from rest_framework import viewsets +from rest_framework.exceptions import ValidationError from .models import Issue, Grouping from .serializers import IssueSerializer, GroupingSerializer class IssueViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Issue.objects.all().order_by('digest_order') # TBD + """ + LIST requires: ?project= + Optional: ?order=asc|desc (default: desc) + LIST ordered by last_seen + RETRIEVE is a pure PK lookup (soft-deletes implied) + """ + queryset = Issue.objects.filter(is_deleted=False) # hide soft-deleted issues; also satisfies router serializer_class = IssueSerializer + def get_queryset(self): + return self.queryset + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + if self.action != "list": + return queryset + + query_params = self.request.query_params + + project = query_params.get("project") + if not project: + # the below until we have a UI for cross-project Issue listing, i.e. #190 + raise ValidationError({"project": ["This field is required."]}) + + order = query_params.get("order", "desc") + if order not in ("asc", "desc"): + raise ValidationError({"order": ["Must be 'asc' or 'desc'."]}) + + ordering = "last_seen" if order == "asc" else "-last_seen" + return queryset.filter(project=project).order_by(ordering) + + def get_object(self): + """ + DRF's get_object(), but bypass filter_queryset for detail. + """ + # TODO: copy/paste from events/api_views.py + queryset = self.get_queryset() + + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + assert lookup_url_kwarg in self.kwargs, ( + 'Expected view %s to be called with a URL keyword argument named "%s".' + % (self.__class__.__name__, lookup_url_kwarg) + ) + + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + obj = get_object_or_404(queryset, **filter_kwargs) + self.check_object_permissions(self.request, obj) + return obj + class GroupingViewSet(viewsets.ReadOnlyModelViewSet): queryset = Grouping.objects.all().order_by('grouping_key') # TBD diff --git a/issues/test_api.py b/issues/test_api.py new file mode 100644 index 0000000..2b84bd4 --- /dev/null +++ b/issues/test_api.py @@ -0,0 +1,76 @@ +from django.test import TestCase as DjangoTestCase +from django.urls import reverse +from django.utils import timezone + +from rest_framework.test import APIClient + +from bsmain.models import AuthToken +from projects.models import Project +from issues.models import Issue +from issues.factories import get_or_create_issue +from events.factories import create_event_data + + +class IssueApiTests(DjangoTestCase): + def setUp(self): + self.client = APIClient() + token = AuthToken.objects.create() + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") + + self.project = Project.objects.create(name="Test Project") + + # create two distinct issues for ordering tests (different grouping keys) + data0 = create_event_data(exception_type="E0") + data1 = create_event_data(exception_type="E1") + + self.issue0, _ = get_or_create_issue(project=self.project, event_data=data0) + self.issue1, _ = get_or_create_issue(project=self.project, event_data=data1) + + # ensure deterministic last_seen ordering + now = timezone.now() + Issue.objects.filter(id=self.issue0.id).update(last_seen=now) + Issue.objects.filter(id=self.issue1.id).update(last_seen=now + timezone.timedelta(seconds=1)) + self.issue0.refresh_from_db() + self.issue1.refresh_from_db() + + def test_list_requires_project(self): + response = self.client.get(reverse("api:issue-list")) + self.assertEqual(response.status_code, 400) + self.assertEqual({"project": ["This field is required."]}, response.json()) + + def test_list_by_project_default_desc(self): + response = self.client.get(reverse("api:issue-list"), {"project": str(self.project.id)}) + self.assertEqual(response.status_code, 200) + ids = [row["id"] for row in response.json()["results"]] + self.assertEqual(ids[0], str(self.issue1.id)) + self.assertEqual(ids[1], str(self.issue0.id)) + + def test_list_by_project_order_asc(self): + response = self.client.get(reverse("api:issue-list"), {"project": str(self.project.id), "order": "asc"}) + self.assertEqual(response.status_code, 200) + ids = [row["id"] for row in response.json()["results"]] + self.assertEqual(ids[0], str(self.issue0.id)) + self.assertEqual(ids[1], str(self.issue1.id)) + + def test_list_rejects_bad_order(self): + response = self.client.get(reverse("api:issue-list"), {"project": str(self.project.id), "order": "sideways"}) + self.assertEqual(response.status_code, 400) + self.assertEqual({"order": ["Must be 'asc' or 'desc'."]}, response.json()) + + def test_detail_by_id(self): + url = reverse("api:issue-detail", args=[self.issue0.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["id"], str(self.issue0.id)) + + def test_detail_ignores_query_filters(self): + url = reverse("api:issue-detail", args=[self.issue0.id]) + response = self.client.get(url, {"project": "00000000-0000-0000-0000-000000000000", "order": "asc"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["id"], str(self.issue0.id)) + + def test_detail_404_on_is_deleted(self): + Issue.objects.filter(id=self.issue0.id).update(is_deleted=True) + url = reverse("api:issue-detail", args=[self.issue0.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, 404)