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
45 changes: 45 additions & 0 deletions bublik/core/report/dto.py
Original file line number Diff line number Diff line change
@@ -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]

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.

nit: ReportConfigDTO doubles as both the response contract (only name/description/version are serialized) and the internal carrier of content used to build the report. Not a bug, but could be confusing later. If you want to keep the DTO pattern consistent with the rest of the PR, consider a small internal-only wrapper (e.g. ReportConfigContentDTO{config: ReportConfigDTO, content: dict}) instead of embedding content in the response DTO — up to you, not blocking.

@ol-zayatsm ol-zayatsm Jul 15, 2026

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.

Added.



@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]
93 changes: 58 additions & 35 deletions bublik/core/report/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -133,47 +140,56 @@ 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.

Args:
config_id: The ID of the report config

Returns:
Tuple of (config_obj, config_data, config_content)
ReportConfigContentDTO

Raises:
NotFoundError: if config not found

@ol-nata ol-nata Aug 24, 2026

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.

Raises: is missing ValidationError, which the method now also raises for an invalid config ID or content.

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.

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.

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

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

Expand All @@ -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
Expand All @@ -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 = (
Expand Down Expand Up @@ -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)

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

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.

Missing 404 — self.get_object() can raise it if run_id doesn't exist.

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.

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'],
),
)
Loading
Loading