Releases API

Fix #191
See #146
This commit is contained in:
Klaas van Schelven
2025-09-10 10:39:22 +02:00
parent 829cea1a80
commit b0b2573d17
5 changed files with 162 additions and 20 deletions

View File

@@ -369,7 +369,7 @@ class BaseIngestAPIView(View):
# multiple events with the same event_id "don't happen" (i.e. are the result of badly misbehaving clients)
raise ValidationError("Event already exists", code="event_already_exists")
release = create_release_if_needed(project, event.release, event.ingested_at, issue)
release, _ = create_release_if_needed(project, event.release, event.ingested_at, issue)
if issue_created:
TurningPoint.objects.create(

View File

@@ -1,11 +1,39 @@
from rest_framework import viewsets
from rest_framework.exceptions import ValidationError
from .models import Release
from .serializers import ReleaseSerializer
from projects.models import Project
from .models import Release, ordered_releases
from .serializers import ReleaseListSerializer, ReleaseDetailSerializer, ReleaseCreateSerializer
class ReleaseViewSet(viewsets.ModelViewSet):
queryset = Release.objects.all().order_by('sort_epoch')
serializer_class = ReleaseSerializer
"""
LIST requires: ?project=<id>
Ordered by sort_epoch.
CREATE allowed. DELETE potential TODO.
"""
queryset = Release.objects.all()
serializer_class = ReleaseListSerializer
http_method_names = ["get", "post", "head", "options"]
# TODO: the idea of required filter-fields when listing; in particular: project is required.
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
if self.action != "list":
return queryset
query_params = self.request.query_params
project_id = query_params.get("project")
if not project_id:
raise ValidationError({"project": ["This field is required."]})
# application-ordering (as opposed to in-db); will have performance implications, but we do "correct first, fast
# later":
project = Project.objects.get(pk=project_id)
return ordered_releases(project=project)
def get_serializer_class(self):
if self.action == "create":
return ReleaseCreateSerializer
if self.action == "retrieve":
return ReleaseDetailSerializer
return ReleaseListSerializer

View File

@@ -110,7 +110,9 @@ def create_release_if_needed(project, version, timestamp, issue=None):
version = sanitize_version(version)
release, release_created = Release.objects.get_or_create(project=project, version=version)
release, release_created = Release.objects.get_or_create(project=project, version=version, defaults={
"date_released": timestamp,
})
if release_created and version != "":
if not project.has_releases:
project.has_releases = True
@@ -138,7 +140,7 @@ def create_release_if_needed(project, version, timestamp, issue=None):
issue.fixed_at = issue.fixed_at + release.version + "\n"
issue.is_resolved_by_next_release = False
return release
return release, release_created
def sanitize_version(version):

View File

@@ -1,19 +1,39 @@
from django.utils import timezone
from rest_framework import serializers
from .models import Release
from projects.models import Project
from rest_framework.exceptions import ValidationError
from .models import Release, create_release_if_needed
class ReleaseSerializer(serializers.ModelSerializer):
class ReleaseListSerializer(serializers.ModelSerializer):
class Meta:
model = Release
fields = ["id", "project", "version", "date_released"]
# TODO: distinguish read vs write fields
fields = [
"id",
"project",
"version",
"date_released",
"semver",
"is_semver",
"sort_epoch",
]
class ReleaseDetailSerializer(serializers.ModelSerializer):
class Meta:
model = Release
fields = ["id", "project", "version", "date_released", "semver", "is_semver", "sort_epoch"]
read_only_fields = ["semver", "is_semver", "sort_epoch"]
class ReleaseCreateSerializer(serializers.Serializer):
project = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all())
version = serializers.CharField(allow_blank=True)
timestamp = serializers.DateTimeField(required=False)
def create(self, validated_data):
project = validated_data["project"]
version = validated_data["version"]
timestamp = validated_data.get("timestamp") or timezone.now()
release, release_created = create_release_if_needed(project=project, version=version, timestamp=timestamp)
if not release_created:
raise ValidationError({"version": ["Release with this version already exists for the project."]})
return release
def to_representation(self, instance):
return ReleaseDetailSerializer(instance).data

92
releases/test_api.py Normal file
View File

@@ -0,0 +1,92 @@
from django.test import TestCase as DjangoTestCase
from django.urls import reverse
from rest_framework.test import APIClient
from bsmain.models import AuthToken
from projects.models import Project
from releases.models import ordered_releases
class ReleaseApiTests(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="RelProj")
def _create(self, version, **extra):
payload = {"project": self.project.id, "version": version}
payload.update(extra)
response = self.client.post(reverse("api:release-list"), payload, format="json")
return response
def test_list_requires_project(self):
response = self.client.get(reverse("api:release-list"))
self.assertEqual(response.status_code, 400)
self.assertEqual({"project": ["This field is required."]}, response.json())
def test_list_uses_ordered_releases(self):
# Create in arbitrary order
self._create("1.0.0")
self._create("1.0.0+build")
self._create("1.0.1")
response = self.client.get(reverse("api:release-list"), {"project": str(self.project.id)})
self.assertEqual(response.status_code, 200)
versions_from_api = [row["version"] for row in response.json()["results"]]
versions_expected = [r.version for r in ordered_releases(project=self.project)]
self.assertEqual(versions_from_api, versions_expected)
def test_create_new_returns_201_and_detail_shape(self):
response = self._create("1.2.3", timestamp="2024-01-01T00:00:00Z")
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertIn("id", body)
# model-computed fields are present in response:
self.assertIn("semver", body)
self.assertIn("is_semver", body)
self.assertIn("sort_epoch", body)
def test_create_duplicate_returns_400(self):
result1 = self._create("2.0.0")
self.assertEqual(result1.status_code, 201)
result2 = self._create("2.0.0") # same project+version
self.assertEqual(result2.status_code, 400)
self.assertIn("version", result2.json())
def test_create_allows_empty_version(self):
response = self._create("")
self.assertEqual(response.status_code, 201)
def test_create_without_timestamp_is_allowed(self):
response = self._create("3.0.0")
self.assertEqual(response.status_code, 201)
def test_detail_returns_readonly_fields(self):
created = self._create("4.5.6")
self.assertEqual(created.status_code, 201)
release_id = created.json()["id"]
response = self.client.get(reverse("api:release-detail", args=[release_id]))
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertIn("semver", body)
self.assertIn("is_semver", body)
self.assertIn("sort_epoch", body)
def test_update_and_delete_methods_not_allowed(self):
created = self._create("9.9.9")
self.assertEqual(created.status_code, 201)
release_id = created.json()["id"]
detail_url = reverse("api:release-detail", args=[release_id])
put_response = self.client.put(detail_url, {"version": "X"}, format="json")
patch_response = self.client.patch(detail_url, {"version": "X"}, format="json")
delete_response = self.client.delete(detail_url)
self.assertEqual(put_response.status_code, 405)
self.assertEqual(patch_response.status_code, 405)
self.assertEqual(delete_response.status_code, 405)