diff --git a/bublik/core/report/dto.py b/bublik/core/report/dto.py new file mode 100644 index 00000000..45b7e73b --- /dev/null +++ b/bublik/core/report/dto.py @@ -0,0 +1,45 @@ +# 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 Any + + +@dataclass +class ReportConfigDTO: + name: str + description: str + version: int + + +@dataclass +class ReportConfigContentDTO: + config: ReportConfigDTO + content: dict[str, Any] + + +@dataclass +class RunReportConfigDTO: + id: int + name: str + version: int + project: int | None + description: str + + +@dataclass +class ReportUnprocessedIterDTO: + test_name: str + common_args: dict[str, Any] + args_vals: dict[str, Any] + reasons: list[str] + + +@dataclass +class ReportDTO: + warnings: list[str] + config: ReportConfigDTO + content: list[dict[str, Any]] + unprocessed_iters: list[ReportUnprocessedIterDTO] diff --git a/bublik/core/report/services.py b/bublik/core/report/services.py index 1b1fa368..f6a23134 100644 --- a/bublik/core/report/services.py +++ b/bublik/core/report/services.py @@ -4,10 +4,17 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, Q, Subquery -from django.forms.models import model_to_dict +from rest_framework.exceptions import ValidationError from bublik.core.exceptions import NotFoundError from bublik.core.report.components import ReportPoint, ReportTestLevel +from bublik.core.report.dto import ( + ReportConfigContentDTO, + ReportConfigDTO, + ReportDTO, + ReportUnprocessedIterDTO, + RunReportConfigDTO, +) from bublik.core.run.services import RunService from bublik.core.utils import parse_number, unordered_group_by from bublik.data.models import ( @@ -133,7 +140,7 @@ def filter_by_not_show_args(mmrs_test, not_show_args): class ReportService: @staticmethod - def get_report_config(config_id: int) -> tuple[Config, dict, dict]: + def get_report_config(config_id: int) -> ReportConfigContentDTO: """ Get and validate a report configuration. @@ -141,31 +148,40 @@ def get_report_config(config_id: int) -> tuple[Config, dict, dict]: config_id: The ID of the report config Returns: - Tuple of (config_obj, config_data, config_content) + ReportConfigContentDTO Raises: NotFoundError: if config not found + ValidationError: if config ID or content is invalid """ try: report_config_obj = Config.objects.get(id=config_id) + except ValueError as e: + msg = f'Invalid config ID: {config_id}' + raise ValidationError(msg) from e except ObjectDoesNotExist as e: msg = f'Config {config_id} not found' raise NotFoundError(msg) from e - config_data = model_to_dict( - report_config_obj, - fields=['name', 'description', 'version'], - ) - report_config = report_config_obj.content + report_config_content = report_config_obj.content # Validate config content - serializer = ConfigSerializer(report_config_obj, {'content': report_config}) - serializer.validate_content(report_config) + serializer = ConfigSerializer( + report_config_obj, data={'content': report_config_content} + ) + serializer.validate_content(report_config_content) - return report_config_obj, config_data, report_config + return ReportConfigContentDTO( + config=ReportConfigDTO( + name=report_config_obj.name, + description=report_config_obj.description, + version=report_config_obj.version, + ), + content=report_config_content, + ) @staticmethod - def get_configs_for_run_report(run) -> list[dict]: + def get_configs_for_run_report(run) -> list[RunReportConfigDTO]: """ Get available report configurations for a run. @@ -173,7 +189,7 @@ def get_configs_for_run_report(run) -> list[dict]: run: TestIterationResult instance Returns: - List of available report config dictionaries + List of available report configuration DTOs. """ iters = TestIterationResult.objects.filter(test_run=run) test_names = list( @@ -199,16 +215,19 @@ def get_configs_for_run_report(run) -> list[dict]: report_config_test_names = report_config_content.get('tests', {}).keys() if set(report_config_test_names).intersection(test_names): run_report_configs.append( - model_to_dict( - report_config, - exclude=['type', 'is_active', 'user', 'content'], + RunReportConfigDTO( + id=report_config.id, + name=report_config.name, + version=report_config.version, + project=report_config.project_id, + description=report_config.description, ), ) return run_report_configs @staticmethod - def get_most_recent_config_for_run_report(run) -> list[dict]: + def get_most_recent_config_for_run_report(run) -> int | None: """ Get the ID of the most recent available report configuration for a run. @@ -220,14 +239,17 @@ def get_most_recent_config_for_run_report(run) -> list[dict]: otherwise None if no configs exist. """ - run_report_configs_data = ReportService.get_configs_for_run_report(run) - if run_report_configs_data: + run_report_configs = ReportService.get_configs_for_run_report(run) + if run_report_configs: # get the ID of the most recent applicable config - return max(run_report_configs_data, key=lambda cfg_data: cfg_data['id'])['id'] + return max( + run_report_configs, + key=lambda report_config: report_config.id, + ).id return None @staticmethod - def generate_report(run_id: int, config_id: int) -> dict: + def generate_report(run_id: int, config_id: int) -> ReportDTO: """ Generate full report for a run using specified config. @@ -236,7 +258,7 @@ def generate_report(run_id: int, config_id: int) -> dict: config_id: The ID of the report config Returns: - Dictionary with warnings, config, content, unprocessed_iters + ReportDTO with warnings, config, content, unprocessed_iters Raises: NotFoundError: if run not found or config not found @@ -248,7 +270,8 @@ def generate_report(run_id: int, config_id: int) -> dict: main_pkg = run.root # Get and validate config - _, config_data, report_config = ReportService.get_report_config(config_id) + report_config_dto = ReportService.get_report_config(config_id) + report_config = report_config_dto.content # Get measurement results mmrs_run = ( @@ -318,16 +341,16 @@ def generate_report(run_id: int, config_id: int) -> dict: except ValueError as ve: test_name = mmr.result.iteration.test.name common_test_args = common_args[test_name] - invalid_iteration = { - 'test_name': test_name, - 'common_args': common_test_args, - 'args_vals': { + invalid_iteration = ReportUnprocessedIterDTO( + test_name=test_name, + common_args=common_test_args, + args_vals={ arg.name: parse_number(arg.value) for arg in mmr.result.iteration.test_arguments.all() if arg.name not in common_test_args }, - 'reasons': ve.args[0], - } + reasons=ve.args[0], + ) if invalid_iteration not in unprocessed_iters: unprocessed_iters.append(invalid_iteration) @@ -344,9 +367,9 @@ def generate_report(run_id: int, config_id: int) -> dict: test = ReportTestLevel(test_name, common_args, list(test_points), report_config) content.append(test.__dict__) - return { - 'warnings': warnings, - 'config': config_data, - 'content': content, - 'unprocessed_iters': unprocessed_iters, - } + return ReportDTO( + warnings=warnings, + config=report_config_dto.config, + content=content, + unprocessed_iters=unprocessed_iters, + ) diff --git a/bublik/interfaces/api_v2/__init__.py b/bublik/interfaces/api_v2/__init__.py index 6d2b48a0..1f071d83 100644 --- a/bublik/interfaces/api_v2/__init__.py +++ b/bublik/interfaces/api_v2/__init__.py @@ -29,7 +29,7 @@ from .outside_domains import OutsideDomainsViewSet from .performance import PerformanceCheckView from .project import ProjectViewSet -from .report import ReportViewSet +from .report.views import ReportViewSet from .result.views import ResultViewSet from .run.views import RunViewSet from .server import ServerViewSet diff --git a/bublik/interfaces/api_v2/report/schemas.py b/bublik/interfaces/api_v2/report/schemas.py new file mode 100644 index 00000000..021773d2 --- /dev/null +++ b/bublik/interfaces/api_v2/report/schemas.py @@ -0,0 +1,60 @@ +# 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.report.serializers import ( + ReportConfigListResponseSerializer, + ReportRetrieveQuerySerializer, + ReportRetrieveResponseSerializer, +) + + +report_viewset_schema = extend_schema_view( + configs=extend_schema( + summary='List of configurations', + description=""" + Return a list of active configs that can be + used to build a report on the current run. + """, + responses={ + 200: OpenApiResponse( + response=ReportConfigListResponseSerializer, + description='Configurations were successfully retrieved', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Current run was not found', + ), + }, + tags=['Report'], + ), + retrieve=extend_schema( + summary='Generate run report', + description=""" + Generates a report for the selected run using the report configuration + passed in the config query parameter. + """, + parameters=[ReportRetrieveQuerySerializer], + responses={ + 200: OpenApiResponse( + response=ReportRetrieveResponseSerializer, + description='Report was successfully generated', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Report config was not provided or is invalid', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Run or report config was not found', + ), + }, + tags=['Report'], + ), +) diff --git a/bublik/interfaces/api_v2/report/serializers.py b/bublik/interfaces/api_v2/report/serializers.py new file mode 100644 index 00000000..ceba182c --- /dev/null +++ b/bublik/interfaces/api_v2/report/serializers.py @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from rest_framework import serializers + + +class RunReportConfigSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + version = serializers.IntegerField() + project = serializers.IntegerField(allow_null=True) + description = serializers.CharField(allow_blank=True) + + +class ReportConfigListResponseSerializer(serializers.Serializer): + run_report_configs = RunReportConfigSerializer(many=True) + + +class ReportRetrieveQuerySerializer(serializers.Serializer): + config = serializers.IntegerField(required=True) + + +class ReportConfigSerializer(serializers.Serializer): + name = serializers.CharField() + description = serializers.CharField(allow_blank=True) + version = serializers.IntegerField() + + +class ReportUnprocessedItersSerializer(serializers.Serializer): + test_name = serializers.CharField() + common_args = serializers.DictField() + args_vals = serializers.DictField() + reasons = serializers.ListField( + child=serializers.CharField(), + ) + + +class ReportAxisSerializer(serializers.Serializer): + label = serializers.CharField() + key = serializers.CharField() + values = serializers.ListField( + child=serializers.JSONField(), + required=False, + ) + + +class ReportPointMetadataSerializer(serializers.Serializer): + iteration_id = serializers.IntegerField() + result_id = serializers.IntegerField() + has_error = serializers.BooleanField() + + +class ReportPointSerializer(serializers.Serializer): + x_value = serializers.JSONField() + y_value = serializers.JSONField(allow_null=True) + metadata = ReportPointMetadataSerializer(required=False) + + +class ReportRecordDataSerializer(serializers.Serializer): + series = serializers.CharField( + allow_blank=True, + required=False, + ) + points = ReportPointSerializer(many=True) + + +class ReportChartSerializer(serializers.Serializer): + warnings = serializers.ListField( + child=serializers.CharField(), + ) + axis_x = ReportAxisSerializer() + axis_y = ReportAxisSerializer() + series_label = serializers.CharField( + allow_blank=True, + required=False, + ) + data = ReportRecordDataSerializer(many=True) + + +class ReportTableSerializer(serializers.Serializer): + warnings = serializers.ListField( + child=serializers.CharField(), + ) + formatters = serializers.DictField( + child=serializers.CharField(), + required=False, + ) + labels = serializers.DictField( + child=serializers.CharField(), + ) + data = ReportRecordDataSerializer(many=True) + + +class ReportRecordContentSerializer(serializers.Serializer): + type = serializers.CharField() + id = serializers.CharField() + label = serializers.CharField() + chart = ReportChartSerializer(required=False) + table = ReportTableSerializer(required=False) + + +class ReportMeasurementContentSerializer(serializers.Serializer): + type = serializers.CharField() + label = serializers.CharField() + id = serializers.CharField() + content = ReportRecordContentSerializer(many=True) + + +class ReportArgsValsContentSerializer(serializers.Serializer): + type = serializers.CharField() + args_vals = serializers.DictField( + child=serializers.JSONField(), + ) + label = serializers.CharField() + id = serializers.CharField() + content = ReportMeasurementContentSerializer(many=True) + + +class ReportTestContentSerializer(serializers.Serializer): + type = serializers.CharField() + id = serializers.CharField() + label = serializers.CharField() + enable_table_view = serializers.BooleanField() + enable_chart_view = serializers.BooleanField() + common_args = serializers.DictField( + child=serializers.JSONField(), + ) + content = ReportArgsValsContentSerializer(many=True) + + +class ReportRetrieveResponseSerializer(serializers.Serializer): + warnings = serializers.ListField( + child=serializers.CharField(), + ) + config = ReportConfigSerializer() + content = ReportTestContentSerializer(many=True) + unprocessed_iters = ReportUnprocessedItersSerializer(many=True) diff --git a/bublik/interfaces/api_v2/report.py b/bublik/interfaces/api_v2/report/views.py similarity index 69% rename from bublik/interfaces/api_v2/report.py rename to bublik/interfaces/api_v2/report/views.py index ee9750a8..1ddd597a 100644 --- a/bublik/interfaces/api_v2/report.py +++ b/bublik/interfaces/api_v2/report/views.py @@ -10,6 +10,11 @@ from bublik.core.report.services import ReportService from bublik.data.models import TestIterationResult from bublik.data.serializers import TestIterationResultSerializer +from bublik.interfaces.api_v2.report.schemas import report_viewset_schema +from bublik.interfaces.api_v2.report.serializers import ( + ReportConfigListResponseSerializer, + ReportRetrieveResponseSerializer, +) __all__ = [ @@ -17,6 +22,7 @@ ] +@report_viewset_schema class ReportViewSet(RetrieveModelMixin, GenericViewSet): queryset = TestIterationResult.objects.all() serializer_class = TestIterationResultSerializer @@ -27,9 +33,12 @@ def configs(self, request, pk=None): Return a list of active configs that can be used to build a report on the current run. Request: GET /api/v2/report//configs """ - return Response( - {'run_report_configs': ReportService.get_configs_for_run_report(self.get_object())}, + report_configs = ReportService.get_configs_for_run_report(self.get_object()) + + serializer = ReportConfigListResponseSerializer( + instance={'run_report_configs': report_configs}, ) + return Response(serializer.data) def retrieve(self, request, pk=None): """ @@ -44,6 +53,7 @@ def retrieve(self, request, pk=None): # Generate report using service layer # Let exceptions bubble up to be handled by custom exception handler result = self.get_object() - report = ReportService.generate_report(result.id, report_config_id) + report_dto = ReportService.generate_report(result.id, report_config_id) - return Response(data=report) + serializer = ReportRetrieveResponseSerializer(instance=report_dto) + return Response(serializer.data)