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

1from django.shortcuts import get_object_or_404 

2from django.http import HttpResponse, JsonResponse 

3from django.db import transaction, models 

4import django_filters 

5 

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 

14 

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 

22 

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 

26 

27from shortener.models import Shortener 

28 

29import json 

30import re 

31 

32 

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 

44 

45 return get_object_or_404(model, name=key) 

46 

47 

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) 

53 

54 

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) 

60 

61 

62class CustomPagination(PageNumberPagination): 

63 page_size = 100 

64 page_size_query_param = 'page_size' 

65 max_page_size = None 

66 

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 

73 

74 # Clamp the maximum size 

75 if self.max_page_size: 

76 page_size = min(page_size, self.max_page_size) 

77 

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 

83 

84 return self.page_size 

85 

86 

87class IssueViewSet(viewsets.ReadOnlyModelViewSet): 

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

89 serializer_class = RestIssueSerializer 

90 

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) 

98 

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) 

110 

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

114 

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

118 

119 

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 

127 

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

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

130 

131 objects_db = dict() 

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

133 objects_db[obj.id] = obj 

134 

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

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

137 

138 return objects, objects_db 

139 

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] 

144 

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 

148 

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 

155 

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

160 

161 return obj 

162 

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 

168 

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

174 

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) 

180 

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) 

188 

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

195 

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

203 

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) 

209 

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) 

216 

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) 

221 

222 

223class RunConfigFilter(filters.FilterSet): 

224 class Meta: 

225 model = RunConfig 

226 fields = { 

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

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

229 } 

230 

231 

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 

252 

253 def get_object(self): 

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

255 

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) 

263 

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) 

278 

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

309 

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) 

316 

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) 

346 

347 

348class ComponentViewSet(viewsets.ReadOnlyModelViewSet): 

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

350 serializer_class = ComponentSerializer 

351 

352 

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 

360 

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) 

374 

375 

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 } 

390 

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

392 def vet(self, request, pk): 

393 return object_vet(Machine, pk) 

394 

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

396 def suppress(self, request, pk): 

397 return object_suppress(Machine, pk) 

398 

399 

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 } 

416 

417 

418class TestSet(viewsets.ReadOnlyModelViewSet): 

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

420 serializer_class = TestSerializer 

421 filterset_class = TestFilter 

422 

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

424 def vet(self, request, pk): 

425 return object_vet(Test, pk) 

426 

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

428 def suppress(self, request, pk): 

429 return object_suppress(Test, pk) 

430 

431 

432class UnknownFailureViewSet(viewsets.ReadOnlyModelViewSet): 

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

434 serializer_class = UnknownFailureSerializer 

435 pagination_class = CustomPagination 

436 

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 ) 

445 

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) 

452 

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) 

459 

460 

461class TextStatusViewSet(viewsets.ReadOnlyModelViewSet): 

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

463 serializer_class = TextStatusSerializer 

464 

465 @action(detail=True) 

466 def vet(self, request, pk): 

467 return object_vet(TextStatus, pk) 

468 

469 @action(detail=True) 

470 def suppress(self, request, pk): 

471 return object_suppress(TextStatus, pk) 

472 

473 

474class BugTrackerViewSet(viewsets.ReadOnlyModelViewSet): 

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

476 serializer_class = BugTrackerSerializer 

477 

478 

479class BugViewSet(RetrieveAPIView): 

480 serializer_class = BugCompleteSerializer 

481 queryset = Bug.objects.all() 

482 

483 def _get_bugtracker(self): 

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

485 

486 try: 

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

488 except (ValueError, BugTracker.DoesNotExist): 

489 pass 

490 

491 try: 

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

493 except BugTracker.DoesNotExist: 

494 pass 

495 

496 return get_object_or_404(BugTracker, short_name=tracker) 

497 

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) 

502 

503 

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

510 

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

512 serializer_class = BugTrackerAccountSerializer 

513 

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

515 

516 

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

521 

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

523 serializer_class = ShortenerSerializer 

524 

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

528 

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

533 

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) 

540 

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

542 

543 

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

550 

551 

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