Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions bublik/core/measurement/dto.py
Original file line number Diff line number Diff line change
@@ -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]
56 changes: 38 additions & 18 deletions bublik/core/measurement/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion bublik/interfaces/api_v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions bublik/interfaces/api_v2/measurement/schemas.py
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No request= specified — drf-spectacular falls back to the viewset's default serializer_class (MeasurementSerializer, i.e. metas) as the request body schema, which doesn't match what the view actually reads (result_ids). Please add an explicit request=(e.g. inline_serializer with result_ids).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as trend_charts — no request=, so the documented request body defaults to MeasurementSerializer instead of the actual result_ids payload. Please add an explicit request=.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

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],
),
)
40 changes: 40 additions & 0 deletions bublik/interfaces/api_v2/measurement/serializers.py
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@
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 = [
'MeasurementViewSet',
]


@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']
Expand All @@ -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):
Expand All @@ -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)
Expand Down
Loading