diff --git a/backend/kernelCI_app/constants/localization.py b/backend/kernelCI_app/constants/localization.py index d0ebbc828..51485777b 100644 --- a/backend/kernelCI_app/constants/localization.py +++ b/backend/kernelCI_app/constants/localization.py @@ -56,6 +56,9 @@ class ClientStrings: BUILD_DETAILS_NOT_FOUND = "Build not found" TREE_NOT_FOUND = "Tree not found." TREE_LATEST_DEFAULT_ORIGIN = "No origin was provided so it was defaulted to" + METRICS_INVALID_INTERVAL = ( + "start_days_ago must be greater than end_days_ago to define a valid interval" + ) @_simple_enum(StrEnum) @@ -67,6 +70,12 @@ class DocStrings: "Optional filter dictionary for additional query parameters" ) DEFAULT_INTERVAL_DESCRIPTION = "Interval in days for the listing" + METRICS_START_DAYS_AGO_DESCRIPTION = ( + "Number of days ago that marks the start of the metrics interval" + ) + METRICS_END_DAYS_AGO_DESCRIPTION = ( + "Number of days ago that marks the end of the metrics interval" + ) DEFAULT_LISTING_STARTING_DATE_DESCRIPTION = ( "Starting date to calculate the search interval." " Should be in ISO format such as 'YYYY-MM-DD HH:MM:SS'." diff --git a/backend/kernelCI_app/queries/notifications.py b/backend/kernelCI_app/queries/notifications.py index 860a855e8..b3e815e97 100644 --- a/backend/kernelCI_app/queries/notifications.py +++ b/backend/kernelCI_app/queries/notifications.py @@ -1,4 +1,6 @@ import sys +from concurrent.futures import ThreadPoolExecutor +from typing import Any from django.db import connection, connections from pydantic import ValidationError @@ -642,6 +644,24 @@ def get_issues_summary_data(*, checkout_ids: list[str]) -> list[dict]: return dict_fetchall(cursor=cursor) +def query_fetchone_work(*, query: str, params: dict[str, Any]): + try: + with connections["default"].cursor() as cursor: + cursor.execute(query, params) + return cursor.fetchone() + finally: + connections["default"].close() + + +def query_fetchall_work(*, query: str, params: dict[str, Any]): + try: + with connections["default"].cursor() as cursor: + cursor.execute(query, params) + return cursor.fetchall() + finally: + connections["default"].close() + + def get_metrics_data( *, start_days_ago: int, @@ -786,21 +806,28 @@ def get_metrics_data( GROUP BY lab """ - with connections["default"].cursor() as cursor: - cursor.execute(total_objects_query, params) - total_objects_result = cursor.fetchone() - - cursor.execute(total_objects_query, prev_params) - prev_total_objects_result = cursor.fetchone() - - cursor.execute(build_incidents_query, params) - build_incidents_result = cursor.fetchall() - - cursor.execute(lab_summary_query, params) - lab_summary_results = cursor.fetchall() + with ThreadPoolExecutor(max_workers=5) as executor: + total_objects_result = executor.submit( + query_fetchone_work, query=total_objects_query, params=params + ) + prev_total_objects_result = executor.submit( + query_fetchone_work, query=total_objects_query, params=prev_params + ) + build_incidents_result = executor.submit( + query_fetchall_work, query=build_incidents_query, params=params + ) + lab_summary_results = executor.submit( + query_fetchall_work, query=lab_summary_query, params=params + ) + prev_lab_summary_results = executor.submit( + query_fetchall_work, query=lab_summary_query, params=prev_params + ) - cursor.execute(lab_summary_query, prev_params) - prev_lab_summary_results = cursor.fetchall() + total_objects_result = total_objects_result.result() + prev_total_objects_result = prev_total_objects_result.result() + build_incidents_result = build_incidents_result.result() + lab_summary_results = lab_summary_results.result() + prev_lab_summary_results = prev_lab_summary_results.result() try: build_incidents_by_origin: dict[str, BuildIncidentsCount] = {} diff --git a/backend/kernelCI_app/tests/integrationTests/metrics_test.py b/backend/kernelCI_app/tests/integrationTests/metrics_test.py new file mode 100644 index 000000000..6352f8dec --- /dev/null +++ b/backend/kernelCI_app/tests/integrationTests/metrics_test.py @@ -0,0 +1,93 @@ +from http import HTTPStatus + +import pytest + +from kernelCI_app.constants.localization import ClientStrings +from kernelCI_app.tests.utils.asserts import ( + assert_has_fields_in_response_content, + assert_status_code_and_error_response, +) +from kernelCI_app.tests.utils.client.metricsClient import MetricsClient +from kernelCI_app.utils import string_to_json + +client = MetricsClient() + +# Snapshot from: curl -s http://localhost:8000/api/metrics/ +metrics_expected_fields = [ + "n_trees", + "n_checkouts", + "n_builds", + "n_tests", + "n_issues", + "n_incidents", + "build_incidents_by_origin", + "top_issues_by_origin", + "lab_maps", + "prev_n_trees", + "prev_n_checkouts", + "prev_n_builds", + "prev_n_tests", + "prev_lab_maps", +] + +metrics_expected_counts = { + "n_trees": 11, + "n_checkouts": 58, + "n_builds": 28, + "n_tests": 30, + "n_issues": 20, + "n_incidents": 8, + "prev_n_trees": 0, + "prev_n_checkouts": 0, + "prev_n_builds": 0, + "prev_n_tests": 0, +} + + +def test_get_metrics(): + response = client.get_metrics() + content = string_to_json(response.content.decode()) + + assert_status_code_and_error_response( + response=response, + content=content, + status_code=HTTPStatus.OK, + should_error=False, + ) + + assert_has_fields_in_response_content( + fields=metrics_expected_fields, response_content=content + ) + + for field, expected in metrics_expected_counts.items(): + assert content[field] == expected, ( + f"{field}: api={content[field]} expected={expected}" + ) + + +@pytest.mark.parametrize( + "query, status_code, has_error_body", + [ + ( + {"start_days_ago": "7", "end_days_ago": "7"}, + HTTPStatus.BAD_REQUEST, + True, + ), + ( + {"start_days_ago": "3", "end_days_ago": "7"}, + HTTPStatus.BAD_REQUEST, + True, + ), + ], +) +def test_get_metrics_invalid_interval(query, status_code, has_error_body): + response = client.get_metrics(query=query) + content = string_to_json(response.content.decode()) + + assert_status_code_and_error_response( + response=response, + content=content, + status_code=status_code, + should_error=has_error_body, + ) + assert content.get("error") == ClientStrings.METRICS_INVALID_INTERVAL diff --git a/backend/kernelCI_app/tests/unitTests/queries/metrics_queries_test.py b/backend/kernelCI_app/tests/unitTests/queries/metrics_queries_test.py new file mode 100644 index 000000000..fcb34fb35 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/queries/metrics_queries_test.py @@ -0,0 +1,57 @@ +from unittest.mock import patch + +from kernelCI_app.queries.notifications import get_metrics_data + +MODULE = "kernelCI_app.queries.notifications" + +EMPTY_TOTALS = (0, 0, 0, 0, 0, 0) + + +class TestGetMetricsDataIntervals: + @patch(f"{MODULE}.query_fetchall_work") + @patch(f"{MODULE}.query_fetchone_work") + def test_builds_current_and_previous_intervals_default_window( + self, mock_fetchone, mock_fetchall + ): + mock_fetchone.return_value = EMPTY_TOTALS + mock_fetchall.return_value = [] + + get_metrics_data(start_days_ago=7, end_days_ago=0) + + fetchone_params = [ + mock_call.kwargs["params"] for mock_call in mock_fetchone.call_args_list + ] + fetchall_params = [ + mock_call.kwargs["params"] for mock_call in mock_fetchall.call_args_list + ] + + curr = {"start_days_ago": "7 days", "end_days_ago": "0 days"} + prev = {"start_days_ago": "14 days", "end_days_ago": "7 days"} + + assert curr in fetchone_params + assert prev in fetchone_params + assert curr in fetchall_params + assert prev in fetchall_params + + @patch(f"{MODULE}.query_fetchall_work") + @patch(f"{MODULE}.query_fetchone_work") + def test_builds_custom_offset_window(self, mock_fetchone, mock_fetchall): + mock_fetchone.return_value = EMPTY_TOTALS + mock_fetchall.return_value = [] + + get_metrics_data(start_days_ago=14, end_days_ago=7) + + fetchone_params = [ + mock_call.kwargs["params"] for mock_call in mock_fetchone.call_args_list + ] + fetchall_params = [ + mock_call.kwargs["params"] for mock_call in mock_fetchall.call_args_list + ] + + curr = {"start_days_ago": "14 days", "end_days_ago": "7 days"} + prev = {"start_days_ago": "21 days", "end_days_ago": "14 days"} + + assert curr in fetchone_params + assert prev in fetchone_params + assert curr in fetchall_params + assert prev in fetchall_params diff --git a/backend/kernelCI_app/tests/unitTests/views/metricsView_test.py b/backend/kernelCI_app/tests/unitTests/views/metricsView_test.py new file mode 100644 index 000000000..9d2ff5859 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/metricsView_test.py @@ -0,0 +1,83 @@ +from unittest.mock import patch + +from django.test.testcases import SimpleTestCase +from pydantic import ValidationError +from rest_framework.test import APIRequestFactory + +from kernelCI_app.constants.localization import ClientStrings +from kernelCI_app.tests.unitTests.commands.metrics_notifications_test import ( + make_metrics_data, +) +from kernelCI_app.typeModels.metrics import metrics_report_data_to_response +from kernelCI_app.views.metricsView import MetricsView + + +class TestMetricsView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = MetricsView() + self.url = "/metrics" + + @patch("kernelCI_app.views.metricsView.get_metrics_data") + def test_get_metrics_success(self, mock_get_metrics_data): + mock_get_metrics_data.return_value = make_metrics_data() + request = self.factory.get(self.url) + response = self.view.get(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + metrics_report_data_to_response(make_metrics_data()).model_dump(), + ) + mock_get_metrics_data.assert_called_once_with(start_days_ago=7, end_days_ago=0) + + @patch("kernelCI_app.views.metricsView.get_metrics_data") + def test_get_metrics_with_custom_interval(self, mock_get_metrics_data): + mock_get_metrics_data.return_value = make_metrics_data() + request = self.factory.get( + self.url, {"start_days_ago": "14", "end_days_ago": "7"} + ) + response = self.view.get(request) + + self.assertEqual(response.status_code, 200) + mock_get_metrics_data.assert_called_once_with(start_days_ago=14, end_days_ago=7) + + def test_get_metrics_invalid_interval(self): + request = self.factory.get( + self.url, {"start_days_ago": "7", "end_days_ago": "7"} + ) + response = self.view.get(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, {"error": ClientStrings.METRICS_INVALID_INTERVAL} + ) + + @patch("kernelCI_app.views.metricsView.MetricsQueryParameters.model_validate") + def test_get_metrics_query_validation_error(self, mock_validate): + mock_error = ValidationError.from_exception_data( + "test_error", + [ + { + "type": "int_parsing", + "loc": ("start_days_ago",), + "input": "invalid", + } + ], + ) + mock_validate.side_effect = mock_error + + request = self.factory.get(self.url) + response = self.view.get(request) + + self.assertEqual(response.status_code, 500) + self.assertIsNotNone(response.data) + + @patch("kernelCI_app.views.metricsView.get_metrics_data") + def test_get_metrics_internal_error(self, mock_get_metrics_data): + mock_get_metrics_data.side_effect = Exception("Database unavailable") + request = self.factory.get(self.url) + response = self.view.get(request) + + self.assertEqual(response.status_code, 500) + self.assertEqual(response.data, {"error": "Database unavailable"}) diff --git a/backend/kernelCI_app/tests/utils/client/metricsClient.py b/backend/kernelCI_app/tests/utils/client/metricsClient.py new file mode 100644 index 000000000..12147d901 --- /dev/null +++ b/backend/kernelCI_app/tests/utils/client/metricsClient.py @@ -0,0 +1,15 @@ +from typing import Any, Optional + +from django.urls import reverse + +import requests +from kernelCI_app.tests.utils.client.baseClient import BaseClient + + +class MetricsClient(BaseClient): + def get_metrics( + self, *, query: Optional[dict[str, Any]] = None + ) -> requests.Response: + path = reverse("metricsView") + url = self.get_endpoint(path=path, query=query) + return requests.get(url) diff --git a/backend/kernelCI_app/typeModels/metrics.py b/backend/kernelCI_app/typeModels/metrics.py new file mode 100644 index 000000000..bf74d7e23 --- /dev/null +++ b/backend/kernelCI_app/typeModels/metrics.py @@ -0,0 +1,64 @@ +from pydantic import BaseModel, Field + +from kernelCI_app.constants.localization import DocStrings +from kernelCI_app.typeModels.metrics_notifications import ( + BuildIncidentsCount, + LabMetricsData, + MetricsReportData, + TopIssue, +) + +DEFAULT_METRICS_START_DAYS_AGO = 7 +DEFAULT_METRICS_END_DAYS_AGO = 0 + + +class MetricsQueryParameters(BaseModel): + start_days_ago: int = Field( + default=DEFAULT_METRICS_START_DAYS_AGO, + ge=0, + description=DocStrings.METRICS_START_DAYS_AGO_DESCRIPTION, + ) + end_days_ago: int = Field( + default=DEFAULT_METRICS_END_DAYS_AGO, + ge=0, + description=DocStrings.METRICS_END_DAYS_AGO_DESCRIPTION, + ) + + +class MetricsResponse(BaseModel): + n_trees: int + n_checkouts: int + n_builds: int + n_tests: int + n_issues: int + n_incidents: int + build_incidents_by_origin: dict[str, BuildIncidentsCount] + top_issues_by_origin: dict[str, list[TopIssue]] + lab_maps: dict[str, LabMetricsData] + prev_n_trees: int + prev_n_checkouts: int + prev_n_builds: int + prev_n_tests: int + prev_lab_maps: dict[str, LabMetricsData] + + +def metrics_report_data_to_response(data: MetricsReportData) -> MetricsResponse: + return MetricsResponse( + n_trees=data.n_trees, + n_checkouts=data.n_checkouts, + n_builds=data.n_builds, + n_tests=data.n_tests, + n_issues=data.n_issues, + n_incidents=data.n_incidents, + build_incidents_by_origin=data.build_incidents_by_origin, + top_issues_by_origin={ + origin: list(issues.values()) + for origin, issues in data.top_issues_by_origin.items() + }, + lab_maps=data.lab_maps, + prev_n_trees=data.prev_n_trees, + prev_n_checkouts=data.prev_n_checkouts, + prev_n_builds=data.prev_n_builds, + prev_n_tests=data.prev_n_tests, + prev_lab_maps=data.prev_lab_maps, + ) diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index 6b2ae6d49..69b887542 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -175,4 +175,5 @@ def view_cache(view): path("proxy/", views.ProxyView.as_view(), name="proxyView"), path("origins/", views.OriginsView.as_view(), name="originsView"), path("tree-report/", views.TreeReport.as_view(), name="treeReportView"), + path("metrics/", views.MetricsView.as_view(), name="metricsView"), ] diff --git a/backend/kernelCI_app/views/metricsView.py b/backend/kernelCI_app/views/metricsView.py new file mode 100644 index 000000000..78cb5227e --- /dev/null +++ b/backend/kernelCI_app/views/metricsView.py @@ -0,0 +1,51 @@ +from http import HTTPStatus + +from drf_spectacular.utils import extend_schema +from pydantic import ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +from kernelCI_app.constants.localization import ClientStrings +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.queries.notifications import get_metrics_data +from kernelCI_app.typeModels.metrics import ( + MetricsQueryParameters, + MetricsResponse, + metrics_report_data_to_response, +) + + +class MetricsView(APIView): + @extend_schema( + parameters=[MetricsQueryParameters], + responses={ + HTTPStatus.OK: MetricsResponse, + HTTPStatus.BAD_REQUEST: dict[str, str], + }, + methods=["GET"], + ) + def get(self, request) -> Response: + try: + query_parameters = MetricsQueryParameters.model_validate(request.GET.dict()) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) + + if query_parameters.start_days_ago <= query_parameters.end_days_ago: + return create_api_error_response( + error_message=ClientStrings.METRICS_INVALID_INTERVAL + ) + + try: + data = get_metrics_data( + start_days_ago=query_parameters.start_days_ago, + end_days_ago=query_parameters.end_days_ago, + ) + valid_response = metrics_report_data_to_response(data) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) + except Exception as e: + return create_api_error_response( + error_message=str(e), + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return Response(valid_response.model_dump())