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