diff --git a/ingest/views.py b/ingest/views.py index 3d554e6..fdae6c5 100644 --- a/ingest/views.py +++ b/ingest/views.py @@ -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( diff --git a/releases/api_views.py b/releases/api_views.py index e0dc27b..ccf95c4 100644 --- a/releases/api_views.py +++ b/releases/api_views.py @@ -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= + 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 diff --git a/releases/models.py b/releases/models.py index 4510571..a0e7592 100644 --- a/releases/models.py +++ b/releases/models.py @@ -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): diff --git a/releases/serializers.py b/releases/serializers.py index 0cdd883..4b82227 100644 --- a/releases/serializers.py +++ b/releases/serializers.py @@ -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 diff --git a/releases/test_api.py b/releases/test_api.py new file mode 100644 index 0000000..7d7f603 --- /dev/null +++ b/releases/test_api.py @@ -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)