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
9 changes: 9 additions & 0 deletions backend/kernelCI_app/constants/localization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'."
Expand Down
55 changes: 41 additions & 14 deletions backend/kernelCI_app/queries/notifications.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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] = {}
Expand Down
93 changes: 93 additions & 0 deletions backend/kernelCI_app/tests/integrationTests/metrics_test.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions backend/kernelCI_app/tests/unitTests/views/metricsView_test.py
Original file line number Diff line number Diff line change
@@ -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"})
15 changes: 15 additions & 0 deletions backend/kernelCI_app/tests/utils/client/metricsClient.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading