Browse Source

Merge master

James Kiger 9 months ago
parent
commit
3b9d65444c

+ 10 - 1
apps/projects/schema.py

@@ -8,7 +8,16 @@ from glitchtip.schema import CamelSchema
 from .models import Project
 
 
-class ProjectSchema(CamelSchema, ModelSchema):
+class NameSlugProjectSchema(CamelSchema, ModelSchema):
+    class Meta:
+        model = Project
+        fields = [
+            "name",
+            "slug",
+        ]
+
+
+class ProjectSchema(NameSlugProjectSchema):
     avatar: dict[str, Optional[str]] = {"avatarType": "", "avatarUuid": None}
     color: str = ""
     features: list = []

+ 287 - 0
apps/releases/api.py

@@ -0,0 +1,287 @@
+from typing import Optional
+
+from django.http import Http404, HttpResponse
+from django.shortcuts import aget_object_or_404
+from ninja import Router
+from ninja.errors import ValidationError
+
+from apps.organizations_ext.models import Organization
+from apps.projects.models import Project
+from glitchtip.api.authentication import AuthHttpRequest
+from glitchtip.api.pagination import paginate
+from glitchtip.api.permissions import has_permission
+
+from .models import Release, ReleaseFile
+from .schema import (
+    ReleaseBase,
+    ReleaseFileSchema,
+    ReleaseIn,
+    ReleaseSchema,
+    ReleaseUpdate,
+)
+
+router = Router()
+
+
+"""
+POST /organizations/{organization_slug}/releases/
+POST /organizations/{organization_slug}/releases/{version}/deploys/ (Not implemented)
+GET /organizations/{organization_slug}/releases/
+GET /organizations/{organization_slug}/releases/{version}/
+PUT /organizations/{organization_slug}/releases/{version}/
+DELETE /organizations/{organization_slug}/releases/{version}/
+GET /organizations/{organization_slug}/releases/{version}/files/
+GET /projects/{organization_slug}/{project_slug}/releases/ (sentry undocumented)
+GET /projects/{organization_slug}/{project_slug}/releases/{version}/ (sentry undocumented)
+DELETE /projects/{organization_slug}/{project_slug}/releases/{version}/ (sentry undocumented)
+POST /projects/{organization_slug}/{project_slug}/releases/ (sentry undocumented)
+"""
+
+
+def get_releases_queryset(
+    organization_slug: str,
+    user_id: int,
+    id: Optional[int] = None,
+    version: Optional[str] = None,
+    project_slug: Optional[str] = None,
+):
+    qs = Release.objects.filter(
+        organization__slug=organization_slug, organization__users=user_id
+    )
+    if id:
+        qs = qs.filter(id=id)
+    if version:
+        qs = qs.filter(version=version)
+    if project_slug:
+        qs = qs.filter(projects__slug=project_slug)
+    return qs.prefetch_related("projects")
+
+
+def get_release_files_queryset(
+    organization_slug: str,
+    user_id: int,
+    version: Optional[str] = None,
+    project_slug: Optional[str] = None,
+):
+    qs = ReleaseFile.objects.filter(
+        release__organization__slug=organization_slug,
+        release__organization__users=user_id,
+    )
+    if version:
+        qs = qs.filter(release__version=version)
+    if project_slug:
+        qs = qs.filter(release__projects__slug=project_slug)
+    return qs.select_related("file")
+
+
+@router.post(
+    "/organizations/{slug:organization_slug}/releases/",
+    response={201: ReleaseSchema},
+    by_alias=True,
+)
+@has_permission(["project:releases"])
+async def create_release(
+    request: AuthHttpRequest, organization_slug: str, payload: ReleaseIn
+):
+    user_id = request.auth.user_id
+    organization = await aget_object_or_404(
+        Organization, slug=organization_slug, users=user_id
+    )
+    data = payload.dict()
+    projects = [
+        project_id
+        async for project_id in Project.objects.filter(
+            slug__in=data.pop("projects"), organization=organization
+        ).values_list("id", flat=True)
+    ]
+    if not projects:
+        raise ValidationError([{"projects": "Require at least one valid project"}])
+    release = await Release.objects.acreate(organization=organization, **data)
+    await release.projects.aadd(*projects)
+    return await get_releases_queryset(organization_slug, user_id, id=release.id).aget()
+
+
+@router.post(
+    "/projects/{slug:organization_slug}/{slug:project_slug}/releases/",
+    response={201: ReleaseSchema},
+    by_alias=True,
+)
+@has_permission(["project:releases"])
+async def create_project_release(
+    request: AuthHttpRequest, organization_slug: str, project_slug, payload: ReleaseBase
+):
+    user_id = request.auth.user_id
+    project = await aget_object_or_404(
+        Project.objects.select_related("organization"),
+        slug=project_slug,
+        organization__slug=organization_slug,
+        organization__users=user_id,
+    )
+    data = payload.dict()
+    version = data.pop("version")
+    release, _ = await Release.objects.aget_or_create(
+        organization=project.organization, version=version, defaults=data
+    )
+    await release.projects.aadd(project)
+    return await get_releases_queryset(organization_slug, user_id, id=release.id).aget()
+
+
+@router.get(
+    "/organizations/{slug:organization_slug}/releases/",
+    response=list[ReleaseSchema],
+    by_alias=True,
+)
+@paginate
+@has_permission(["project:releases"])
+async def list_releases(
+    request: AuthHttpRequest, response: HttpResponse, organization_slug: str
+):
+    return get_releases_queryset(organization_slug, request.auth.user_id)
+
+
+@router.get(
+    "/organizations/{slug:organization_slug}/releases/{slug:version}/",
+    response=ReleaseSchema,
+    by_alias=True,
+)
+@has_permission(["project:releases"])
+async def get_release(request: AuthHttpRequest, organization_slug: str, version: str):
+    return await aget_object_or_404(
+        get_releases_queryset(organization_slug, request.auth.user_id, version=version)
+    )
+
+
+@router.put(
+    "/organizations/{slug:organization_slug}/releases/{slug:version}/",
+    response=ReleaseSchema,
+    by_alias=True,
+)
+@has_permission(["project:releases"])
+async def update_release(
+    request: AuthHttpRequest,
+    organization_slug: str,
+    version: str,
+    payload: ReleaseUpdate,
+):
+    user_id = request.auth.user_id
+    release = await aget_object_or_404(
+        get_releases_queryset(organization_slug, user_id, version=version)
+    )
+    for attr, value in payload.dict().items():
+        setattr(release, attr, value)
+    await release.asave()
+    return await get_releases_queryset(organization_slug, user_id, id=release.id).aget()
+
+
+@router.delete(
+    "/organizations/{slug:organization_slug}/releases/{slug:version}/",
+    response={204: None},
+)
+@has_permission(["project:releases"])
+async def delete_organization_release(
+    request: AuthHttpRequest, organization_slug: str, version: str
+):
+    result, _ = await get_releases_queryset(
+        organization_slug, request.auth.user_id, version=version
+    ).adelete()
+    if not result:
+        raise Http404
+    return 204, None
+
+
+@router.get(
+    "/projects/{slug:organization_slug}/{slug:project_slug}/releases/",
+    response=list[ReleaseSchema],
+    by_alias=True,
+)
+@paginate
+@has_permission(["project:releases"])
+async def list_project_releases(
+    request: AuthHttpRequest,
+    response: HttpResponse,
+    organization_slug: str,
+    project_slug: str,
+):
+    return get_releases_queryset(
+        organization_slug, request.auth.user_id, project_slug=project_slug
+    )
+
+
+@router.get(
+    "/projects/{slug:organization_slug}/{slug:project_slug}/releases/{slug:version}/",
+    response=ReleaseSchema,
+    by_alias=True,
+)
+@has_permission(["project:releases"])
+async def get_project_release(
+    request: AuthHttpRequest, organization_slug: str, project_slug: str, version: str
+):
+    return await aget_object_or_404(
+        get_releases_queryset(
+            organization_slug,
+            request.auth.user_id,
+            project_slug=project_slug,
+            version=version,
+        )
+    )
+
+
+@router.delete(
+    "/projects/{slug:organization_slug}/{slug:project_slug}/releases/{slug:version}/",
+    response={204: None},
+)
+@has_permission(["project:releases"])
+async def delete_project_release(
+    request: AuthHttpRequest, organization_slug: str, project_slug: str, version: str
+):
+    result, _ = await get_releases_queryset(
+        organization_slug,
+        request.auth.user_id,
+        version=version,
+        project_slug=project_slug,
+    ).adelete()
+    if not result:
+        raise Http404
+    return 204, None
+
+
+@router.get(
+    "/projects/{slug:organization_slug}/{slug:project_slug}/releases/{slug:version}/files/",
+    response=list[ReleaseFileSchema],
+    by_alias=True,
+)
+@paginate
+@has_permission(["project:releases"])
+async def list_project_release_files(
+    request: AuthHttpRequest,
+    response: HttpResponse,
+    organization_slug: str,
+    project_slug: str,
+    version: str,
+):
+    return get_release_files_queryset(
+        organization_slug,
+        request.auth.user_id,
+        project_slug=project_slug,
+        version=version,
+    )
+
+
+@router.get(
+    "/organizations/{slug:organization_slug}/releases/{slug:version}/files/",
+    response=list[ReleaseFileSchema],
+    by_alias=True,
+)
+@paginate
+@has_permission(["project:releases"])
+async def list_release_files(
+    request: AuthHttpRequest,
+    response: HttpResponse,
+    organization_slug: str,
+    version: str,
+):
+    return get_release_files_queryset(
+        organization_slug,
+        request.auth.user_id,
+        version=version,
+    )

+ 57 - 0
apps/releases/schema.py

@@ -0,0 +1,57 @@
+from datetime import datetime
+from typing import Optional
+
+from django.utils.timezone import now
+from ninja import Field, ModelSchema, Schema
+
+from apps.projects.schema import NameSlugProjectSchema
+from glitchtip.schema import CamelSchema
+
+from .models import Release, ReleaseFile
+
+
+class ReleaseUpdate(Schema):
+    ref: Optional[str] = None
+    released: Optional[datetime] = Field(alias="dateReleased", default_factory=now)
+
+
+class ReleaseBase(ReleaseUpdate):
+    version: str = Field(serialization_alias="shortVersion")
+
+
+class ReleaseIn(ReleaseBase):
+    projects: list[str]
+
+
+class ReleaseSchema(CamelSchema, ReleaseBase, ModelSchema):
+    created: datetime = Field(serialization_alias="dateCreated")
+    released: Optional[datetime] = Field(serialization_alias="dateReleased")
+    short_version: str = Field(validation_alias="version")
+    projects: list[NameSlugProjectSchema]
+
+    class Meta:
+        model = Release
+        fields = [
+            "url",
+            "data",
+            "deploy_count",
+            "projects",
+            "version",
+        ]
+
+
+class ReleaseFileSchema(CamelSchema, ModelSchema):
+    id: str
+    created: datetime = Field(serialization_alias="dateCreated")
+    sha1: Optional[str] = Field(validation_alias="file.checksum", default=None)
+    headers: Optional[dict[str, str]] = Field(
+        validation_alias="file.headers", default=None
+    )
+    size: Optional[int] = Field(validation_alias="file.size", default=None)
+
+    class Meta:
+        model = ReleaseFile
+        fields = ["name"]
+
+    class Config(CamelSchema.Config):
+        coerce_numbers_to_str = True

+ 0 - 12
apps/releases/serializers.py

@@ -29,18 +29,6 @@ class ReleaseSerializer(serializers.ModelSerializer):
         )
         lookup_field = "version"
 
-    def create(self, validated_data):
-        version = validated_data.pop("version")
-        organization = validated_data.pop("organization")
-        instance, _ = Release.objects.get_or_create(
-            version=version, organization=organization, defaults=validated_data
-        )
-        return instance
-
-
-class ReleaseUpdateSerializer(ReleaseSerializer):
-    version = serializers.CharField(read_only=True)
-
 
 class ReleaseFileSerializer(serializers.ModelSerializer):
     file = serializers.FileField(write_only=True, allow_empty_file=True)

+ 69 - 13
apps/releases/tests/test_api.py

@@ -1,19 +1,27 @@
+from django.test import TestCase
 from django.urls import reverse
 from model_bakery import baker
 
 from apps.organizations_ext.models import OrganizationUserRole
-from glitchtip.test_utils.test_case import GlitchTipTestCase
+from glitchtip.test_utils.test_case import GlitchTipTestCaseMixin
 
 from ..models import Release
 
 
-class ReleaseAPITestCase(GlitchTipTestCase):
+class ReleaseAPITestCase(GlitchTipTestCaseMixin, TestCase):
     def setUp(self):
-        self.create_user_and_project()
+        self.create_logged_in_user()
+
+    def test_create(self):
+        url = reverse("api:create_release", args=[self.organization.slug])
+        data = {"version": "1.0", "projects": [self.project.slug]}
+        res = self.client.post(url, data, content_type="application/json")
+        self.assertContains(res, data["version"], status_code=201)
+        self.assertTrue(Release.objects.filter(version=data["version"]).exists())
 
     def test_list(self):
         url = reverse(
-            "organization-releases-list",
+            "api:list_releases",
             kwargs={"organization_slug": self.organization.slug},
         )
         release1 = baker.make("releases.Release", organization=self.organization)
@@ -29,7 +37,7 @@ class ReleaseAPITestCase(GlitchTipTestCase):
     def test_retrieve(self):
         release = baker.make("releases.Release", organization=self.organization)
         url = reverse(
-            "organization-releases-detail",
+            "api:get_release",
             kwargs={
                 "organization_slug": self.organization.slug,
                 "version": release.version,
@@ -41,21 +49,20 @@ class ReleaseAPITestCase(GlitchTipTestCase):
     def test_finalize(self):
         release = baker.make("releases.Release", organization=self.organization)
         url = reverse(
-            "organization-releases-detail",
+            "api:update_release",
             kwargs={
                 "organization_slug": release.organization.slug,
                 "version": release.version,
             },
         )
-        data = {"projects": ["fun"], "dateReleased": "2021-09-04T14:08:57.388525996Z"}
-        res = self.client.put(url, data)
-        self.assertEqual(res.status_code, 200)
-        self.assertEqual(res.data["dateReleased"], "2021-09-04T14:08:57.388525Z")
+        data = {"dateReleased": "2021-09-04T14:08:57.388525996Z"}
+        res = self.client.put(url, data, content_type="application/json")
+        self.assertContains(res, data["dateReleased"][:14])
 
-    def test_destroy(self):
+    def test_destroy_org_release(self):
         release1 = baker.make("releases.Release", organization=self.organization)
         url = reverse(
-            "organization-releases-detail",
+            "api:delete_organization_release",
             kwargs={
                 "organization_slug": release1.organization.slug,
                 "version": release1.version,
@@ -67,7 +74,7 @@ class ReleaseAPITestCase(GlitchTipTestCase):
 
         release2 = baker.make("releases.Release")
         url = reverse(
-            "organization-releases-detail",
+            "api:delete_organization_release",
             kwargs={
                 "organization_slug": release2.organization.slug,
                 "version": release2.version,
@@ -76,3 +83,52 @@ class ReleaseAPITestCase(GlitchTipTestCase):
         res = self.client.delete(url)
         self.assertEqual(res.status_code, 404)
         self.assertEqual(Release.objects.all().count(), 1)
+
+    def test_project_list(self):
+        url = reverse(
+            "api:list_project_releases",
+            kwargs={
+                "organization_slug": self.organization.slug,
+                "project_slug": self.project.slug,
+            },
+        )
+        project2 = baker.make("projects.Project", organization=self.organization)
+        release1 = baker.make(
+            "releases.Release",
+            organization=self.organization,
+            projects=[self.project, project2],
+        )
+        release2 = baker.make("releases.Release", organization=self.organization)
+        res = self.client.get(url)
+        self.assertContains(res, release1.version)
+        self.assertNotContains(res, release2.version)  # User not in project
+        self.assertEqual(len(res.json()), 1)
+
+    def test_destroy_project_release(self):
+        release = baker.make(
+            "releases.Release", organization=self.organization, projects=[self.project]
+        )
+        other_project= baker.make("projects.Project", organization=self.organization)
+        url = reverse(
+            "api:delete_project_release",
+            kwargs={
+                "organization_slug": release.organization.slug,
+                "project_slug": other_project.slug,
+                "version": release.version,
+            },
+        )
+        res = self.client.delete(url)
+        self.assertEqual(res.status_code, 404)
+        self.assertEqual(Release.objects.all().count(), 1)
+
+        url = reverse(
+            "api:delete_project_release",
+            kwargs={
+                "organization_slug": release.organization.slug,
+                "project_slug": self.project.slug,
+                "version": release.version,
+            },
+        )
+        res = self.client.delete(url)
+        self.assertEqual(res.status_code, 204)
+        self.assertEqual(Release.objects.all().count(), 0)

+ 43 - 23
apps/releases/tests/test_permissions.py

@@ -4,7 +4,6 @@ from django.core.files.uploadedfile import InMemoryUploadedFile
 from django.urls import reverse
 from model_bakery import baker
 
-from apps.organizations_ext.models import OrganizationUserRole
 from glitchtip.test_utils.test_case import APIPermissionTestCase
 
 
@@ -17,24 +16,43 @@ class ReleaseAPIPermissionTests(APIPermissionTestCase):
         self.release.projects.add(self.project)
 
         self.organization_list_url = reverse(
-            "organization-releases-list",
+            "api:list_releases",
             kwargs={"organization_slug": self.organization.slug},
         )
         self.project_list_url = reverse(
-            "project-releases-list",
-            kwargs={"project_pk": self.organization.slug + "/" + self.project.slug},
+            "api:list_project_releases",
+            kwargs={
+                "organization_slug": self.organization.slug,
+                "project_slug": self.project.slug,
+            },
         )
         self.organization_detail_url = reverse(
-            "organization-releases-detail",
+            "api:get_release",
             kwargs={
                 "organization_slug": self.organization.slug,
                 "version": self.release.version,
             },
         )
         self.project_detail_url = reverse(
-            "project-releases-detail",
+            "api:get_project_release",
             kwargs={
-                "project_pk": self.organization.slug + "/" + self.project.slug,
+                "organization_slug": self.organization.slug,
+                "project_slug": self.project.slug,
+                "version": self.release.version,
+            },
+        )
+        self.org_delete_url = reverse(
+            "api:delete_organization_release",
+            kwargs={
+                "organization_slug": self.organization.slug,
+                "version": self.release.version,
+            },
+        )
+        self.project_delete_url = reverse(
+            "api:delete_project_release",
+            kwargs={
+                "organization_slug": self.organization.slug,
+                "project_slug": self.project.slug,
                 "version": self.release.version,
             },
         )
@@ -42,14 +60,14 @@ class ReleaseAPIPermissionTests(APIPermissionTestCase):
     def test_list(self):
         self.assertGetReqStatusCode(self.organization_list_url, 403)
         self.assertGetReqStatusCode(self.project_list_url, 403)
-        self.auth_token.add_permission("project:read")
+        self.auth_token.add_permission("project:releases")
         self.assertGetReqStatusCode(self.organization_list_url, 200)
         self.assertGetReqStatusCode(self.project_list_url, 200)
 
     def test_retrieve(self):
         self.assertGetReqStatusCode(self.organization_detail_url, 403)
         self.assertGetReqStatusCode(self.project_detail_url, 403)
-        self.auth_token.add_permission("project:read")
+        self.auth_token.add_permission("project:releases")
         self.assertGetReqStatusCode(self.organization_detail_url, 200)
         self.assertGetReqStatusCode(self.project_detail_url, 200)
 
@@ -65,25 +83,26 @@ class ReleaseAPIPermissionTests(APIPermissionTestCase):
 
     def test_create(self):
         self.auth_token.add_permission("project:read")
-        data = {"version": "new-version"}
+        data = {"version": "new-version", "projects": [self.project.slug]}
         self.assertPostReqStatusCode(self.organization_list_url, data, 403)
         self.assertPostReqStatusCode(self.project_list_url, data, 403)
         self.auth_token.add_permission("project:releases")
-        # Unsure if this should be supported
-        # self.assertPostReqStatusCode(self.organization_list_url, data, 201)
+        self.assertPostReqStatusCode(self.organization_list_url, data, 201)
         self.assertPostReqStatusCode(self.project_list_url, data, 201)
 
-    def test_destroy(self):
+    def test_org_release_destroy(self):
         self.auth_token.add_permissions(["project:read", "project:write"])
-        self.assertDeleteReqStatusCode(self.project_detail_url, 403)
+        self.assertDeleteReqStatusCode(self.org_delete_url, 403)
 
         self.auth_token.add_permission("project:releases")
-        self.assertDeleteReqStatusCode(self.project_detail_url, 204)
+        self.assertDeleteReqStatusCode(self.org_delete_url, 204)
+
+    def test_project_release_destroy(self):
+        self.auth_token.add_permissions(["project:read", "project:write"])
+        self.assertDeleteReqStatusCode(self.project_delete_url, 403)
 
-    def test_user_destroy(self):
-        self.client.force_login(self.user)
-        self.set_user_role(OrganizationUserRole.MEMBER)
-        self.assertDeleteReqStatusCode(self.project_detail_url, 204)
+        self.auth_token.add_permission("project:releases")
+        self.assertDeleteReqStatusCode(self.project_delete_url, 204)
 
     def test_update(self):
         self.auth_token.add_permission("project:read")
@@ -105,10 +124,11 @@ class ReleaseFileAPIPermissionTests(APIPermissionTestCase):
         self.release_file = baker.make("releases.ReleaseFile", release=self.release)
 
         self.list_url = reverse(
-            "files-list",
+            "api:list_project_release_files",
             kwargs={
-                "project_pk": self.organization.slug + "/" + self.project.slug,
-                "release_version": self.release.version,
+                "organization_slug": self.organization.slug,
+                "project_slug": self.project.slug,
+                "version": self.release.version,
             },
         )
         self.detail_url = reverse(
@@ -122,7 +142,7 @@ class ReleaseFileAPIPermissionTests(APIPermissionTestCase):
 
     def test_list(self):
         self.assertGetReqStatusCode(self.list_url, 403)
-        self.auth_token.add_permission("project:read")
+        self.auth_token.add_permission("project:releases")
         self.assertGetReqStatusCode(self.list_url, 200)
 
     def test_retrieve(self):

+ 0 - 35
apps/releases/views.py

@@ -4,7 +4,6 @@ from rest_framework.response import Response
 
 from apps.files.tasks import assemble_artifacts_task
 from apps.organizations_ext.models import Organization
-from apps.projects.models import Project
 
 from .models import Release, ReleaseFile
 from .permissions import ReleaseFilePermission, ReleasePermission
@@ -12,7 +11,6 @@ from .serializers import (
     AssembleSerializer,
     ReleaseFileSerializer,
     ReleaseSerializer,
-    ReleaseUpdateSerializer,
 )
 
 
@@ -30,25 +28,6 @@ class ReleaseViewSet(viewsets.ModelViewSet):
     lookup_field = "version"
     lookup_value_regex = "[^/]+"
 
-    def get_serializer_class(self):
-        serializer_class = self.serializer_class
-        if self.request.method == "PUT":
-            serializer_class = ReleaseUpdateSerializer
-        return serializer_class
-
-    def get_queryset(self):
-        if not self.request.user.is_authenticated:
-            return self.queryset.none()
-
-        queryset = self.queryset.filter(organization__users=self.request.user)
-        organization_slug = self.kwargs.get("organization_slug")
-        if organization_slug:
-            queryset = queryset.filter(organization__slug=organization_slug)
-        project_slug = self.kwargs.get("project_slug")
-        if project_slug:
-            queryset = queryset.filter(projects__slug=project_slug)
-        return queryset
-
     def get_organization(self):
         try:
             return Organization.objects.get(
@@ -58,19 +37,6 @@ class ReleaseViewSet(viewsets.ModelViewSet):
         except Organization.DoesNotExist:
             raise exceptions.ValidationError("Organization does not exist")
 
-    def perform_create(self, serializer):
-        organization = self.get_organization()
-        try:
-            project = Project.objects.get(
-                slug=self.kwargs.get("project_slug"),
-                organization=organization,
-            )
-        except Project.DoesNotExist:
-            raise exceptions.ValidationError("Project does not exist")
-
-        release = serializer.save(organization=organization)
-        release.projects.add(project)
-
     @action(detail=True, methods=["post"])
     def assemble(self, request, organization_slug: str, version: str):
         organization = self.get_organization()
@@ -94,7 +60,6 @@ class ReleaseViewSet(viewsets.ModelViewSet):
 
 class ReleaseFileViewSet(
     mixins.CreateModelMixin,
-    mixins.ListModelMixin,
     mixins.DestroyModelMixin,
     mixins.RetrieveModelMixin,
     viewsets.GenericViewSet,

+ 1 - 0
apps/teams/tests/test_api_permissions.py

@@ -64,6 +64,7 @@ class TeamAPIPermissionTests(APIPermissionTestCase):
         self.assertDeleteReqStatusCode(self.detail_url, 204)
 
     def test_user_destroy(self):
+        self.set_client_credentials(None)
         self.client.force_login(self.user)
         self.set_user_role(OrganizationUserRole.MEMBER)
         self.assertDeleteReqStatusCode(self.detail_url, 404)

+ 2 - 0
glitchtip/api/api.py

@@ -16,6 +16,7 @@ from apps.event_ingest.api import router as event_ingest_router
 from apps.event_ingest.embed_api import router as embed_router
 from apps.importer.api import router as importer_router
 from apps.issue_events.api import router as issue_events_router
+from apps.releases.api import router as releases_router
 from apps.teams.api import router as teams_router
 from apps.users.utils import ais_user_registration_open
 from glitchtip.constants import SOCIAL_ADAPTER_MAP
@@ -44,6 +45,7 @@ api.add_router("", event_ingest_router)
 api.add_router("0", importer_router)
 api.add_router("0", issue_events_router)
 api.add_router("0", teams_router)
+api.add_router("0", releases_router)
 api.add_router("embed", embed_router)
 
 

+ 10 - 10
poetry.lock

@@ -288,17 +288,17 @@ files = [
 
 [[package]]
 name = "boto3"
-version = "1.34.108"
+version = "1.34.109"
 description = "The AWS SDK for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "boto3-1.34.108-py3-none-any.whl", hash = "sha256:3601267d76cac17f1d4595c3d8d968dc15be074b79bfa3985187a02b328a0a5f"},
-    {file = "boto3-1.34.108.tar.gz", hash = "sha256:677723295151d29ff9b363598a20c1997c4e2af7e50669d9e428b757fe586a10"},
+    {file = "boto3-1.34.109-py3-none-any.whl", hash = "sha256:50a0f24dd737529ae489a3586f260b9220c6aede1ae7851fa4f33878c8805ef8"},
+    {file = "boto3-1.34.109.tar.gz", hash = "sha256:98d389562e03a46fd79fea5f988e9e6032674a0c3e9e42c06941ec588b7e1070"},
 ]
 
 [package.dependencies]
-botocore = ">=1.34.108,<1.35.0"
+botocore = ">=1.34.109,<1.35.0"
 jmespath = ">=0.7.1,<2.0.0"
 s3transfer = ">=0.10.0,<0.11.0"
 
@@ -307,13 +307,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
 
 [[package]]
 name = "botocore"
-version = "1.34.108"
+version = "1.34.109"
 description = "Low-level, data-driven core of boto 3."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "botocore-1.34.108-py3-none-any.whl", hash = "sha256:b1b9d00804267669c5fcc36489269f7e9c43580c30f0885fbf669cf73cec720b"},
-    {file = "botocore-1.34.108.tar.gz", hash = "sha256:384c9408c447631475dc41fdc9bf2e0f30c29c420d96bfe8b468bdc2bace3e13"},
+    {file = "botocore-1.34.109-py3-none-any.whl", hash = "sha256:647059a81acbfab85c694b9b57b0ef200dde071449fb8837f10aef9c6472730d"},
+    {file = "botocore-1.34.109.tar.gz", hash = "sha256:804821252597821f7223cb3bfca2a2a513ae0bb9a71e8e22605aff6866e13e71"},
 ]
 
 [package.dependencies]
@@ -3726,13 +3726,13 @@ crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"]
 
 [[package]]
 name = "sentry-sdk"
-version = "2.1.1"
+version = "2.2.0"
 description = "Python client for Sentry (https://sentry.io)"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "sentry_sdk-2.1.1-py2.py3-none-any.whl", hash = "sha256:99aeb78fb76771513bd3b2829d12613130152620768d00cd3e45ac00cb17950f"},
-    {file = "sentry_sdk-2.1.1.tar.gz", hash = "sha256:95d8c0bb41c8b0bc37ab202c2c4a295bb84398ee05f4cdce55051cd75b926ec1"},
+    {file = "sentry_sdk-2.2.0-py2.py3-none-any.whl", hash = "sha256:674f58da37835ea7447fe0e34c57b4a4277fad558b0a7cb4a6c83bcb263086be"},
+    {file = "sentry_sdk-2.2.0.tar.gz", hash = "sha256:70eca103cf4c6302365a9d7cf522e7ed7720828910eb23d43ada8e50d1ecda9d"},
 ]
 
 [package.dependencies]

Some files were not shown because too many files changed in this diff