Skip to content

Commit 6d8134c

Browse files
feat: API for changing suggestion state
1 parent e3ead57 commit 6d8134c

6 files changed

Lines changed: 72 additions & 5 deletions

File tree

src/api/tests/test_api.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Callable
22

3+
from django.contrib.auth.models import User
34
from rest_framework.reverse import reverse
45
from rest_framework.test import APIClient
56

@@ -55,3 +56,20 @@ def test_published_issue(
5556
response = client.get(url, {"cve": "CVE-9999-0000"})
5657
assert response.status_code == 200
5758
assert len(response.data) == 0
59+
60+
61+
# TODO(@fricklerhandwerk): Exercise all state sequences
62+
def test_suggestion_change_state(
63+
cached_suggestion: CVEDerivationClusterProposal,
64+
staff: User,
65+
) -> None:
66+
client = APIClient()
67+
client.force_login(staff)
68+
response = client.post(
69+
f"/api/v1/suggestions/{cached_suggestion.pk}/change_status",
70+
{
71+
"status": CVEDerivationClusterProposal.Status.ACCEPTED,
72+
},
73+
format="json",
74+
)
75+
assert response.status_code == 200

src/api/urls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from django.urls import include, path
22
from rest_framework import routers
33

4-
from api.views import NixpkgsIssueViewSet
4+
from api.views import NixpkgsIssueViewSet, SuggestionViewSet
55

6-
v1_router = routers.DefaultRouter()
6+
v1_router = routers.DefaultRouter(trailing_slash=False)
77
v1_router.register(r"issues", NixpkgsIssueViewSet)
8+
v1_router.register("suggestions", SuggestionViewSet)
89

910
urlpatterns = [
1011
path("v1/", include(v1_router.urls)),

src/api/views.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
from django_filters import rest_framework as filters
22
from rest_framework import serializers, viewsets
3-
from rest_framework.permissions import AllowAny
3+
from rest_framework.decorators import action
4+
from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated
5+
from rest_framework.request import Request
6+
from rest_framework.response import Response
7+
from rest_framework.views import APIView
48

5-
from shared.models import NixpkgsIssue
9+
from shared.auth import user_can_edit_suggestion
10+
from shared.models import CVEDerivationClusterProposal, NixpkgsIssue
611

712

813
class StringInFilter(filters.BaseInFilter, filters.CharFilter):
@@ -39,3 +44,26 @@ class Meta:
3944
"suggestion__cve",
4045
).all()
4146
serializer_class = Serializer
47+
48+
49+
class CanEditSuggestion(BasePermission):
50+
def has_permission(self, request: Request, view: APIView) -> bool: # pyright: ignore[reportIncompatibleMethodOverride]
51+
return user_can_edit_suggestion(request.user)
52+
53+
54+
class SuggestionViewSet(viewsets.GenericViewSet):
55+
class StatusSerializer(serializers.ModelSerializer):
56+
class Meta:
57+
model = CVEDerivationClusterProposal
58+
extra_kwargs = {"status": {"required": True}}
59+
fields = ["status", "rejection_reason", "comment"]
60+
61+
queryset = CVEDerivationClusterProposal.objects.all()
62+
permission_classes = [IsAuthenticated, CanEditSuggestion]
63+
64+
@action(detail=True, methods=["post"], serializer_class=StatusSerializer)
65+
def change_status(self, request: Request, pk: int) -> Response:
66+
serializer = self.get_serializer(instance=self.get_object(), data=request.data)
67+
serializer.is_valid(raise_exception=True)
68+
serializer.save()
69+
return Response(serializer.data)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.12 on 2026-04-30 13:39
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('shared', '0082_alter_derivationclusterproposallink_derivation_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='cvederivationclusterproposal',
15+
name='comment',
16+
field=models.CharField(blank=True, help_text='Optional free text comment for additional notes, context, dismissal reason', max_length=1000, null=True),
17+
),
18+
]

src/shared/models/linkage.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ class RejectionReason(models.TextChoices):
5959

6060
comment = models.CharField(
6161
max_length=1000,
62-
default="",
62+
null=True,
63+
blank=True,
6364
help_text=_(
6465
"Optional free text comment for additional notes, context, dismissal reason"
6566
),

src/webview/suggestions/views/status.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def post(self, request: HttpRequest, suggestion_id: int) -> HttpResponse:
7575
github_issue_link = None # Will be used if we publish
7676
# We keep track of the previous status to provide an undo action
7777
undo_status_target = suggestion.status
78+
# TODO(@fricklerhandwerk): Move this business logic into the model, so it can be re-used in the REST API.
7879
# We keep track of the previous potential rejection_reason to be able to restore it during an undo
7980
undo_rejection_reason = suggestion.rejection_reason
8081
if new_status == "rejected":

0 commit comments

Comments
 (0)