Teams & Projects API

See #146
This commit is contained in:
Klaas van Schelven
2025-09-11 09:54:12 +02:00
parent c222581951
commit 30ae7881aa
7 changed files with 335 additions and 20 deletions

17
bugsink/api_fields.py Normal file
View File

@@ -0,0 +1,17 @@
from rest_framework import serializers
class EnumLowercaseChoiceField(serializers.ChoiceField):
def __init__(self, enum_cls, **kwargs):
self._to_value = {member.name.lower(): member.value for member in enum_cls}
super().__init__(choices=self._to_value, **kwargs)
self._to_name = {member.value: member.name.lower() for member in enum_cls}
def to_representation(self, value):
# fails hard for invalid values (shouldn't happen, would imply data corruption)
return self._to_name[value]
def to_internal_value(self, data):
key = super().to_internal_value(data)
return self._to_value[key]

View File

@@ -1,9 +1,53 @@
from django.shortcuts import get_object_or_404
from rest_framework import viewsets
from .models import Project
from .serializers import ProjectSerializer
from .serializers import (
ProjectListSerializer,
ProjectDetailSerializer,
ProjectCreateUpdateSerializer,
)
class ProjectViewSet(viewsets.ModelViewSet):
queryset = Project.objects.all().order_by('id')
serializer_class = ProjectSerializer
"""
/api/canonical/0/projects/
GET /projects/ → list ordered by name ASC, hides soft-deleted, optional ?team=<uuid> filter
GET /projects/{pk}/ → detail (pure PK)
POST /projects/ → create {team, name, visibility?}
PATCH /projects/{pk}/ → minimal updates
DELETE → 405
"""
queryset = Project.objects.all()
http_method_names = ["get", "post", "patch", "head", "options"]
def filter_queryset(self, queryset):
if self.action != "list":
return queryset
query_params = self.request.query_params
# Hide soft-deleted in lists
qs = queryset.filter(is_deleted=False)
# Optional team filter (no hard requirement; avoids guessing UI rules)
team_id = query_params.get("team")
if team_id:
qs = qs.filter(team=team_id)
# Explicit ordering aligned with UI
return qs.order_by("name")
def get_object(self):
# Pure PK lookup (bypass filter_queryset)
queryset = self.get_queryset()
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
obj = get_object_or_404(queryset, **{self.lookup_field: self.kwargs[lookup_url_kwarg]})
self.check_object_permissions(self.request, obj)
return obj
def get_serializer_class(self):
if self.action in ("create", "partial_update"):
return ProjectCreateUpdateSerializer
if self.action == "retrieve":
return ProjectDetailSerializer
return ProjectListSerializer

View File

@@ -1,9 +1,14 @@
from rest_framework import serializers
from bugsink.api_fields import EnumLowercaseChoiceField
from .models import Project
from teams.models import Team
from .models import Project, ProjectVisibility
class ProjectSerializer(serializers.ModelSerializer):
class ProjectListSerializer(serializers.ModelSerializer):
visibility = EnumLowercaseChoiceField(ProjectVisibility)
dsn = serializers.CharField(read_only=True)
class Meta:
model = Project
fields = [
@@ -12,8 +17,7 @@ class ProjectSerializer(serializers.ModelSerializer):
"name",
"slug",
"is_deleted",
"sentry_key", # or just: "dsn"
# users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, through="ProjectMembership")
"dsn",
"digested_event_count",
"stored_event_count",
"alert_on_new_issue",
@@ -22,3 +26,63 @@ class ProjectSerializer(serializers.ModelSerializer):
"visibility",
"retention_max_event_count",
]
class ProjectDetailSerializer(serializers.ModelSerializer):
visibility = EnumLowercaseChoiceField(ProjectVisibility)
dsn = serializers.CharField(read_only=True)
class Meta:
model = Project
fields = [
"id",
"team",
"name",
"slug",
"is_deleted",
"dsn",
"digested_event_count",
"stored_event_count",
"alert_on_new_issue",
"alert_on_regression",
"alert_on_unmute",
"visibility",
"retention_max_event_count",
]
class ProjectCreateUpdateSerializer(serializers.ModelSerializer):
id = serializers.UUIDField(read_only=True)
team = serializers.PrimaryKeyRelatedField(queryset=Team.objects.all())
visibility = EnumLowercaseChoiceField(ProjectVisibility, required=False)
class Meta:
model = Project
fields = [
"id",
"team",
"name",
"visibility",
"alert_on_new_issue",
"alert_on_regression",
"alert_on_unmute",
"retention_max_event_count",
# "slug", auto-generated for uniqueness
# "is_deleted", must go through delete_deferred()
# "digested_event_count", system-managed counter
# "stored_event_count", system-managed counter
# "has_releases", system-managed flag
# "dsn", derived from base_url + ids + key
# "sentry_key", server-generated, not client-writable
# "quota_exceeded_until", system-managed quota state
# "next_quota_check", system-managed quota scheduler
]
# extra_kwargs: mark alert/retention fields optional on write (they have defaults)
extra_kwargs = {
"alert_on_new_issue": {"required": False},
"alert_on_regression": {"required": False},
"alert_on_unmute": {"required": False},
"retention_max_event_count": {"required": False},
}

77
projects/test_api.py Normal file
View File

@@ -0,0 +1,77 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from bsmain.models import AuthToken
from teams.models import Team
from projects.models import Project
class ProjectApiTests(TestCase):
def setUp(self):
self.client = APIClient()
token = AuthToken.objects.create()
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}")
self.team = Team.objects.create(name="Engineering")
def test_list_orders_by_name_and_hides_deleted(self):
Project.objects.create(team=self.team, name="Zebra")
Project.objects.create(team=self.team, name="Alpha")
Project.objects.create(team=self.team, name="Gamma", is_deleted=True)
r = self.client.get(reverse("api:project-list"))
self.assertEqual(r.status_code, 200)
names = [row["name"] for row in r.json()["results"]]
self.assertEqual(names, ["Alpha", "Zebra"])
def test_optional_team_filter(self):
other = Team.objects.create(name="Ops")
Project.objects.create(team=self.team, name="A1")
Project.objects.create(team=other, name="B1")
r = self.client.get(reverse("api:project-list"), {"team": str(self.team.id)})
self.assertEqual(r.status_code, 200)
names = [row["name"] for row in r.json()["results"]]
self.assertEqual(names, ["A1"])
def test_create_requires_team_and_name(self):
r1 = self.client.post(reverse("api:project-list"), {"name": "ProjOnly"}, format="json")
self.assertEqual(r1.status_code, 400)
self.assertIn("team", r1.json())
r2 = self.client.post(reverse("api:project-list"), {"team": str(self.team.id)}, format="json")
self.assertEqual(r2.status_code, 400)
self.assertIn("name", r2.json())
def test_create_and_retrieve(self):
r = self.client.post(
reverse("api:project-list"),
{"team": str(self.team.id), "name": "Core", "visibility": "team_members"},
format="json",
)
self.assertEqual(r.status_code, 201)
pid = r.json()["id"]
r2 = self.client.get(reverse("api:project-detail", args=[pid]))
self.assertEqual(r2.status_code, 200)
body = r2.json()
self.assertEqual(body["name"], "Core")
self.assertEqual(body["visibility"], "team_members")
self.assertIn("dsn", body) # read-only; present on detail
def test_patch_minimal(self):
p = Project.objects.create(team=self.team, name="Old")
r = self.client.patch(
reverse("api:project-detail", args=[p.id]),
{"name": "New", "alert_on_unmute": False},
format="json",
)
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(body["name"], "New")
self.assertFalse(body["alert_on_unmute"])
def test_delete_not_allowed(self):
p = Project.objects.create(team=self.team, name="Temp")
r = self.client.delete(reverse("api:project-detail", args=[p.id]))
self.assertEqual(r.status_code, 405)

View File

@@ -1,9 +1,42 @@
from django.shortcuts import get_object_or_404
from rest_framework import viewsets
from .models import Team
from .serializers import TeamSerializer
from .serializers import (
TeamListSerializer,
TeamDetailSerializer,
TeamCreateUpdateSerializer,
)
class TeamViewSet(viewsets.ReadOnlyModelViewSet): # create? then we need a way to deal with visibility
queryset = Team.objects.all().order_by('name') # ordering: TBD
serializer_class = TeamSerializer
class TeamViewSet(viewsets.ModelViewSet):
"""
/api/canonical/0/teams/
GET /teams/ → list ordered by name ASC
GET /teams/{pk}/ → detail (pure PK)
POST /teams/ → create {name, visibility?}
PATCH /teams/{pk}/ → minimal updates
DELETE → 405
"""
queryset = Team.objects.all()
http_method_names = ["get", "post", "patch", "head", "options"]
def filter_queryset(self, queryset):
if self.action != "list":
return queryset
# Explicit ordering aligned with UI
return queryset.order_by("name")
def get_object(self):
# Pure PK lookup (bypass filter_queryset)
queryset = self.get_queryset()
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
obj = get_object_or_404(queryset, **{self.lookup_field: self.kwargs[lookup_url_kwarg]})
self.check_object_permissions(self.request, obj)
return obj
def get_serializer_class(self):
if self.action in ("create", "partial_update"):
return TeamCreateUpdateSerializer
if self.action == "retrieve":
return TeamDetailSerializer
return TeamListSerializer

View File

@@ -1,13 +1,28 @@
from rest_framework import serializers
from .models import Team
from bugsink.api_fields import EnumLowercaseChoiceField
from .models import Team, TeamVisibility
class TeamSerializer(serializers.ModelSerializer):
class TeamListSerializer(serializers.ModelSerializer):
visibility = EnumLowercaseChoiceField(TeamVisibility)
class Meta:
model = Team
fields = [
"id",
"name",
# "visibility",
]
fields = ["id", "name", "visibility"]
class TeamDetailSerializer(serializers.ModelSerializer):
visibility = EnumLowercaseChoiceField(TeamVisibility)
class Meta:
model = Team
fields = ["id", "name", "visibility"]
class TeamCreateUpdateSerializer(serializers.ModelSerializer):
id = serializers.UUIDField(read_only=True)
visibility = EnumLowercaseChoiceField(TeamVisibility, required=False)
class Meta:
model = Team
fields = ["id", "name", "visibility"]

65
teams/test_api.py Normal file
View File

@@ -0,0 +1,65 @@
from django.test import TestCase as DjangoTestCase
from django.urls import reverse
from rest_framework.test import APIClient
from bsmain.models import AuthToken
from teams.models import Team
class TeamApiTests(DjangoTestCase):
def setUp(self):
self.client = APIClient()
token = AuthToken.objects.create()
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}")
def test_list_ordering_by_name(self):
Team.objects.create(name="Zeta")
Team.objects.create(name="Alpha")
Team.objects.create(name="Gamma")
r = self.client.get(reverse("api:team-list"))
self.assertEqual(r.status_code, 200)
names = [row["name"] for row in r.json()["results"]]
self.assertEqual(names, ["Alpha", "Gamma", "Zeta"])
def test_create_requires_name(self):
r = self.client.post(reverse("api:team-list"), {"visibility": "discoverable"}, format="json")
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json(), {"name": ["This field is required."]})
def test_create_minimal_and_retrieve(self):
r = self.client.post(
reverse("api:team-list"),
{"name": "Core Team", "visibility": "discoverable"},
format="json",
)
self.assertEqual(r.status_code, 201)
team_id = r.json()["id"]
r2 = self.client.get(reverse("api:team-detail", args=[team_id]))
self.assertEqual(r2.status_code, 200)
self.assertEqual(r2.json()["name"], "Core Team")
self.assertEqual(r2.json()["visibility"], "discoverable")
def test_patch_minimal(self):
team = Team.objects.create(name="Old Name")
r = self.client.patch(
reverse("api:team-detail", args=[team.id]),
{"name": "New Name"},
format="json",
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["name"], "New Name")
def test_delete_not_allowed(self):
team = Team.objects.create(name="Temp")
r = self.client.delete(reverse("api:team-detail", args=[team.id]))
self.assertEqual(r.status_code, 405)
def test_create_rejects_invalid_visibility(self):
r = self.client.post(
reverse("api:team-list"),
{"name": "Bad", "visibility": "nope"},
format="json",
)
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json(), {"visibility": ['"nope" is not a valid choice.']})