Coverage for CIResults/rest_views.py: 100%
310 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-06 08:12 +0000
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-06 08:12 +0000
1from django.shortcuts import get_object_or_404
2from django.http import HttpResponse, JsonResponse
3from django.db import transaction, models
4import django_filters
6from rest_framework.decorators import action, api_view, permission_classes
7from rest_framework.generics import RetrieveAPIView, ListCreateAPIView
8from rest_framework.response import Response
9from rest_framework.pagination import PageNumberPagination
10from rest_framework import status, viewsets, permissions, mixins, serializers
11from django_filters import rest_framework as filters
12from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
13from drf_spectacular.types import OpenApiTypes
15from .serializers import ImportTestSuiteRunSerializer, IssueFilterSerializer, RunConfigSerializer, TextStatusSerializer
16from .serializers import BuildSerializer, TestSerializer, BugTrackerAccountSerializer, KnownIssuesSerializer
17from .serializers import serialize_MetricPassRatePerRunconfig, serialize_MetricPassRatePerTest, BugTrackerSerializer
18from .serializers import BugCompleteSerializer, ShortenerSerializer, ImportMachineSerializer, ComponentSerializer
19from .serializers import RunConfigDiffSerializer, RestIssueSerializer
20from .serializers import UnknownFailureSerializer
21from .filtering import QueryCreator
23from .models import Component, Build, Test, Machine, RunConfigTag, RunConfig, TextStatus, Bug, TestResult
24from .models import IssueFilter, Issue, KnownFailure, MachineTag, BugTrackerAccount, BugTracker, UnknownFailure
25from .metrics import MetricPassRatePerRunconfig, MetricPassRatePerTest
27from shortener.models import Shortener
29import json
30import re
33def get_obj_by_id_or_name(model, key):
34 # FIXME: There are some corner cases, where this function is faulty, e.g. when the name of object contains only
35 # numbers, and the number is bigger than the model's maximal ID this function raises model.DoesNotExist
36 # exception - client receives response with status code 500.
37 # Fixing it with catching the exception will only create another issue, where users could get objects,
38 # which they didn't ask for, e.g. they provide the name that contains only numbers, the object
39 # doesn't exist, although they still get some "random" object with id equal to the name.
40 try:
41 return model.objects.get(pk=int(key))
42 except ValueError:
43 pass
45 return get_object_or_404(model, name=key)
48def object_vet(model, pk):
49 obj = get_object_or_404(model, pk=pk)
50 if not obj.vetted:
51 obj.vet()
52 return Response(status=status.HTTP_200_OK)
55def object_suppress(model, pk):
56 obj = get_object_or_404(model, pk=pk)
57 if obj.vetted:
58 obj.suppress()
59 return Response(status=status.HTTP_200_OK)
62class CustomPagination(PageNumberPagination):
63 page_size = 100
64 page_size_query_param = 'page_size'
65 max_page_size = None
67 def get_page_size(self, request):
68 if self.page_size_query_param:
69 try:
70 page_size = int(request.query_params.get(self.page_size_query_param, self.page_size))
71 except ValueError:
72 return self.page_size
74 # Clamp the maximum size
75 if self.max_page_size:
76 page_size = min(page_size, self.max_page_size)
78 # Do not set any limits if the page size is null or negative
79 if page_size <= 0:
80 return None
81 else:
82 return page_size
84 return self.page_size
87class IssueViewSet(viewsets.ReadOnlyModelViewSet):
88 queryset = Issue.objects.all().order_by('-id')
89 serializer_class = RestIssueSerializer
91 def patch(self, request, pk):
92 issue = Issue.objects.get(pk=pk)
93 serializer = RestIssueSerializer(issue, data=request.data, partial=True)
94 if serializer.is_valid():
95 serializer.save()
96 return Response(data=serializer.data, status=status.HTTP_200_OK)
97 return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST)
99 @classmethod
100 def _execute_action(cls, request, pk, permsission_name, action):
101 if not request.user.has_perm(permsission_name):
102 return Response(data={"message": f"User {request.user} doesn't have sufficient permissions"},
103 status=status.HTTP_401_UNAUTHORIZED)
104 try:
105 issue = get_object_or_404(Issue, pk=pk)
106 action(issue)
107 except Exception as err:
108 return Response(data={"message": str(err)}, status=status.HTTP_400_BAD_REQUEST)
109 return Response(status=status.HTTP_200_OK)
111 @action(detail=True)
112 def archive(self, request, pk=None):
113 return self._execute_action(request, pk, "CIResults.archive_issue", lambda issue: issue.archive(request.user))
115 @action(detail=True)
116 def restore(self, request, pk=None):
117 return self._execute_action(request, pk, "CIResults.restore_issue", lambda issue: issue.restore())
120class IssueFilterViewSet(viewsets.ModelViewSet):
121 queryset = IssueFilter.objects.all().order_by('-id').prefetch_related('tags', 'tests__testsuite',
122 'tests__first_runconfig',
123 'machine_tags', 'machines',
124 'statuses__testsuite')
125 serializer_class = IssueFilterSerializer
126 pagination_class = CustomPagination
128 def __check_list__(self, request_data, field, field_name, db_class, errors):
129 objects = set(request_data.get(field, []))
131 objects_db = dict()
132 for obj in db_class.objects.filter(id__in=objects):
133 objects_db[obj.id] = obj
135 if len(objects) != len(objects_db):
136 errors.append("At least one {} does not exist".format(field_name))
138 return objects, objects_db
140 def __get_or_None__(self, klass, field, request_dict, errors):
141 obj = None
142 if field in request_dict:
143 obj_id = request_dict[field]
145 # Do not consider empty strings as meaning a valid value
146 if isinstance(obj_id, str) and len(obj_id) == 0:
147 return None
149 # Convert the id to an int or fail
150 try:
151 obj_id = int(obj_id)
152 except Exception:
153 errors.append("The field '{}' needs to be an integer".format(field))
154 return None
156 # Try getting the object
157 obj = klass.objects.filter(id=obj_id).first()
158 if obj is None:
159 errors.append("The object referenced by '{}' does not exist".format(field))
161 return obj
163 def get_queryset(self):
164 queryset = self.queryset
165 if description := self.request.query_params.get("description"):
166 queryset = queryset.filter(description__contains=description)
167 return queryset
169 @transaction.atomic
170 def create(self, request):
171 errors = []
172 if len(request.data.get('description', '')) == 0:
173 errors.append("The field 'description' cannot be empty")
175 # Check if the filter should replace another one
176 edit_filter = self.__get_or_None__(IssueFilter, 'edit_filter',
177 request.data, errors)
178 edit_issue = self.__get_or_None__(Issue, 'edit_issue',
179 request.data, errors)
181 # Check that all the tags, machines, tests, and statuses are present
182 tags, tags_db = self.__check_list__(request.data, "tags", "tag", RunConfigTag, errors)
183 machine_tags, machine_tags_db = self.__check_list__(request.data, "machine_tags", "machine tag",
184 MachineTag, errors)
185 machines, machines_db = self.__check_list__(request.data, "machines", "machine", Machine, errors)
186 tests, tests_db = self.__check_list__(request.data, "tests", "test", Test, errors)
187 statuses, statuses_db = self.__check_list__(request.data, "statuses", "status", TextStatus, errors)
189 # Check the regular expressions
190 for field in ['stdout_regex', 'stderr_regex', 'dmesg_regex']:
191 try:
192 re.compile(request.data.get(field, ""), re.DOTALL)
193 except Exception:
194 errors.append("The field '{}' does not contain a valid regular expression".format(field))
196 # Create the object or fail depending on whether we got errors or not
197 if len(errors) == 0:
198 filter = IssueFilter.objects.create(description=request.data.get('description'),
199 stdout_regex=request.data.get('stdout_regex', ""),
200 stderr_regex=request.data.get('stderr_regex', ""),
201 dmesg_regex=request.data.get('dmesg_regex', ""),
202 user_query=request.data.get('user_query', ""))
204 filter.tags.add(*tags_db)
205 filter.machines.add(*machines_db)
206 filter.machine_tags.add(*machine_tags_db)
207 filter.tests.add(*tests_db)
208 filter.statuses.add(*statuses_db)
210 # If this filter is supposed to replace another filter
211 if edit_filter is not None:
212 if edit_issue is not None:
213 edit_issue.replace_filter(edit_filter, filter, request.user)
214 else:
215 edit_filter.replace(filter, request.user)
217 serializer = IssueFilterSerializer(filter)
218 return Response(serializer.data, status=status.HTTP_201_CREATED)
219 else:
220 return Response(errors, status=status.HTTP_400_BAD_REQUEST)
223class RunConfigFilter(filters.FilterSet):
224 class Meta:
225 model = RunConfig
226 fields = {
227 "name": ["exact", "contains"],
228 "builds__name": ["exact", "contains"],
229 }
232@extend_schema_view(
233 retrieve=extend_schema(
234 parameters=[
235 OpenApiParameter(
236 name="id",
237 description="A unique ID or name identifying this runconfig.",
238 location=OpenApiParameter.PATH,
239 )
240 ],
241 responses={status.HTTP_200_OK: RunConfigSerializer}
242 ),
243)
244class RunConfigViewSet(mixins.CreateModelMixin,
245 mixins.ListModelMixin,
246 mixins.RetrieveModelMixin,
247 viewsets.GenericViewSet):
248 queryset = RunConfig.objects.all().order_by('-id')
249 serializer_class = RunConfigSerializer
250 lookup_value_regex = r"[\w.-]+"
251 filterset_class = RunConfigFilter
253 def get_object(self):
254 return get_obj_by_id_or_name(RunConfig, self.kwargs.get("pk"))
256 @classmethod
257 def known_failures_serialized(cls, runcfg):
258 f = KnownFailure.objects.filter(result__ts_run__runconfig=runcfg)
259 failures = f.prefetch_related('result__status', 'result__test', 'result__test__testsuite',
260 'result__ts_run__machine', 'matched_ifa__issue__bugs',
261 'matched_ifa__issue__bugs__tracker')
262 return KnownIssuesSerializer(failures, read_only=True, many=True)
264 @extend_schema(
265 parameters=[
266 OpenApiParameter(
267 name="id",
268 description="A unique ID or name identifying this runconfig.",
269 location=OpenApiParameter.PATH,
270 )
271 ],
272 responses={status.HTTP_200_OK: KnownIssuesSerializer}
273 )
274 @action(detail=True)
275 def known_failures(self, request, pk=None):
276 runcfg = self.get_object()
277 return Response(self.known_failures_serialized(runcfg).data, status=status.HTTP_200_OK)
279 @extend_schema(
280 description="Compare two runconfigs",
281 parameters=[
282 OpenApiParameter(
283 name="id",
284 description="Name or ID of of the RunConfig used for the test suite run",
285 location=OpenApiParameter.PATH
286 ),
287 OpenApiParameter(
288 name="to",
289 description="Id or name of RunConfig to compare",
290 required=True
291 ),
292 OpenApiParameter(
293 name="no_compress",
294 description="Should not compress comparison results",
295 type=bool
296 ),
297 OpenApiParameter(
298 name="summary",
299 description="Return raw text summary (to enable add this parameter to request)",
300 )
301 ],
302 responses={status.HTTP_200_OK: RunConfigDiffSerializer}
303 )
304 @action(detail=True)
305 def compare(self, request, pk=None):
306 runcfg_from = self.get_object()
307 runcfg_to = get_obj_by_id_or_name(RunConfig, request.GET.get('to'))
308 no_compress = serializers.BooleanField().to_representation(request.GET.get('no_compress'))
310 diff = runcfg_from.compare(runcfg_to, no_compress=no_compress)
311 if request.GET.get('summary') is None:
312 serializer = RunConfigDiffSerializer(diff)
313 return Response(serializer.data, status=status.HTTP_200_OK)
314 else:
315 return HttpResponse(diff.text)
317 @extend_schema(
318 description=(
319 "Create test suite run object"
320 ),
321 parameters=[
322 OpenApiParameter(
323 name="id",
324 description="Id or name of base RunConfig",
325 location=OpenApiParameter.PATH
326 ),
327 ],
328 request=ImportTestSuiteRunSerializer,
329 responses={
330 status.HTTP_200_OK: ImportTestSuiteRunSerializer,
331 status.HTTP_400_BAD_REQUEST: OpenApiTypes.STR,
332 },
333 )
334 @action(detail=True, methods=["post"], url_path='testsuiterun')
335 def import_test_suite_run(self, request, pk=None):
336 try:
337 request.data["runconfig_name"] = self.get_object().name
338 serializer = ImportTestSuiteRunSerializer(data=request.data)
339 if serializer.is_valid():
340 serializer.save()
341 return Response(serializer.data, status=status.HTTP_200_OK)
342 else:
343 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
344 except Exception as error:
345 return Response(str(error), status=status.HTTP_400_BAD_REQUEST)
348class ComponentViewSet(viewsets.ReadOnlyModelViewSet):
349 queryset = Component.objects.all().order_by('-id')
350 serializer_class = ComponentSerializer
353class BuildViewSet(mixins.CreateModelMixin,
354 mixins.ListModelMixin,
355 mixins.RetrieveModelMixin,
356 mixins.UpdateModelMixin,
357 viewsets.GenericViewSet):
358 queryset = Build.objects.all().order_by('-id')
359 serializer_class = BuildSerializer
361 @extend_schema(
362 parameters=[
363 OpenApiParameter(
364 name="id",
365 description="A unique ID or name identifying this build.",
366 location=OpenApiParameter.PATH,
367 )
368 ]
369 )
370 def retrieve(self, request, pk=None):
371 build = get_obj_by_id_or_name(Build, pk)
372 serializer = BuildSerializer(build)
373 return Response(serializer.data)
376class MachineViewSet(
377 mixins.ListModelMixin,
378 mixins.CreateModelMixin,
379 mixins.RetrieveModelMixin,
380 viewsets.GenericViewSet,
381):
382 queryset = Machine.objects.all()
383 serializer_class = ImportMachineSerializer
384 filterset_fields = {
385 "id": ["exact"],
386 "name": ["exact"],
387 "public": ["exact"],
388 "vetted_on": ["isnull"],
389 }
391 @action(detail=True, methods=["post"])
392 def vet(self, request, pk):
393 return object_vet(Machine, pk)
395 @action(detail=True, methods=["post"])
396 def suppress(self, request, pk):
397 return object_suppress(Machine, pk)
400class TestFilter(filters.FilterSet):
401 class Meta:
402 model = Test
403 fields = {
404 'id': ['exact'],
405 'name': ['exact'],
406 'testsuite': ['exact'],
407 'public': ['exact'],
408 'added_on': ['lte', 'gte'],
409 'vetted_on': ['isnull'],
410 }
411 filter_overrides = {
412 models.DateTimeField: {
413 'filter_class': django_filters.IsoDateTimeFilter
414 },
415 }
418class TestSet(viewsets.ReadOnlyModelViewSet):
419 queryset = Test.objects.all().order_by('-id')
420 serializer_class = TestSerializer
421 filterset_class = TestFilter
423 @action(detail=True, methods=["post"])
424 def vet(self, request, pk):
425 return object_vet(Test, pk)
427 @action(detail=True, methods=["post"])
428 def suppress(self, request, pk):
429 return object_suppress(Test, pk)
432class UnknownFailureViewSet(viewsets.ReadOnlyModelViewSet):
433 queryset = UnknownFailure.objects.all().order_by('-id')
434 serializer_class = UnknownFailureSerializer
435 pagination_class = CustomPagination
437 EXTRA_FIELDS_ARG = OpenApiParameter(
438 name="extra_fields",
439 description=(
440 f"Comma-separated list of fields to expand (available choices: "
441 f"{', '.join(serializer_class.extra_fields())})"
442 ),
443 type=str,
444 )
446 @extend_schema(description="Retrieve a single unknown failure", parameters=[EXTRA_FIELDS_ARG])
447 def retrieve(self, request, pk):
448 extra_fields = request.query_params.get('extra_fields', '').split(',')
449 failure = get_object_or_404(UnknownFailure, pk=pk)
450 serializer = self.serializer_class(failure, extra_fields=extra_fields)
451 return Response(serializer.data)
453 @extend_schema(description="List all unknown failures", parameters=[EXTRA_FIELDS_ARG])
454 def list(self, request):
455 page = self.paginate_queryset(self.queryset)
456 extra_fields = request.query_params.get('extra_fields', '').split(',')
457 serializer = self.serializer_class(page, extra_fields=extra_fields, many=True)
458 return self.get_paginated_response(serializer.data)
461class TextStatusViewSet(viewsets.ReadOnlyModelViewSet):
462 queryset = TextStatus.objects.all().order_by('-id')
463 serializer_class = TextStatusSerializer
465 @action(detail=True)
466 def vet(self, request, pk):
467 return object_vet(TextStatus, pk)
469 @action(detail=True)
470 def suppress(self, request, pk):
471 return object_suppress(TextStatus, pk)
474class BugTrackerViewSet(viewsets.ReadOnlyModelViewSet):
475 queryset = BugTracker.objects.all().order_by('-id')
476 serializer_class = BugTrackerSerializer
479class BugViewSet(RetrieveAPIView):
480 serializer_class = BugCompleteSerializer
481 queryset = Bug.objects.all()
483 def _get_bugtracker(self):
484 tracker = self.kwargs.get('tracker')
486 try:
487 return BugTracker.objects.get(pk=int(tracker))
488 except (ValueError, BugTracker.DoesNotExist):
489 pass
491 try:
492 return BugTracker.objects.get(name=tracker)
493 except BugTracker.DoesNotExist:
494 pass
496 return get_object_or_404(BugTracker, short_name=tracker)
498 def retrieve(self, request, *args, **kwargs):
499 bug = get_object_or_404(Bug, tracker=self._get_bugtracker(), bug_id=kwargs.get('bug_id'))
500 serializer = self.get_serializer(bug)
501 return Response(serializer.data)
504class BugTrackerAccountViewSet(viewsets.ModelViewSet):
505 # WARNING: we do not yet perform access control for setting who is a user or developer because of the limited damage
506 # this can cause and the annoyance of having to ask large group of users to authenticate then be granted the
507 # privilege to change the roles
508 permission_classes = []
509 authentication_classes = []
511 queryset = BugTrackerAccount.objects.all().order_by('-id')
512 serializer_class = BugTrackerAccountSerializer
514 http_method_names = ['get', 'patch']
517class ShortenerViewSet(ListCreateAPIView, viewsets.GenericViewSet):
518 # WARNING: No access control is performed because these objects can be created simply by navigating the website
519 permission_classes = []
520 authentication_classes = []
522 queryset = Shortener.objects.all().order_by('-id')
523 serializer_class = ShortenerSerializer
525 def create(self, request, *args, **kwargs):
526 if request.method != "POST" or request.content_type != "application/json":
527 raise ValueError("Only JSON POST requests are supported")
529 data = json.loads(request.body)
530 fulls = data.get('full')
531 if fulls is None:
532 raise ValueError("Missing the field 'full' which should contain the full text to be shortened")
534 if isinstance(fulls, list):
535 shorts = [Shortener.get_or_create(full=f) for f in fulls]
536 serializer = self.get_serializer(shorts, many=True)
537 else:
538 short = Shortener.get_or_create(full=fulls)
539 serializer = self.get_serializer(short)
541 return JsonResponse(serializer.data, safe=False)
544@api_view()
545@permission_classes((permissions.AllowAny,))
546def metrics_passrate_per_runconfig_view(request):
547 user_query = QueryCreator(request, TestResult).request_to_query()
548 history = MetricPassRatePerRunconfig(user_query)
549 return Response(serialize_MetricPassRatePerRunconfig(history))
552@api_view(['GET', 'POST'])
553@permission_classes((permissions.AllowAny,))
554def metrics_passrate_per_test_view(request):
555 user_query = QueryCreator(request, TestResult).request_to_query()
556 passrate = MetricPassRatePerTest(user_query)
557 return Response(serialize_MetricPassRatePerTest(passrate))