diff --git a/bublik/core/measurement/dto.py b/bublik/core/measurement/dto.py new file mode 100644 index 000000000..6029b3f23 --- /dev/null +++ b/bublik/core/measurement/dto.py @@ -0,0 +1,31 @@ +# 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, Any + + +if TYPE_CHECKING: + from datetime import datetime + + +@dataclass +class MeasurementChartDTO: + id: int | str + title: str | None + subtitle: str + axis_x: dict[str, Any] + axis_y: dict[str, Any] + dataset: list[list[Any]] + + +@dataclass +class MeasurementDTO: + run_id: int + result_id: int + start: datetime + test_name: str + parameters_list: list[str] + measurement_series_charts: list[MeasurementChartDTO] diff --git a/bublik/core/measurement/services.py b/bublik/core/measurement/services.py index b2b449ffc..5477974ba 100644 --- a/bublik/core/measurement/services.py +++ b/bublik/core/measurement/services.py @@ -10,6 +10,10 @@ from rest_framework.exceptions import ValidationError from bublik.core.exceptions import NotFoundError +from bublik.core.measurement.dto import ( + MeasurementChartDTO, + MeasurementDTO, +) from bublik.core.measurement.representation import ChartViewBuilder from bublik.core.utils import key_value_dict_transforming, unordered_group_by from bublik.data.models import ( @@ -169,6 +173,17 @@ def grouper_by_measurement(cv): return charts +def _chart_to_dto(chart: dict) -> MeasurementChartDTO: + return MeasurementChartDTO( + id=chart['id'], + title=chart['title'], + subtitle=chart['subtitle'], + axis_x=chart['axis_x'], + axis_y=chart['axis_y'], + dataset=chart['dataset'], + ) + + class MeasurementService: @staticmethod def list_measurements(): @@ -196,12 +211,15 @@ def get_measurement(measurement_id: int) -> Measurement: """ try: return Measurement.objects.get(id=measurement_id) + except ValueError as e: + msg = f'Invalid measurement ID: {measurement_id}' + raise ValidationError(msg) from e except ObjectDoesNotExist as e: msg = f'Measurement {measurement_id} not found' raise NotFoundError(msg) from e @staticmethod - def get_trend_charts(result_ids: list[int]) -> list: + def get_trend_charts(result_ids: list[int]) -> list[MeasurementChartDTO]: """ Get measurement trend charts for multiple result IDs. @@ -211,7 +229,7 @@ def get_trend_charts(result_ids: list[int]) -> list: result_ids: List of test result IDs Returns: - List of chart representations + List of MeasurementChartDTO's instances Raises: ValidationError: if result_ids is empty @@ -224,16 +242,18 @@ def get_trend_charts(result_ids: list[int]) -> list: mmrs_groups = unordered_group_by(mmrs, 'measurement_group_key') return [ - ( - ChartViewBuilder(next(iter(mmr_group)).measurement).by_measurement_results( - mmr_group, - ) - ).representation() + _chart_to_dto( + ( + ChartViewBuilder(next(iter(mmr_group)).measurement).by_measurement_results( + mmr_group, + ) + ).representation(), + ) for _mm_key, mmr_group in mmrs_groups.items() ] @staticmethod - def get_measurements_by_result_ids(result_ids: list[int]) -> list[dict]: + def get_measurements_by_result_ids(result_ids: list[int]) -> list[MeasurementDTO]: """ Get measurements with parameters for each result ID. @@ -246,7 +266,7 @@ def get_measurements_by_result_ids(result_ids: list[int]) -> list[dict]: result_ids: List of test result IDs Returns: - List of dictionaries containing measurement data for each result + List of MeasurementDTO's instances Raises: ValidationError: if result_ids is empty @@ -277,17 +297,17 @@ def get_measurements_by_result_ids(result_ids: list[int]) -> list[dict]: # Strings nested within an f-string cannot use the same quote character # as the f-string prior to Python 3.12 chart['id'] = f'{test_result.id}_{chart["id"]}' - measurement_series_charts.append(chart) + measurement_series_charts.append(_chart_to_dto(chart)) measurement_series_charts_by_result.append( - { - 'run_id': test_result.test_run_id, - 'result_id': test_result.id, - 'start': test_result.start, - 'test_name': test_result.iteration.test.name, - 'parameters_list': parameters_list, - 'measurement_series_charts': measurement_series_charts, - }, + MeasurementDTO( + run_id=test_result.test_run_id, + result_id=test_result.id, + start=test_result.start, + test_name=test_result.iteration.test.name, + parameters_list=parameters_list, + measurement_series_charts=measurement_series_charts, + ), ) return measurement_series_charts_by_result diff --git a/bublik/interfaces/api_v2/__init__.py b/bublik/interfaces/api_v2/__init__.py index 6d2b48a04..57fa22166 100644 --- a/bublik/interfaces/api_v2/__init__.py +++ b/bublik/interfaces/api_v2/__init__.py @@ -25,7 +25,7 @@ from .job_task.views import JobTaskExecutionViewSet from .log import LogViewSet from .management import clear_all_runs_stats_cache, local_logs, meta_categorization -from .measurements import MeasurementViewSet +from .measurement.views import MeasurementViewSet from .outside_domains import OutsideDomainsViewSet from .performance import PerformanceCheckView from .project import ProjectViewSet diff --git a/bublik/interfaces/api_v2/measurement/schemas.py b/bublik/interfaces/api_v2/measurement/schemas.py new file mode 100644 index 000000000..6ab7ade1e --- /dev/null +++ b/bublik/interfaces/api_v2/measurement/schemas.py @@ -0,0 +1,96 @@ +# 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.measurement.serializers import ( + MeasurementByResultSerializer, + MeasurementChartSerializer, + MeasurementListResponseSerializer, + MeasurementRequestBodySerializer, +) + + +MEASUREMENT_TAG = 'Measurements' + + +measurement_viewset_schema = extend_schema_view( + list=extend_schema( + summary='List measurements', + description=""" + Return a list of available measurements, + each represented by its defining set of metadata. + """, + responses={ + 200: OpenApiResponse( + response=MeasurementListResponseSerializer(many=True), + description='Measurements were successfully retrieved', + ) + }, + tags=[MEASUREMENT_TAG], + ), + trend_charts=extend_schema( + summary='Get measurement trend charts', + description=""" + Build and return measurement trend charts for the specified + test result IDs. + """, + request=MeasurementRequestBodySerializer, + responses={ + 200: OpenApiResponse( + response=MeasurementChartSerializer(many=True), + description='Measurement trend charts were successfully retrieved', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result IDs were not provided', + ), + }, + tags=[MEASUREMENT_TAG], + ), + by_result_ids=extend_schema( + summary='Get measurements by result IDs', + description=""" + Return measurement data, test parameters, and chart series + for the specified test result IDs. + """, + request=MeasurementRequestBodySerializer, + responses={ + 200: OpenApiResponse( + response=MeasurementByResultSerializer(many=True), + description='Measurement data for result IDs were successfully retrieved', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Result IDs were not provided', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='One of the specified results was not found', + ), + }, + tags=[MEASUREMENT_TAG], + ), + retrieve=extend_schema( + summary='Get measurement', + description=""" + Return the set of metadata that describes a measurement, identified by its ID. + """, + responses={ + 200: OpenApiResponse( + response=MeasurementListResponseSerializer, + description='Measurement details were successfully retrieved', + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='Measurement ID was not provided or is invalid', + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description='Measurement was not found', + ), + }, + tags=[MEASUREMENT_TAG], + ), +) diff --git a/bublik/interfaces/api_v2/measurement/serializers.py b/bublik/interfaces/api_v2/measurement/serializers.py new file mode 100644 index 000000000..169bba86f --- /dev/null +++ b/bublik/interfaces/api_v2/measurement/serializers.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 OKTET Labs Ltd. All rights reserved. + +from rest_framework import serializers + + +class MeasurementChartSerializer(serializers.Serializer): + id = serializers.JSONField() + title = serializers.CharField(allow_null=True) + subtitle = serializers.CharField() + axis_x = serializers.DictField() + axis_y = serializers.DictField() + dataset = serializers.ListField(child=serializers.ListField(child=serializers.JSONField())) + + +class MeasurementByResultSerializer(serializers.Serializer): + run_id = serializers.IntegerField() + result_id = serializers.IntegerField() + start = serializers.DateTimeField() + test_name = serializers.CharField() + parameters_list = serializers.ListField(child=serializers.CharField()) + measurement_series_charts = MeasurementChartSerializer(many=True) + + +class MeasurementMetasSerializer(serializers.Serializer): + name = serializers.CharField() + type = serializers.CharField() + value = serializers.CharField() + comment = serializers.CharField(allow_null=True) + + +class MeasurementListResponseSerializer(serializers.Serializer): + metas = MeasurementMetasSerializer(many=True) + + +class MeasurementRequestBodySerializer(serializers.Serializer): + result_ids = serializers.ListField( + child=serializers.IntegerField(), + allow_empty=False, + ) diff --git a/bublik/interfaces/api_v2/measurements.py b/bublik/interfaces/api_v2/measurement/views.py similarity index 78% rename from bublik/interfaces/api_v2/measurements.py rename to bublik/interfaces/api_v2/measurement/views.py index 904def8da..d9527a8f9 100644 --- a/bublik/interfaces/api_v2/measurements.py +++ b/bublik/interfaces/api_v2/measurement/views.py @@ -12,6 +12,11 @@ from bublik.core.measurement.services import MeasurementService from bublik.data.models import Measurement from bublik.data.serializers import MeasurementSerializer +from bublik.interfaces.api_v2.measurement.schemas import measurement_viewset_schema +from bublik.interfaces.api_v2.measurement.serializers import ( + MeasurementByResultSerializer, + MeasurementChartSerializer, +) all = [ @@ -19,7 +24,9 @@ ] +@measurement_viewset_schema class MeasurementViewSet(GenericViewSet): + pagination_class = None queryset = Measurement.objects.all() serializer_class = MeasurementSerializer search_fields: typing.ClassVar[list[str]] = ['tool', 'type', 'name', 'keys', 'aggr'] @@ -37,7 +44,8 @@ def trend_charts(self, request): raise ValidationError(msg) charts = MeasurementService.get_trend_charts(result_ids) - return Response(charts) + serializer = MeasurementChartSerializer(charts, many=True) + return Response(serializer.data) @action(detail=False, methods=['post']) def by_result_ids(self, request): @@ -47,7 +55,8 @@ def by_result_ids(self, request): raise ValidationError(msg) measurements = MeasurementService.get_measurements_by_result_ids(result_ids) - return Response(measurements) + serializer = MeasurementByResultSerializer(measurements, many=True) + return Response(serializer.data) def retrieve(self, request, pk=None): measurement = MeasurementService.get_measurement(pk)