-
Notifications
You must be signed in to change notification settings - Fork 7
report: align OpenAPI schemas with actual API responses and introduce DTO service contracts #342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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] | ||
|
|
||
|
|
||
| @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] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
|
@@ -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, | ||
| ) | ||
| 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', | ||
| ), | ||
| }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing 404 —
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'], | ||
| ), | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
ReportConfigDTOdoubles as both the response contract (onlyname/description/versionare serialized) and the internal carrier ofcontentused 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 embeddingcontentin the response DTO — up to you, not blocking.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added.