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

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 

6 

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 

15 

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 

29 

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 

33 

34from shortener.models import Shortener 

35 

36import json 

37import re 

38 

39 

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 

51 

52 return get_object_or_404(model, name=key) 

53 

54 

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) 

60 

61 

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) 

67 

68 

69class CustomPagination(PageNumberPagination): 

70 page_size = 100 

71 page_size_query_param = 'page_size' 

72 max_page_size = None 

73 

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 

80 

81 # Clamp the maximum size 

82 if self.max_page_size: 

83 page_size = min(page_size, self.max_page_size) 

84 

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 

90 

91 return self.page_size 

92 

93 

94class IssueViewSet(viewsets.ReadOnlyModelViewSet): 

95 queryset = Issue.objects.all().order_by('-id') 

96 serializer_class = RestIssueSerializer 

97 

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) 

105 

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) 

117 

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)) 

121 

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()) 

125 

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 ) 

151 

152 

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 

160 

161 def __check_list__(self, request_data, field, field_name, db_class, errors): 

162 objects = set(request_data.get(field, [])) 

163 

164 objects_db = dict() 

165 for obj in db_class.objects.filter(id__in=objects): 

166 objects_db[obj.id] = obj 

167 

168 if len(objects) != len(objects_db): 

169 errors.append("At least one {} does not exist".format(field_name)) 

170 

171 return objects, objects_db 

172 

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] 

177 

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 

181 

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 

188 

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)) 

193 

194 return obj 

195 

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 

201 

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") 

207 

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) 

213 

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) 

221 

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)) 

228 

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', "")) 

236 

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) 

242 

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) 

249 

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) 

254 

255 

256class RunConfigFilter(filters.FilterSet): 

257 class Meta: 

258 model = RunConfig 

259 fields = { 

260 "name": ["exact", "contains"], 

261 "builds__name": ["exact", "contains"], 

262 } 

263 

264 

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 

285 

286 def get_object(self): 

287 return get_obj_by_id_or_name(RunConfig, self.kwargs.get("pk")) 

288 

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) 

296 

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) 

311 

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')) 

342 

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) 

349 

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) 

379 

380 

381class ComponentViewSet(viewsets.ReadOnlyModelViewSet): 

382 queryset = Component.objects.all().order_by('-id') 

383 serializer_class = ComponentSerializer 

384 

385 

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 

393 

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) 

407 

408 

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 } 

423 

424 @action(detail=True, methods=["post"]) 

425 def vet(self, request, pk): 

426 return object_vet(Machine, pk) 

427 

428 @action(detail=True, methods=["post"]) 

429 def suppress(self, request, pk): 

430 return object_suppress(Machine, pk) 

431 

432 

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 } 

449 

450 

451class TestSet(viewsets.ReadOnlyModelViewSet): 

452 queryset = Test.objects.all().order_by('-id') 

453 serializer_class = TestSerializer 

454 filterset_class = TestFilter 

455 

456 @action(detail=True, methods=["post"]) 

457 def vet(self, request, pk): 

458 return object_vet(Test, pk) 

459 

460 @action(detail=True, methods=["post"]) 

461 def suppress(self, request, pk): 

462 return object_suppress(Test, pk) 

463 

464 

465class UnknownFailureViewSet(viewsets.ReadOnlyModelViewSet): 

466 queryset = UnknownFailure.objects.all().order_by('-id') 

467 serializer_class = UnknownFailureSerializer 

468 pagination_class = CustomPagination 

469 

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 ) 

478 

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) 

485 

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) 

492 

493 

494class TextStatusViewSet(viewsets.ReadOnlyModelViewSet): 

495 queryset = TextStatus.objects.all().order_by('-id') 

496 serializer_class = TextStatusSerializer 

497 

498 @action(detail=True) 

499 def vet(self, request, pk): 

500 return object_vet(TextStatus, pk) 

501 

502 @action(detail=True) 

503 def suppress(self, request, pk): 

504 return object_suppress(TextStatus, pk) 

505 

506 

507class BugTrackerViewSet(viewsets.ReadOnlyModelViewSet): 

508 queryset = BugTracker.objects.all().order_by('-id') 

509 serializer_class = BugTrackerSerializer 

510 

511 

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') 

521 

522 lookup_field = 'bug_id' 

523 lookup_url_kwarg = 'bug_id' 

524 lookup_value_regex = r"[^/]+" 

525 

526 @cached_property 

527 def bugtracker(self): 

528 tracker = self.kwargs.get('tracker') 

529 

530 try: 

531 return BugTracker.objects.get(pk=int(tracker)) 

532 except (ValueError, BugTracker.DoesNotExist): 

533 pass 

534 

535 try: 

536 return BugTracker.objects.get(name=tracker) 

537 except BugTracker.DoesNotExist: 

538 pass 

539 

540 return get_object_or_404(BugTracker, short_name=tracker) 

541 

542 def get_queryset(self): 

543 return super().get_queryset().filter(tracker=self.bugtracker) 

544 

545 def perform_create(self, serializer): 

546 serializer.save(tracker=self.bugtracker) 

547 

548 

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 = [] 

555 

556 queryset = BugTrackerAccount.objects.all().order_by('-id') 

557 serializer_class = BugTrackerAccountSerializer 

558 

559 http_method_names = ['get', 'patch'] 

560 

561 

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 = [] 

566 

567 queryset = Shortener.objects.all().order_by('-id') 

568 serializer_class = ShortenerSerializer 

569 

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") 

573 

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") 

578 

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) 

585 

586 return JsonResponse(serializer.data, safe=False) 

587 

588 

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)) 

595 

596 

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))