mirror of
https://github.com/bugsink/bugsink.git
synced 2025-12-19 19:49:44 -06:00
17
bugsink/api_fields.py
Normal file
17
bugsink/api_fields.py
Normal 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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
77
projects/test_api.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
65
teams/test_api.py
Normal 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.']})
|
||||
Reference in New Issue
Block a user