From a384ab534ff57c4d4062d761f028ebf0b943d3c9 Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Mon, 15 Jun 2026 18:37:12 +0000 Subject: [PATCH 1/5] api: fix results module export declaration The API module intended to declare public exports, but used a plain all variable instead of Python's __all__ convention. Fix the export declaration separately so later run and result API refactors do not carry an unrelated module-level cleanup. Signed-off-by: Mikhail Zayats --- bublik/interfaces/api_v2/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bublik/interfaces/api_v2/results.py b/bublik/interfaces/api_v2/results.py index 95ede3d7..19a90b5c 100644 --- a/bublik/interfaces/api_v2/results.py +++ b/bublik/interfaces/api_v2/results.py @@ -23,7 +23,7 @@ ) -all = [ +__all__ = [ 'RunViewSet', 'ResultViewSet', ] From 360f5bd5fdddacfa9888608729f8854e07b85a2d Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Mon, 15 Jun 2026 19:44:01 +0000 Subject: [PATCH 2/5] run: prepare API modules for independent evolution Split run and result API views into dedicated modules to keep entity-specific code isolated and easier to evolve independently. Signed-off-by: Mikhail Zayats --- bublik/interfaces/api_v2/__init__.py | 3 +- bublik/interfaces/api_v2/result/views.py | 59 +++++++++++++++++++ .../api_v2/{results.py => run/views.py} | 43 -------------- 3 files changed, 61 insertions(+), 44 deletions(-) create mode 100644 bublik/interfaces/api_v2/result/views.py rename bublik/interfaces/api_v2/{results.py => run/views.py} (78%) diff --git a/bublik/interfaces/api_v2/__init__.py b/bublik/interfaces/api_v2/__init__.py index d975f50c..6d2b48a0 100644 --- a/bublik/interfaces/api_v2/__init__.py +++ b/bublik/interfaces/api_v2/__init__.py @@ -30,7 +30,8 @@ from .performance import PerformanceCheckView from .project import ProjectViewSet from .report import ReportViewSet -from .results import ResultViewSet, RunViewSet +from .result.views import ResultViewSet +from .run.views import RunViewSet from .server import ServerViewSet from .tree import TreeViewSet from .url_shortener import URLShortenerView diff --git a/bublik/interfaces/api_v2/result/views.py b/bublik/interfaces/api_v2/result/views.py new file mode 100644 index 00000000..9daf201f --- /dev/null +++ b/bublik/interfaces/api_v2/result/views.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2016-2023 OKTET Labs Ltd. All rights reserved. + +from typing import ClassVar + +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from bublik.core.result import ResultService +from bublik.core.run.stats import ( + generate_results_details, +) +from bublik.data.serializers import ( + TestIterationResultSerializer, +) + + +__all__ = [ + 'ResultViewSet', +] + + +class ResultViewSet(ModelViewSet): + serializer_class = TestIterationResultSerializer + filter_backends: ClassVar[list] = [] + + def get_queryset(self): + parent_id = self.request.query_params.get('parent_id') + test_name = self.request.query_params.get('test_name') + start_exec_seqno = self.request.query_params.get('start_exec_seqno') + results = self.request.query_params.get('results') + result_properties = self.request.query_params.get('result_properties') + requirements = self.request.query_params.get('requirements') + + return ResultService.list_results( + parent_id=parent_id, + test_name=test_name, + start_exec_seqno=start_exec_seqno, + results=results, + result_properties=result_properties, + requirements=requirements, + ) + + def retrieve(self, request, pk=None): + return Response(data={'result': ResultService.get_result_details(pk)}) + + def list(self, request): + return Response( + data={'results': generate_results_details(self.get_queryset())}, + ) + + @action(detail=True, methods=['get']) + def artifacts_and_verdicts(self, request, pk=None): + return Response(ResultService.get_result_artifacts_and_verdicts(pk)) + + @action(detail=True, methods=['get']) + def measurements(self, request, pk=None): + return Response(ResultService.get_result_measurements(pk)) diff --git a/bublik/interfaces/api_v2/results.py b/bublik/interfaces/api_v2/run/views.py similarity index 78% rename from bublik/interfaces/api_v2/results.py rename to bublik/interfaces/api_v2/run/views.py index 19a90b5c..52a3a4bf 100644 --- a/bublik/interfaces/api_v2/results.py +++ b/bublik/interfaces/api_v2/run/views.py @@ -1,8 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (C) 2016-2023 OKTET Labs Ltd. All rights reserved. -from typing import ClassVar - from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -10,10 +8,8 @@ from rest_framework.viewsets import ModelViewSet from bublik.core.cache import RunCache -from bublik.core.result import ResultService from bublik.core.run.services import RunsChartGroupBy, RunService from bublik.core.run.stats import ( - generate_results_details, generate_runs_details, ) from bublik.core.utils import get_difference @@ -25,7 +21,6 @@ __all__ = [ 'RunViewSet', - 'ResultViewSet', ] @@ -153,41 +148,3 @@ def comment(self, request, pk=None): return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) - - -class ResultViewSet(ModelViewSet): - serializer_class = TestIterationResultSerializer - filter_backends: ClassVar[list] = [] - - def get_queryset(self): - parent_id = self.request.query_params.get('parent_id') - test_name = self.request.query_params.get('test_name') - start_exec_seqno = self.request.query_params.get('start_exec_seqno') - results = self.request.query_params.get('results') - result_properties = self.request.query_params.get('result_properties') - requirements = self.request.query_params.get('requirements') - - return ResultService.list_results( - parent_id=parent_id, - test_name=test_name, - start_exec_seqno=start_exec_seqno, - results=results, - result_properties=result_properties, - requirements=requirements, - ) - - def retrieve(self, request, pk=None): - return Response(data={'result': ResultService.get_result_details(pk)}) - - def list(self, request): - return Response( - data={'results': generate_results_details(self.get_queryset())}, - ) - - @action(detail=True, methods=['get']) - def artifacts_and_verdicts(self, request, pk=None): - return Response(ResultService.get_result_artifacts_and_verdicts(pk)) - - @action(detail=True, methods=['get']) - def measurements(self, request, pk=None): - return Response(ResultService.get_result_measurements(pk)) From 3877043c938a9aac5c69ebc6619ea4c2f898685c Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Mon, 15 Jun 2026 19:48:16 +0000 Subject: [PATCH 3/5] run: introduce DTO-based service contracts Replace dict-based service responses with typed DTOs to introduce explicit and structured data contracts across service boundaries and move serialization to API/MCP boundary to improve separation of concerns between service layer and API/MCP layers. Signed-off-by: Mikhail Zayats --- bublik/core/run/dto.py | 149 ++++++++++++++++ bublik/core/run/services.py | 71 ++++---- bublik/core/run/stats.py | 149 ++++++++++------ bublik/interfaces/api_v2/run/serializers.py | 179 ++++++++++++++++++++ bublik/interfaces/api_v2/run/views.py | 32 +++- bublik/mcp/tools.py | 36 ++-- 6 files changed, 518 insertions(+), 98 deletions(-) create mode 100644 bublik/core/run/dto.py create mode 100644 bublik/interfaces/api_v2/run/serializers.py diff --git a/bublik/core/run/dto.py b/bublik/core/run/dto.py new file mode 100644 index 00000000..3072309c --- /dev/null +++ b/bublik/core/run/dto.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from datetime import datetime, timedelta + + +@dataclass +class RunCompromisedDetails: + status: bool + comment: str | None = None + bug_id: str | None = None + bug_url: str | None = None + + +@dataclass +class RunRevision: + name: str + value: str + url: str + + +@dataclass +class RunSpecialCategory: + name: str + values: list[str] + + +@dataclass +class RunDetailsResult: + project_id: int + project_name: str + id: int + start: datetime | None + finish: datetime | None + duration: timedelta | None + main_package: str | None + status: str | None + status_by_nok: str + compromised: RunCompromisedDetails | None + conclusion: str + conclusion_reason: str | None + important_tags: list[str] + relevant_tags: list[str] + branches: list[str] + revisions: list[RunRevision] + labels: list[str] + special_categories: list[RunSpecialCategory] + configuration: str | None + + +@dataclass +class RunStatsValues: + passed: int + failed: int + passed_unexpected: int + failed_unexpected: int + skipped: int + skipped_unexpected: int + abnormal: int + + +@dataclass +class RunStatsComment: + comment_id: str + updated: str + serial: str + comment: str + + +@dataclass +class RunStatsResult: + result_id: int + exec_seqno: int + parent_id: int | None + type: str + test_id: int + test_name: str + period: str + path: list[str] + objective: str + children: list[RunStatsResult] + stats: RunStatsValues + comments: list[RunStatsComment] + + +@dataclass +class RunCompromisedResult: + compromised: bool + + +@dataclass +class MarkRunCompromisedResult: + comment: str + bug: str | None + + +@dataclass +class RunSummaryStats: + tests_total: int + tests_total_plan_percent: int | None + tests_total_ok: int + tests_total_ok_percent: int + tests_total_nok: int + tests_total_nok_percent: int + + +@dataclass +class RunSummaryResult: + id: int + project_id: int + project_name: str + start: datetime | None + finish: datetime | None + duration: timedelta | None + status: str | None + status_by_nok: str + compromised: bool | None + conclusion: str + conclusion_reason: str | None + metadata: list[str] + important_tags: list[str] + relevant_tags: list[str] + stats: RunSummaryStats | None + + +@dataclass +class RunListPagination: + count: int + next: str | None + previous: str | None + + +@dataclass +class RunListResult: + pagination: RunListPagination + results: list[RunSummaryResult] + + +@dataclass +class RunCommentResult: + id: int + comment: str diff --git a/bublik/core/run/services.py b/bublik/core/run/services.py index e0bb1e75..551b0e25 100644 --- a/bublik/core/run/services.py +++ b/bublik/core/run/services.py @@ -22,6 +22,15 @@ unmark_run_compromised, validate_compromised_request, ) +from bublik.core.run.dto import ( + MarkRunCompromisedResult, + RunCommentResult, + RunCompromisedResult, + RunDetailsResult, + RunListPagination, + RunListResult, + RunStatsResult, +) from bublik.core.run.external_links import get_sources from bublik.core.run.filter_expression import filter_by_expression from bublik.core.run.stats import ( @@ -71,7 +80,7 @@ def get_run(run_id: int) -> models.TestIterationResult: raise NotFoundError(msg) from e @staticmethod - def get_run_details(run_id: int) -> dict: + def get_run_details(run_id: int) -> RunDetailsResult: ''' Get full details for a single run. @@ -79,7 +88,7 @@ def get_run_details(run_id: int) -> dict: run_id: The ID of the test run Returns: - Dictionary with full run details + RunDetailsResult with full run details ''' run = RunService.get_run(run_id) return generate_all_run_details(run) @@ -99,7 +108,10 @@ def get_run_status(run_id: int) -> str: return get_run_status(run) @staticmethod - def get_run_stats(run_id: int, requirements: str | None = None) -> dict: + def get_run_stats( + run_id: int, + requirements: str | None = None, + ) -> RunStatsResult | None: ''' Get statistics for a run. @@ -108,7 +120,7 @@ def get_run_stats(run_id: int, requirements: str | None = None) -> dict: requirements: Optional requirements filter Returns: - Dictionary with run statistics + RunStatsResult with run statistics or None if stats are unavailable ''' return get_run_stats_detailed_with_comments(run_id, requirements) @@ -127,7 +139,7 @@ def get_run_source(run_id: int) -> str: return get_sources(run) @staticmethod - def get_run_compromised(run_id: int) -> dict: + def get_run_compromised(run_id: int) -> RunCompromisedResult: ''' Get compromised status for a run. @@ -135,13 +147,10 @@ def get_run_compromised(run_id: int) -> dict: run_id: The ID of the test run Returns: - Dictionary with compromised status data + RunCompromisedResult with compromised status data ''' run = RunService.get_run(run_id) - compromised_data = is_run_compromised(run) - if not compromised_data: - compromised_data = {'compromised': False} - return compromised_data + return RunCompromisedResult(compromised=bool(is_run_compromised(run))) @staticmethod def mark_run_compromised( @@ -149,7 +158,7 @@ def mark_run_compromised( comment: str, bug_id: str | None = None, reference_key: str | None = None, - ) -> dict: + ) -> MarkRunCompromisedResult: ''' Mark a run as compromised. @@ -160,7 +169,7 @@ def mark_run_compromised( reference_key: Optional reference key Returns: - Dictionary with comment and bug info + MarkRunCompromisedResult with comment and bug info Raises: ValidationError: if validation fails @@ -170,10 +179,10 @@ def mark_run_compromised( raise ValidationError(err_msg) mark_run_compromised(run_id, comment, bug_id, reference_key) - return { - 'comment': comment, - 'bug': f'Bug ID: {bug_id}' if bug_id else None, - } + return MarkRunCompromisedResult( + comment=comment, + bug=f'Bug ID: {bug_id}' if bug_id else None, + ) @staticmethod def unmark_run_compromised(run_id: int) -> None: @@ -260,7 +269,7 @@ def list_runs( project_id: int | None = None, page: int | None = None, page_size: int | None = None, - ) -> dict: + ) -> RunListResult: ''' List runs filtered by date range and optionally by project. @@ -272,7 +281,7 @@ def list_runs( page_size: Items per page (default: 25, max: 10000) Returns: - Dictionary with pagination metadata and run detail dictionaries + RunListResult with pagination metadata and run details ''' runs = RunService.list_runs_queryset( @@ -280,8 +289,12 @@ def list_runs( finish_date=finish_date, project_id=project_id, ) - runs_details = generate_runs_details(runs) - return PaginatedResult.paginate_queryset(runs_details, page, page_size) + paginated_runs = PaginatedResult.paginate_queryset(runs, page, page_size) + + return RunListResult( + pagination=RunListPagination(**paginated_runs['pagination']), + results=generate_runs_details(paginated_runs['results']), + ) @staticmethod def aggregate_runs_by_period( @@ -526,7 +539,7 @@ def get_run_requirements(run_id: int) -> list[str]: ) @staticmethod - def get_nok_distribution(run_id: int) -> dict: + def get_nok_distribution(run_id: int) -> list[bool]: ''' Get NOK (failure) distribution for a run. @@ -534,10 +547,10 @@ def get_nok_distribution(run_id: int) -> dict: run_id: The ID of the test run Returns: - Dictionary with NOK distribution data + List of NOK distribution flags ''' run = RunService.get_run(run_id) - return get_nok_results_distribution(run) + return list(get_nok_results_distribution(run)) @staticmethod def get_run_comment(run_id: int) -> str | None: @@ -566,7 +579,7 @@ def get_run_comment(run_id: int) -> str | None: return comments.first().meta.value @staticmethod - def create_run_comment(run_id: int, content: str) -> dict: + def create_run_comment(run_id: int, content: str) -> RunCommentResult: ''' Create or update run comment. @@ -575,7 +588,7 @@ def create_run_comment(run_id: int, content: str) -> dict: content: Comment content Returns: - Dictionary with comment details + RunCommentResult with comment details ''' run = RunService.get_run(run_id) @@ -594,10 +607,10 @@ def create_run_comment(run_id: int, content: str) -> dict: ) mr, _ = mr_serializer.get_or_create() - return { - 'id': mr.id, - 'comment': mr.meta.value, - } + return RunCommentResult( + id=mr.id, + comment=mr.meta.value, + ) @staticmethod def delete_run_comment(run_id: int) -> None: diff --git a/bublik/core/run/stats.py b/bublik/core/run/stats.py index 21a76276..cd95996e 100644 --- a/bublik/core/run/stats.py +++ b/bublik/core/run/stats.py @@ -27,6 +27,17 @@ get_tags_by_runs, is_result_unexpected, ) +from bublik.core.run.dto import ( + RunCompromisedDetails, + RunDetailsResult, + RunRevision, + RunSpecialCategory, + RunStatsComment, + RunStatsResult, + RunStatsValues, + RunSummaryResult, + RunSummaryStats, +) from bublik.core.run.filter_expression import filter_by_expression from bublik.core.utils import key_value_dict_transforming, key_value_list_transforming from bublik.data.models import ( @@ -203,9 +214,29 @@ def generate_result( def get_run_stats_detailed_with_comments(run_id, requirements): run_stats = get_run_stats_detailed(run_id, requirements) + if run_stats is None: + return None + tests_comments = get_tests_comments(run_id) add_comments(run_stats, tests_comments) - return run_stats + return _run_stats_data_to_result(run_stats) + + +def _run_stats_data_to_result(stats_data): + return RunStatsResult( + result_id=stats_data['result_id'], + exec_seqno=stats_data['exec_seqno'], + parent_id=stats_data['parent_id'], + type=stats_data['type'], + test_id=stats_data['test_id'], + test_name=stats_data['test_name'], + period=stats_data['period'], + path=stats_data['path'], + objective=stats_data['objective'], + children=[_run_stats_data_to_result(child) for child in stats_data.get('children', [])], + stats=RunStatsValues(**stats_data['stats']), + comments=[RunStatsComment(**comment) for comment in stats_data.get('comments', [])], + ) def add_comments(node, tests_comments): @@ -440,14 +471,14 @@ def get_run_stats_summary(run_id): tests_total_ok_percent += 1 tests_total_nok_percent -= 1 - return { - 'tests_total': tests_total, - 'tests_total_plan_percent': tests_total_plan_percent, - 'tests_total_ok': tests_total_ok, - 'tests_total_ok_percent': tests_total_ok_percent, - 'tests_total_nok': tests_total_nok, - 'tests_total_nok_percent': tests_total_nok_percent, - } + return RunSummaryStats( + tests_total=tests_total, + tests_total_plan_percent=tests_total_plan_percent, + tests_total_ok=tests_total_ok, + tests_total_ok_percent=tests_total_ok_percent, + tests_total_nok=tests_total_nok, + tests_total_nok_percent=tests_total_nok_percent, + ) def get_expected_results(result): @@ -655,7 +686,10 @@ def generate_all_run_details(run): q = MetaResultsQuery(run_meta_results) branches = q.metas_query('branch') - revisions = build_revision_references(q.revision_related_query(), project.id) + revisions = [ + RunRevision(**revision) + for revision in build_revision_references(q.revision_related_query(), project.id) + ] labels = q.labels_query(category_names, project.id) configurations = list( key_value_list_transforming( @@ -664,32 +698,45 @@ def generate_all_run_details(run): ], ), ) - categories = get_metas_by_category(run_meta_results, category_names, project.id) - for category, category_values in categories.items(): - categories[category] = list(key_value_list_transforming(category_values)) + categories = [ + RunSpecialCategory( + name=category, + values=list(key_value_list_transforming(category_values)), + ) + for category, category_values in get_metas_by_category( + run_meta_results, + category_names, + project.id, + ).items() + ] + compromised_details = get_compromised_details(run) logger.debug('[run_details]: preparing resulting dict') - return { - 'project_id': project.id, - 'project_name': project.name, - 'id': run_id, - 'start': run.start, - 'finish': run.finish, - 'duration': run.duration, - 'main_package': run.main_package.iteration.test.name if run.main_package else None, - 'status': get_run_status(run), - 'status_by_nok': get_run_status_by_nok(run)[0], - 'compromised': get_compromised_details(run), - 'conclusion': conclusion, - 'conclusion_reason': conclusion_reason, - 'important_tags': important_tags.get(run_id, []), - 'relevant_tags': relevant_tags.get(run_id, []), - 'branches': list(key_value_list_transforming(branches)), - 'revisions': revisions, - 'labels': list(key_value_list_transforming(labels)), - 'special_categories': categories, - 'configuration': configurations[0] if configurations else None, - } + return RunDetailsResult( + project_id=project.id, + project_name=project.name, + id=run_id, + start=run.start, + finish=run.finish, + duration=run.duration, + main_package=run.main_package.iteration.test.name if run.main_package else None, + status=get_run_status(run), + status_by_nok=get_run_status_by_nok(run)[0], + compromised=( + RunCompromisedDetails(**compromised_details) + if compromised_details is not None + else None + ), + conclusion=conclusion, + conclusion_reason=conclusion_reason, + important_tags=important_tags.get(run_id, []), + relevant_tags=relevant_tags.get(run_id, []), + branches=list(key_value_list_transforming(branches)), + revisions=revisions, + labels=list(key_value_list_transforming(labels)), + special_categories=categories, + configuration=configurations[0] if configurations else None, + ) def generate_runs_details(runs): @@ -701,23 +748,23 @@ def generate_runs_details(runs): run_id = run.id conclusion, conclusion_reason = get_run_conclusion(run) runs_data.append( - { - 'id': run_id, - 'project_id': run.project.id, - 'project_name': run.project.name, - 'start': run.start, - 'finish': run.finish, - 'duration': run.duration, - 'status': get_run_status(run), - 'status_by_nok': get_run_status_by_nok(run)[0], - 'compromised': is_run_compromised(run), - 'conclusion': conclusion, - 'conclusion_reason': conclusion_reason, - 'metadata': metadata_by_runs.get(run_id, []), - 'important_tags': important_tags.get(run_id, []), - 'relevant_tags': relevant_tags.get(run_id, []), - 'stats': get_run_stats_summary(run_id), - }, + RunSummaryResult( + id=run_id, + project_id=run.project.id, + project_name=run.project.name, + start=run.start, + finish=run.finish, + duration=run.duration, + status=get_run_status(run), + status_by_nok=get_run_status_by_nok(run)[0], + compromised=is_run_compromised(run), + conclusion=conclusion, + conclusion_reason=conclusion_reason, + metadata=metadata_by_runs.get(run_id, []), + important_tags=important_tags.get(run_id, []), + relevant_tags=relevant_tags.get(run_id, []), + stats=get_run_stats_summary(run_id), + ), ) return runs_data diff --git a/bublik/interfaces/api_v2/run/serializers.py b/bublik/interfaces/api_v2/run/serializers.py new file mode 100644 index 00000000..a973f82c --- /dev/null +++ b/bublik/interfaces/api_v2/run/serializers.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from __future__ import annotations + +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from bublik.core.run.dto import ( + MarkRunCompromisedResult, + RunCommentResult, + RunCompromisedDetails, + RunCompromisedResult, + RunDetailsResult, + RunRevision, + RunSpecialCategory, + RunStatsComment, + RunStatsResult, + RunStatsValues, + RunSummaryResult, + RunSummaryStats, + ) + + +def serialize_run_compromised_details( + compromised: RunCompromisedDetails | None, +): + if compromised is None: + return None + + return { + 'status': compromised.status, + 'comment': compromised.comment, + 'bug_id': compromised.bug_id, + 'bug_url': compromised.bug_url, + } + + +def serialize_run_revision(revision: RunRevision): + return { + 'name': revision.name, + 'value': revision.value, + 'url': revision.url, + } + + +def serialize_run_special_categories(categories: list[RunSpecialCategory]): + return {category.name: category.values for category in categories} + + +def serialize_run_details(run_details: RunDetailsResult): + return { + 'project_id': run_details.project_id, + 'project_name': run_details.project_name, + 'id': run_details.id, + 'start': run_details.start, + 'finish': run_details.finish, + 'duration': run_details.duration, + 'main_package': run_details.main_package, + 'status': run_details.status, + 'status_by_nok': run_details.status_by_nok, + 'compromised': serialize_run_compromised_details(run_details.compromised), + 'conclusion': run_details.conclusion, + 'conclusion_reason': run_details.conclusion_reason, + 'important_tags': run_details.important_tags, + 'relevant_tags': run_details.relevant_tags, + 'branches': run_details.branches, + 'revisions': [serialize_run_revision(revision) for revision in run_details.revisions], + 'labels': run_details.labels, + 'special_categories': serialize_run_special_categories( + run_details.special_categories, + ), + 'configuration': run_details.configuration, + } + + +def serialize_run_stats_values(stats: RunStatsValues): + return { + 'passed': stats.passed, + 'failed': stats.failed, + 'passed_unexpected': stats.passed_unexpected, + 'failed_unexpected': stats.failed_unexpected, + 'skipped': stats.skipped, + 'skipped_unexpected': stats.skipped_unexpected, + 'abnormal': stats.abnormal, + } + + +def serialize_run_stats_comment(comment: RunStatsComment): + return { + 'comment_id': comment.comment_id, + 'updated': comment.updated, + 'serial': comment.serial, + 'comment': comment.comment, + } + + +def serialize_run_stats_result(stats: RunStatsResult | None): + if stats is None: + return None + + return { + 'result_id': stats.result_id, + 'exec_seqno': stats.exec_seqno, + 'parent_id': stats.parent_id, + 'type': stats.type, + 'test_id': stats.test_id, + 'test_name': stats.test_name, + 'period': stats.period, + 'path': stats.path, + 'objective': stats.objective, + 'children': [serialize_run_stats_result(child) for child in stats.children], + 'stats': serialize_run_stats_values(stats.stats), + 'comments': [serialize_run_stats_comment(comment) for comment in stats.comments], + } + + +def serialize_run_compromised_result(result: RunCompromisedResult): + return {'compromised': result.compromised} + + +def serialize_mark_run_compromised_result(result: MarkRunCompromisedResult): + return { + 'comment': result.comment, + 'bug': result.bug, + } + + +def serialize_run_summary_stats(stats: RunSummaryStats | None): + if stats is None: + return None + + return { + 'tests_total': stats.tests_total, + 'tests_total_plan_percent': stats.tests_total_plan_percent, + 'tests_total_ok': stats.tests_total_ok, + 'tests_total_ok_percent': stats.tests_total_ok_percent, + 'tests_total_nok': stats.tests_total_nok, + 'tests_total_nok_percent': stats.tests_total_nok_percent, + } + + +def serialize_run_summary_result(result: RunSummaryResult): + return { + 'id': result.id, + 'project_id': result.project_id, + 'project_name': result.project_name, + 'start': result.start, + 'finish': result.finish, + 'duration': result.duration, + 'status': result.status, + 'status_by_nok': result.status_by_nok, + 'compromised': result.compromised, + 'conclusion': result.conclusion, + 'conclusion_reason': result.conclusion_reason, + 'metadata': result.metadata, + 'important_tags': result.important_tags, + 'relevant_tags': result.relevant_tags, + 'stats': serialize_run_summary_stats(result.stats), + } + + +def serialize_run_summary_results(results: list[RunSummaryResult]): + return [serialize_run_summary_result(result) for result in results] + + +def serialize_paginated_run_summary_results(paginated_result): + paginated_result['results'] = serialize_run_summary_results( + paginated_result['results'], + ) + return paginated_result + + +def serialize_run_comment_result(result: RunCommentResult): + return { + 'id': result.id, + 'comment': result.comment, + } diff --git a/bublik/interfaces/api_v2/run/views.py b/bublik/interfaces/api_v2/run/views.py index 52a3a4bf..419a4ae1 100644 --- a/bublik/interfaces/api_v2/run/views.py +++ b/bublik/interfaces/api_v2/run/views.py @@ -17,6 +17,14 @@ RunCommentSerializer, TestIterationResultSerializer, ) +from bublik.interfaces.api_v2.run.serializers import ( + serialize_mark_run_compromised_result, + serialize_run_comment_result, + serialize_run_compromised_result, + serialize_run_details, + serialize_run_stats_result, + serialize_run_summary_results, +) __all__ = [ @@ -49,7 +57,7 @@ def list(self, request): return Response( { 'pagination': self.paginator.get_pagination(), - 'results': generate_runs_details(results), + 'results': serialize_run_summary_results(generate_runs_details(results)), }, ) @@ -88,12 +96,13 @@ def nok_distribution(self, _request, pk=None): @action(detail=True, methods=['get']) def details(self, _request, pk=None): - return Response(data=RunService.get_run_details(pk)) + return Response(data=serialize_run_details(RunService.get_run_details(pk))) @action(detail=True, methods=['get']) def stats(self, _request, pk=None): requirements = self.request.query_params.get('requirements') - return Response({'results': RunService.get_run_stats(pk, requirements)}) + run_stats = RunService.get_run_stats(pk, requirements) + return Response({'results': serialize_run_stats_result(run_stats)}) @action(detail=True, methods=['get']) def requirements(self, _request, pk=None): @@ -110,12 +119,18 @@ def status(self, _request, pk=None): @action(detail=True, methods=['get', 'post', 'delete']) def compromised(self, request, pk=None): if request.method == 'GET': - return Response(RunService.get_run_compromised(pk)) + return Response( + serialize_run_compromised_result(RunService.get_run_compromised(pk)), + ) if request.method == 'POST': comment = request.data.get('comment') bug_id = request.data.get('bug_id') reference_key = request.data.get('reference_key') - return Response(RunService.mark_run_compromised(pk, comment, bug_id, reference_key)) + return Response( + serialize_mark_run_compromised_result( + RunService.mark_run_compromised(pk, comment, bug_id, reference_key), + ), + ) try: RunService.unmark_run_compromised(pk) return Response({'message': f'Run {pk} is no longer compromised'}) @@ -136,12 +151,15 @@ def comment(self, request, pk=None): if request.method == 'POST': content = request.data.get('comment') result = RunService.create_run_comment(pk, content) - return Response(result, status=status.HTTP_201_CREATED) + return Response( + serialize_run_comment_result(result), + status=status.HTTP_201_CREATED, + ) if request.method == 'PUT': content = request.data.get('comment') result = RunService.create_run_comment(pk, content) - return Response(result, status=status.HTTP_200_OK) + return Response(serialize_run_comment_result(result), status=status.HTTP_200_OK) if request.method == 'DELETE': RunService.delete_run_comment(pk) diff --git a/bublik/mcp/tools.py b/bublik/mcp/tools.py index fe56d9f9..fb48a2f9 100644 --- a/bublik/mcp/tools.py +++ b/bublik/mcp/tools.py @@ -19,6 +19,12 @@ from bublik.core.run.stats import generate_runs_details, get_test_runs from bublik.core.server import ServerService from bublik.core.tree.services import TreeService +from bublik.interfaces.api_v2.run.serializers import ( + serialize_paginated_run_summary_results, + serialize_run_compromised_result, + serialize_run_details, + serialize_run_stats_result, +) from bublik.mcp.models import JsonLog from bublik.mcp.processor import LogProcessor @@ -59,7 +65,7 @@ async def get_run_details(run_id: int) -> dict: Returns: Dictionary with full run details including metadata, stats, etc. ''' - return await sync_to_async(RunService.get_run_details)(run_id) + return serialize_run_details(await sync_to_async(RunService.get_run_details)(run_id)) @mcp.tool() async def get_run_status(run_id: int) -> str: @@ -89,7 +95,9 @@ async def get_run_stats( Returns: Dictionary with run statistics including pass/fail counts ''' - return await sync_to_async(RunService.get_run_stats)(run_id, requirements) + return serialize_run_stats_result( + await sync_to_async(RunService.get_run_stats)(run_id, requirements), + ) @mcp.tool() async def get_run_source(run_id: int) -> str: @@ -115,7 +123,9 @@ async def get_run_compromised(run_id: int) -> dict: Returns: Dictionary with compromised status data including comment and bug ID ''' - return await sync_to_async(RunService.get_run_compromised)(run_id) + return serialize_run_compromised_result( + await sync_to_async(RunService.get_run_compromised)(run_id), + ) @mcp.tool() async def get_result_details(result_id: int) -> dict: @@ -257,10 +267,12 @@ async def list_runs( # noqa: PLR0913 branch_expr=branch_expr, ) runs_details = await sync_to_async(generate_runs_details)(queryset) - return await sync_to_async(PaginatedResult.paginate_queryset)( - runs_details, - page, - page_size, + return serialize_paginated_run_summary_results( + await sync_to_async(PaginatedResult.paginate_queryset)( + runs_details, + page, + page_size, + ), ) @mcp.tool() @@ -292,10 +304,12 @@ async def list_runs_today( else [] ) runs_details = await sync_to_async(generate_runs_details)(queryset) if date_str else [] - return await sync_to_async(PaginatedResult.paginate_queryset)( - runs_details, - page, - page_size, + return serialize_paginated_run_summary_results( + await sync_to_async(PaginatedResult.paginate_queryset)( + runs_details, + page, + page_size, + ), ) @mcp.tool() From 7bcbb2875b64a192ab4641ee65eacc33f569bbec Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Wed, 17 Jun 2026 20:13:24 +0000 Subject: [PATCH 4/5] run: align OpenAPI schemas with actual API responses Ensure consistency between OpenAPI schemas and API responses by introducing explicit request/response serializers and binding them via drf-spectacular. Signed-off-by: Mikhail Zayats --- bublik/interfaces/api_v2/run/schemas.py | 311 ++++++++++++++++++++ bublik/interfaces/api_v2/run/serializers.py | 184 ++++++++++++ bublik/interfaces/api_v2/run/views.py | 102 ++++--- 3 files changed, 554 insertions(+), 43 deletions(-) create mode 100644 bublik/interfaces/api_v2/run/schemas.py diff --git a/bublik/interfaces/api_v2/run/schemas.py b/bublik/interfaces/api_v2/run/schemas.py new file mode 100644 index 00000000..a70c8b2d --- /dev/null +++ b/bublik/interfaces/api_v2/run/schemas.py @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view + +from bublik.interfaces.api_v2.errors.serializers import ErrorResponseSerializer +from bublik.interfaces.api_v2.run.serializers import ( + DropCacheRequestSerializer, + DropCacheResponseSerializer, + MarkRunCompromisedRequestSerializer, + MarkRunCompromisedResponseSerializer, + RunCommentRequestSerializer, + RunCommentResponseSerializer, + RunCommentValueResponseSerializer, + RunCompromisedResponseSerializer, + RunDetailsResponseSerializer, + RunListQuerySerializer, + RunListResponseSerializer, + RunRequirementsResponseSerializer, + RunSourceResponseSerializer, + RunStatsQuerySerializer, + RunStatsResponseSerializer, + RunStatusResponseSerializer, + UnmarkRunCompromisedResponseSerializer, +) + + +RUN_TAG = 'Runs' + + +run_viewset_schema = extend_schema_view( + list=extend_schema( + summary='List runs', + description=''' + Returns a paginated list of test runs with project, timing, status, + classification, metadata, tag, and summary statistics fields. + Supports date, project, status, metadata, and expression filters. + ''', + parameters=[RunListQuerySerializer], + responses={ + 200: OpenApiResponse( + response=RunListResponseSerializer, + description='Runs were successfully retrieved', + ), + }, + tags=[RUN_TAG], + ), + drop_cache=extend_schema( + summary='Drop run cache', + description=''' + Deletes selected cache entries for every run matching the current run + filters and returns identifiers of affected runs. + ''', + request=DropCacheRequestSerializer, + responses={ + 200: OpenApiResponse( + response=DropCacheResponseSerializer, + description='Run cache entries were successfully deleted', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Unknown cache key was provided', + ), + }, + tags=[RUN_TAG], + ), + nok_distribution=extend_schema( + summary='Get NOK distribution', + description=''' + Returns a boolean NOK flag for each test result in the run. + ''', + responses={ + 200: OpenApiResponse( + response={'type': 'array', 'items': {'type': 'boolean'}}, + description='NOK distribution was successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + details=extend_schema( + summary='Get run details', + description=''' + Returns full details for a single run, including metadata, tags, + branches, revisions, labels, configuration, status, and conclusion. + ''', + responses={ + 200: OpenApiResponse( + response=RunDetailsResponseSerializer, + description='Run details were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + stats=extend_schema( + summary='Get run statistics', + description=''' + Returns the run result tree with aggregated pass/fail statistics and + comments. The optional requirements filter limits statistics to tests + matching the provided requirement list. + ''', + parameters=[RunStatsQuerySerializer], + responses={ + 200: OpenApiResponse( + response=RunStatsResponseSerializer, + description='Run statistics were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + requirements=extend_schema( + summary='List run requirements', + description=''' + Returns sorted requirement values associated with a run. + ''', + responses={ + 200: OpenApiResponse( + response=RunRequirementsResponseSerializer, + description='Run requirements were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + source=extend_schema( + summary='Get run source URL', + description=''' + Returns the external source URL associated with a run. + ''', + responses={ + 200: OpenApiResponse( + response=RunSourceResponseSerializer, + description='Run source URL was successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + status=extend_schema( + summary='Get run status', + description=''' + Returns the configured status value for a run. + ''', + responses={ + 200: OpenApiResponse( + response=RunStatusResponseSerializer, + description='Run status was successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + compromised=extend_schema( + summary='Get run compromised status', + description=''' + Returns whether a run is marked as compromised. + ''', + responses={ + 200: OpenApiResponse( + response=RunCompromisedResponseSerializer, + description='Run compromised status was successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), + comment=extend_schema( + summary='Get run comment', + description=''' + Returns the current run comment, or null if no comment exists. + ''', + responses={ + 200: OpenApiResponse( + response=RunCommentValueResponseSerializer, + description='Run comment was successfully retrieved', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Multiple comments were found for the run', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], + ), +) + + +mark_compromised_schema = extend_schema( + summary='Mark run as compromised', + description=''' + Marks a run as compromised using a required comment and optional bug + reference data. + ''', + request=MarkRunCompromisedRequestSerializer, + responses={ + 200: OpenApiResponse( + response=MarkRunCompromisedResponseSerializer, + description='Run was successfully marked as compromised', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Compromised request validation failed', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run was not found', + ), + }, + tags=[RUN_TAG], +) + + +unmark_compromised_schema = extend_schema( + summary='Unmark run as compromised', + description=''' + Removes the compromised marker from a run. + ''', + responses={ + 200: OpenApiResponse( + response=UnmarkRunCompromisedResponseSerializer, + description='Run was successfully unmarked as compromised', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run could not be unmarked', + ), + }, + tags=[RUN_TAG], +) + + +create_comment_schema = extend_schema( + summary='Create run comment', + description=''' + Creates or replaces a run comment. + ''', + request=RunCommentRequestSerializer, + responses={ + 201: OpenApiResponse( + response=RunCommentResponseSerializer, + description='Run comment was successfully created', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run comment request validation failed', + ), + }, + tags=[RUN_TAG], +) + + +update_comment_schema = extend_schema( + summary='Update run comment', + description=''' + Creates or replaces a run comment. + ''', + request=RunCommentRequestSerializer, + responses={ + 200: OpenApiResponse( + response=RunCommentResponseSerializer, + description='Run comment was successfully updated', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run comment request validation failed', + ), + }, + tags=[RUN_TAG], +) + + +delete_comment_schema = extend_schema( + summary='Delete run comment', + description=''' + Deletes the current run comment. + ''', + responses={ + 204: OpenApiResponse(description='Run comment was successfully deleted'), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='No comment exists for the run', + ), + }, + tags=[RUN_TAG], +) diff --git a/bublik/interfaces/api_v2/run/serializers.py b/bublik/interfaces/api_v2/run/serializers.py index a973f82c..f4106da0 100644 --- a/bublik/interfaces/api_v2/run/serializers.py +++ b/bublik/interfaces/api_v2/run/serializers.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING +from rest_framework import serializers + if TYPE_CHECKING: from bublik.core.run.dto import ( @@ -23,6 +25,188 @@ ) +class PaginationSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + + +class RunListQuerySerializer(serializers.Serializer): + start_date = serializers.DateField(required=False) + finish_date = serializers.DateField(required=False) + project = serializers.IntegerField(required=False) + run_status = serializers.CharField(required=False) + run_metas = serializers.CharField(required=False) + tag_expr = serializers.CharField(required=False) + label_expr = serializers.CharField(required=False) + revision_expr = serializers.CharField(required=False) + branch_expr = serializers.CharField(required=False) + + +class DropCacheRequestSerializer(serializers.Serializer): + keys = serializers.ListField(child=serializers.CharField()) + + +class DropCacheResponseSerializer(serializers.Serializer): + results = serializers.ListField(child=serializers.IntegerField()) + + +class CompromisedDetailsSerializer(serializers.Serializer): + status = serializers.BooleanField() + comment = serializers.CharField(allow_null=True, required=False) + bug_id = serializers.CharField(allow_null=True, required=False) + bug_url = serializers.CharField(allow_null=True, required=False) + + +class RevisionSerializer(serializers.Serializer): + name = serializers.CharField() + value = serializers.CharField() + url = serializers.CharField(allow_blank=True) + + +class RunSummaryStatsSerializer(serializers.Serializer): + tests_total = serializers.IntegerField() + tests_total_plan_percent = serializers.IntegerField(allow_null=True) + tests_total_ok = serializers.IntegerField() + tests_total_ok_percent = serializers.IntegerField() + tests_total_nok = serializers.IntegerField() + tests_total_nok_percent = serializers.IntegerField() + + +class RunListItemSerializer(serializers.Serializer): + id = serializers.IntegerField() + project_id = serializers.IntegerField() + project_name = serializers.CharField() + start = serializers.DateTimeField(allow_null=True) + finish = serializers.DateTimeField(allow_null=True) + duration = serializers.DurationField(allow_null=True) + status = serializers.CharField(allow_null=True) + status_by_nok = serializers.CharField() + compromised = serializers.BooleanField(allow_null=True) + conclusion = serializers.CharField() + conclusion_reason = serializers.CharField(allow_null=True) + metadata = serializers.ListField(child=serializers.CharField()) + important_tags = serializers.ListField(child=serializers.CharField()) + relevant_tags = serializers.ListField(child=serializers.CharField()) + stats = RunSummaryStatsSerializer(allow_null=True) + + +class RunListResponseSerializer(serializers.Serializer): + pagination = PaginationSerializer() + results = RunListItemSerializer(many=True) + + +class RunDetailsResponseSerializer(serializers.Serializer): + project_id = serializers.IntegerField() + project_name = serializers.CharField() + id = serializers.IntegerField() + start = serializers.DateTimeField(allow_null=True) + finish = serializers.DateTimeField(allow_null=True) + duration = serializers.DurationField(allow_null=True) + main_package = serializers.CharField(allow_null=True) + status = serializers.CharField(allow_null=True) + status_by_nok = serializers.CharField() + compromised = CompromisedDetailsSerializer(allow_null=True) + conclusion = serializers.CharField() + conclusion_reason = serializers.CharField(allow_null=True) + important_tags = serializers.ListField(child=serializers.CharField()) + relevant_tags = serializers.ListField(child=serializers.CharField()) + branches = serializers.ListField(child=serializers.CharField()) + revisions = RevisionSerializer(many=True) + labels = serializers.ListField(child=serializers.CharField()) + special_categories = serializers.DictField( + child=serializers.ListField(child=serializers.CharField()), + ) + configuration = serializers.CharField(allow_null=True) + + +class RunStatsQuerySerializer(serializers.Serializer): + requirements = serializers.CharField(required=False) + + +class RunStatsValuesSerializer(serializers.Serializer): + passed = serializers.IntegerField() + failed = serializers.IntegerField() + passed_unexpected = serializers.IntegerField() + failed_unexpected = serializers.IntegerField() + skipped = serializers.IntegerField() + skipped_unexpected = serializers.IntegerField() + abnormal = serializers.IntegerField() + + +class RunStatsCommentSerializer(serializers.Serializer): + comment_id = serializers.CharField() + updated = serializers.CharField() + serial = serializers.CharField() + comment = serializers.CharField() + + +class RunStatsNodeSerializer(serializers.Serializer): + result_id = serializers.IntegerField() + exec_seqno = serializers.IntegerField() + parent_id = serializers.IntegerField(allow_null=True) + type = serializers.CharField() + test_id = serializers.IntegerField() + test_name = serializers.CharField() + period = serializers.CharField() + path = serializers.ListField(child=serializers.CharField()) + objective = serializers.CharField(allow_blank=True) + children = serializers.ListField( + child=serializers.DictField(), + help_text='Child nodes with the same structure.', + ) + stats = RunStatsValuesSerializer() + comments = RunStatsCommentSerializer(many=True) + + +class RunStatsResponseSerializer(serializers.Serializer): + results = RunStatsNodeSerializer(allow_null=True) + + +class RunRequirementsResponseSerializer(serializers.Serializer): + requirements = serializers.ListField(child=serializers.CharField()) + + +class RunSourceResponseSerializer(serializers.Serializer): + url = serializers.CharField(allow_null=True) + + +class RunStatusResponseSerializer(serializers.Serializer): + status = serializers.CharField(allow_null=True) + + +class RunCompromisedResponseSerializer(serializers.Serializer): + compromised = serializers.BooleanField() + + +class MarkRunCompromisedRequestSerializer(serializers.Serializer): + comment = serializers.CharField() + bug_id = serializers.CharField(required=False, allow_null=True) + reference_key = serializers.CharField(required=False, allow_null=True) + + +class MarkRunCompromisedResponseSerializer(serializers.Serializer): + comment = serializers.CharField() + bug = serializers.CharField(allow_null=True) + + +class UnmarkRunCompromisedResponseSerializer(serializers.Serializer): + message = serializers.CharField() + + +class RunCommentRequestSerializer(serializers.Serializer): + comment = serializers.CharField() + + +class RunCommentValueResponseSerializer(serializers.Serializer): + comment = serializers.CharField(allow_null=True) + + +class RunCommentResponseSerializer(serializers.Serializer): + id = serializers.IntegerField() + comment = serializers.CharField() + + def serialize_run_compromised_details( compromised: RunCompromisedDetails | None, ): diff --git a/bublik/interfaces/api_v2/run/views.py b/bublik/interfaces/api_v2/run/views.py index 419a4ae1..511650cc 100644 --- a/bublik/interfaces/api_v2/run/views.py +++ b/bublik/interfaces/api_v2/run/views.py @@ -13,11 +13,17 @@ generate_runs_details, ) from bublik.core.utils import get_difference -from bublik.data.serializers import ( - RunCommentSerializer, - TestIterationResultSerializer, +from bublik.data.serializers import TestIterationResultSerializer +from bublik.interfaces.api_v2.run.schemas import ( + create_comment_schema, + delete_comment_schema, + mark_compromised_schema, + run_viewset_schema, + unmark_compromised_schema, + update_comment_schema, ) from bublik.interfaces.api_v2.run.serializers import ( + RunCommentRequestSerializer, serialize_mark_run_compromised_result, serialize_run_comment_result, serialize_run_compromised_result, @@ -32,6 +38,7 @@ ] +@run_viewset_schema class RunViewSet(ModelViewSet): serializer_class = TestIterationResultSerializer @@ -116,21 +123,27 @@ def source(self, _request, pk=None): def status(self, _request, pk=None): return Response({'status': RunService.get_run_status(pk)}) - @action(detail=True, methods=['get', 'post', 'delete']) - def compromised(self, request, pk=None): - if request.method == 'GET': - return Response( - serialize_run_compromised_result(RunService.get_run_compromised(pk)), - ) - if request.method == 'POST': - comment = request.data.get('comment') - bug_id = request.data.get('bug_id') - reference_key = request.data.get('reference_key') - return Response( - serialize_mark_run_compromised_result( - RunService.mark_run_compromised(pk, comment, bug_id, reference_key), - ), - ) + @action(detail=True, methods=['get']) + def compromised(self, _request, pk=None): + return Response( + serialize_run_compromised_result(RunService.get_run_compromised(pk)), + ) + + @mark_compromised_schema + @compromised.mapping.post + def mark_compromised(self, request, pk=None): + comment = request.data.get('comment') + bug_id = request.data.get('bug_id') + reference_key = request.data.get('reference_key') + return Response( + serialize_mark_run_compromised_result( + RunService.mark_run_compromised(pk, comment, bug_id, reference_key), + ), + ) + + @unmark_compromised_schema + @compromised.mapping.delete + def unmark_compromised(self, _request, pk=None): try: RunService.unmark_run_compromised(pk) return Response({'message': f'Run {pk} is no longer compromised'}) @@ -140,29 +153,32 @@ def compromised(self, request, pk=None): @action( detail=True, - methods=['get', 'post', 'put', 'delete'], - serializer_class=RunCommentSerializer, + methods=['get'], + serializer_class=RunCommentRequestSerializer, ) - def comment(self, request, pk=None): - if request.method == 'GET': - comment = RunService.get_run_comment(pk) - return Response({'comment': comment}) - - if request.method == 'POST': - content = request.data.get('comment') - result = RunService.create_run_comment(pk, content) - return Response( - serialize_run_comment_result(result), - status=status.HTTP_201_CREATED, - ) - - if request.method == 'PUT': - content = request.data.get('comment') - result = RunService.create_run_comment(pk, content) - return Response(serialize_run_comment_result(result), status=status.HTTP_200_OK) - - if request.method == 'DELETE': - RunService.delete_run_comment(pk) - return Response(status=status.HTTP_204_NO_CONTENT) - - return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + def comment(self, _request, pk=None): + comment = RunService.get_run_comment(pk) + return Response({'comment': comment}) + + @create_comment_schema + @comment.mapping.post + def create_comment(self, request, pk=None): + content = request.data.get('comment') + result = RunService.create_run_comment(pk, content) + return Response( + serialize_run_comment_result(result), + status=status.HTTP_201_CREATED, + ) + + @update_comment_schema + @comment.mapping.put + def update_comment(self, request, pk=None): + content = request.data.get('comment') + result = RunService.create_run_comment(pk, content) + return Response(serialize_run_comment_result(result), status=status.HTTP_200_OK) + + @delete_comment_schema + @comment.mapping.delete + def delete_comment(self, _request, pk=None): + RunService.delete_run_comment(pk) + return Response(status=status.HTTP_204_NO_CONTENT) From dd7e981396abea3611fd5d04b3874c7299a7d245 Mon Sep 17 00:00:00 2001 From: Mikhail Zayats Date: Wed, 17 Jun 2026 20:14:18 +0000 Subject: [PATCH 5/5] result: align OpenAPI schemas with actual API responses Ensure consistency between OpenAPI schemas and API responses by introducing explicit request/response serializers and binding them via drf-spectacular. Signed-off-by: Mikhail Zayats --- bublik/interfaces/api_v2/result/schemas.py | 93 +++++++++++++++++++ .../interfaces/api_v2/result/serializers.py | 76 +++++++++++++++ bublik/interfaces/api_v2/result/views.py | 6 +- 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 bublik/interfaces/api_v2/result/schemas.py create mode 100644 bublik/interfaces/api_v2/result/serializers.py diff --git a/bublik/interfaces/api_v2/result/schemas.py b/bublik/interfaces/api_v2/result/schemas.py new file mode 100644 index 00000000..9f401d82 --- /dev/null +++ b/bublik/interfaces/api_v2/result/schemas.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view + +from bublik.interfaces.api_v2.errors.serializers import ErrorResponseSerializer +from bublik.interfaces.api_v2.result.serializers import ( + ResultArtifactsAndVerdictsResponseSerializer, + ResultListQuerySerializer, + ResultListResponseSerializer, + ResultMeasurementsResponseSerializer, + ResultRetrieveResponseSerializer, +) + + +RESULT_TAG = 'Results' + + +result_viewset_schema = extend_schema_view( + retrieve=extend_schema( + summary='Get result details', + description=''' + Returns full details for a single test iteration result, including + expected and obtained results, artifacts, parameters, comments, + requirements, error state, and measurement availability. + ''', + responses={ + 200: OpenApiResponse( + response=ResultRetrieveResponseSerializer, + description='Result details were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result was not found', + ), + }, + tags=[RESULT_TAG], + ), + list=extend_schema( + summary='List results', + description=''' + Returns test iteration results matching the provided parent, test name, + execution sequence, result status, classification, and requirement + filters. + ''', + parameters=[ResultListQuerySerializer], + responses={ + 200: OpenApiResponse( + response=ResultListResponseSerializer, + description='Results were successfully retrieved', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result filter validation failed', + ), + }, + tags=[RESULT_TAG], + ), + artifacts_and_verdicts=extend_schema( + summary='Get result artifacts and verdicts', + description=''' + Returns artifact and verdict meta values for a result. + ''', + responses={ + 200: OpenApiResponse( + response=ResultArtifactsAndVerdictsResponseSerializer, + description='Artifacts and verdicts were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result was not found', + ), + }, + tags=[RESULT_TAG], + ), + measurements=extend_schema( + summary='Get result measurements', + description=''' + Returns measurement chart and table data for a result. + ''', + responses={ + 200: OpenApiResponse( + response=ResultMeasurementsResponseSerializer, + description='Result measurements were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result was not found', + ), + }, + tags=[RESULT_TAG], + ), +) diff --git a/bublik/interfaces/api_v2/result/serializers.py b/bublik/interfaces/api_v2/result/serializers.py new file mode 100644 index 00000000..8bb7e058 --- /dev/null +++ b/bublik/interfaces/api_v2/result/serializers.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from rest_framework import serializers + + +class ResultListQuerySerializer(serializers.Serializer): + parent_id = serializers.IntegerField(required=False) + test_name = serializers.CharField(required=False) + start_exec_seqno = serializers.IntegerField(required=False) + results = serializers.CharField(required=False) + result_properties = serializers.CharField(required=False) + requirements = serializers.CharField(required=False) + + +class ResultKeySerializer(serializers.Serializer): + name = serializers.CharField() + url = serializers.CharField(allow_null=True) + + +class ExpectedResultSerializer(serializers.Serializer): + result_type = serializers.CharField(allow_null=True) + verdicts = serializers.ListField(child=serializers.CharField()) + keys = ResultKeySerializer(many=True) + + +class ObtainedResultSerializer(serializers.Serializer): + result_type = serializers.CharField(allow_null=True) + verdicts = serializers.ListField(child=serializers.CharField()) + + +class ResultDetailsSerializer(serializers.Serializer): + name = serializers.CharField() + result_id = serializers.IntegerField() + run_id = serializers.IntegerField() + project_id = serializers.IntegerField() + project_name = serializers.CharField() + iteration_id = serializers.IntegerField() + start = serializers.DateTimeField(allow_null=True) + obtained_result = ObtainedResultSerializer() + expected_results = ExpectedResultSerializer(many=True) + artifacts = serializers.ListField(child=serializers.CharField()) + parameters = serializers.ListField(child=serializers.CharField()) + comments = serializers.ListField(child=serializers.CharField()) + requirements = serializers.ListField(child=serializers.CharField()) + has_error = serializers.BooleanField() + has_measurements = serializers.BooleanField() + + +class ResultListResponseSerializer(serializers.Serializer): + results = ResultDetailsSerializer(many=True) + + +class ResultRetrieveResponseSerializer(serializers.Serializer): + result = ResultDetailsSerializer() + + +class MetaValueSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField(allow_null=True) + type = serializers.CharField() + value = serializers.CharField(allow_null=True) + hash = serializers.CharField() + comment = serializers.CharField(allow_null=True) + + +class ResultArtifactsAndVerdictsResponseSerializer(serializers.Serializer): + artifacts = MetaValueSerializer(many=True) + verdicts = MetaValueSerializer(many=True) + + +class ResultMeasurementsResponseSerializer(serializers.Serializer): + run_id = serializers.IntegerField(allow_null=True) + iteration_id = serializers.IntegerField() + charts = serializers.ListField(child=serializers.DictField()) + tables = serializers.ListField(child=serializers.DictField()) diff --git a/bublik/interfaces/api_v2/result/views.py b/bublik/interfaces/api_v2/result/views.py index 9daf201f..ccbe75d9 100644 --- a/bublik/interfaces/api_v2/result/views.py +++ b/bublik/interfaces/api_v2/result/views.py @@ -11,9 +11,8 @@ from bublik.core.run.stats import ( generate_results_details, ) -from bublik.data.serializers import ( - TestIterationResultSerializer, -) +from bublik.data.serializers import TestIterationResultSerializer +from bublik.interfaces.api_v2.result.schemas import result_viewset_schema __all__ = [ @@ -21,6 +20,7 @@ ] +@result_viewset_schema class ResultViewSet(ModelViewSet): serializer_class = TestIterationResultSerializer filter_backends: ClassVar[list] = []