Skip to content
Draft
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
3 changes: 3 additions & 0 deletions backend/kernelCI_app/constants/localization.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ClientStrings:
ISSUE_TESTS_NOT_FOUND = "No tests found for this issue"
ISSUE_BUILDS_NOT_FOUND = "No builds found for this issue"
NO_HARDWARE_FOUND = "No hardware found"
NO_LABS_FOUND = "No labs found"
HARDWARE_NOT_FOUND = "Hardware not found"
HARDWARE_NO_COMMITS = "This hardware isn't associated with any commit"
HARDWARE_TEST_NOT_FOUND = "No tests found for this hardware"
Expand Down Expand Up @@ -107,6 +108,8 @@ class DocStrings:
"and/or tag strings that appear in checkout.git_commit_tags."
)

LAB_LISTING_ORIGIN_DESCRIPTION = "Origin of the lab results"

ISSUE_DETAILS_VERSION_DESCRIPTION = "Issue version"

ISSUE_EXTRA_ID_LIST_DESCRIPTION = "List of issue ids"
Expand Down
91 changes: 91 additions & 0 deletions backend/kernelCI_app/queries/labs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from django.db import connection


def get_lab_listing_data(
*,
origin: str,
interval_in_days: int,
) -> list[tuple]:
params = {
"origin": origin,
"interval": f"{interval_in_days} days",
}

query = """
WITH status_counts AS (
SELECT
COALESCE(bl.name, b.misc->>'lab') AS lab_name,
'build' AS item_type,
CASE
WHEN b.status IN ('PASS', 'FAIL') THEN b.status
ELSE 'INCONCLUSIVE'
END AS item_status,
COUNT(*) AS item_count
FROM builds b
LEFT JOIN labs bl ON b.lab_id = bl.id
WHERE
b.origin = %(origin)s
AND b.start_time >= NOW() - INTERVAL %(interval)s
AND b.id NOT LIKE 'maestro:dummy_%%'
AND COALESCE(bl.name, b.misc->>'lab') IS NOT NULL
GROUP BY 1, 2, 3

UNION ALL

SELECT
COALESCE(tl.name, t.misc->>'runtime') AS lab_name,
CASE
WHEN t.path = 'boot' OR t.path LIKE 'boot.%%' THEN 'boot'
ELSE 'test'
END AS item_type,
CASE
WHEN t.status IN ('PASS', 'FAIL') THEN t.status
ELSE 'INCONCLUSIVE'
END AS item_status,
COUNT(*) AS item_count
FROM tests t
LEFT JOIN labs tl ON t.lab_id = tl.id
WHERE
t.origin = %(origin)s
AND t.start_time >= NOW() - INTERVAL %(interval)s
AND t.path IS NOT NULL
AND COALESCE(tl.name, t.misc->>'runtime') IS NOT NULL
GROUP BY 1, 2, 3
)
SELECT
lab_name,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'build' AND item_status = 'PASS'
), 0) AS build_pass,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'build' AND item_status = 'FAIL'
), 0) AS build_fail,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'build' AND item_status = 'INCONCLUSIVE'
), 0) AS build_inc,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'boot' AND item_status = 'PASS'
), 0) AS boot_pass,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'boot' AND item_status = 'FAIL'
), 0) AS boot_fail,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'boot' AND item_status = 'INCONCLUSIVE'
), 0) AS boot_inc,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'test' AND item_status = 'PASS'
), 0) AS test_pass,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'test' AND item_status = 'FAIL'
), 0) AS test_fail,
COALESCE(SUM(item_count) FILTER (
WHERE item_type = 'test' AND item_status = 'INCONCLUSIVE'
), 0) AS test_inc
FROM status_counts
GROUP BY lab_name
ORDER BY lab_name
"""

with connection.cursor() as cursor:
cursor.execute(query, params)
return cursor.fetchall()
75 changes: 75 additions & 0 deletions backend/kernelCI_app/tests/integrationTests/labs_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from http import HTTPStatus

import pytest
from django.urls import reverse

import requests
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.baseClient import BaseClient
from kernelCI_app.utils import string_to_json

LAB_FIELDS = [
"lab_name",
"build_status_summary",
"boot_status_summary",
"test_status_summary",
]
STATUS_FIELDS = ["PASS", "FAIL", "INCONCLUSIVE"]


@pytest.mark.parametrize(
"query, status_code, has_error_body",
[
(
{"origin": "maestro", "interval_in_days": "7"},
HTTPStatus.OK,
False,
),
(
{"origin": "invalid", "interval_in_days": "7"},
HTTPStatus.OK,
True,
),
(
{"origin": "maestro", "interval_in_days": "-1"},
HTTPStatus.BAD_REQUEST,
True,
),
],
)
def test_lab_listing(
query: dict,
status_code: HTTPStatus,
has_error_body: bool,
) -> None:
url = BaseClient().get_endpoint(path=reverse("labs"), query=query)
response = requests.get(url)
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,
)

if has_error_body:
return

lab = content["labs"][0]
assert_has_fields_in_response_content(
fields=LAB_FIELDS,
response_content=lab,
)
for summary in (
lab["build_status_summary"],
lab["boot_status_summary"],
lab["test_status_summary"],
):
assert_has_fields_in_response_content(
fields=STATUS_FIELDS,
response_content=summary,
)
106 changes: 106 additions & 0 deletions backend/kernelCI_app/tests/unitTests/views/labView_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from http import HTTPStatus
from unittest.mock import patch

from django.test.testcases import SimpleTestCase
from rest_framework.test import APIRequestFactory

from kernelCI_app.constants.localization import ClientStrings
from kernelCI_app.views.labView import LabView


class TestLabView(SimpleTestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.view = LabView()
self.url = "/labs"

@patch("kernelCI_app.views.labView.get_lab_listing_data")
def test_get_lab_listing_success(self, mock_get_lab_listing_data):
mock_get_lab_listing_data.return_value = [
("lab-collabora", *range(9)),
]

query_params = {
"interval_in_days": "7",
"origin": "maestro",
}

request = self.factory.get(self.url, query_params)
response = self.view.get(request)

self.assertEqual(response.status_code, HTTPStatus.OK)
mock_get_lab_listing_data.assert_called_once_with(
origin="maestro",
interval_in_days=7,
)
self.assertEqual(
response.data,
{
"labs": [
{
"lab_name": "lab-collabora",
"build_status_summary": {
"PASS": 0,
"FAIL": 1,
"INCONCLUSIVE": 2,
},
"boot_status_summary": {
"PASS": 3,
"FAIL": 4,
"INCONCLUSIVE": 5,
},
"test_status_summary": {
"PASS": 6,
"FAIL": 7,
"INCONCLUSIVE": 8,
},
}
]
},
)

def test_get_lab_listing_invalid_query_params_returns_bad_request(self):
request = self.factory.get(
self.url,
{"origin": "maestro", "interval_in_days": "-1"},
)
response = self.view.get(request)

self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
self.assertIn("interval_in_days", response.data)

@patch("kernelCI_app.views.labView.get_lab_listing_data")
def test_get_lab_listing_no_labs_found_returns_ok_with_error(
self, mock_get_lab_listing_data
):
mock_get_lab_listing_data.return_value = []

query_params = {
"interval_in_days": "7",
"origin": "maestro",
}

request = self.factory.get(self.url, query_params)
response = self.view.get(request)

self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertEqual(response.data, {"error": ClientStrings.NO_LABS_FOUND})

@patch("kernelCI_app.views.labView.get_lab_listing_data")
def test_get_lab_listing_sanitize_validation_error_returns_internal_server_error(
self, mock_get_lab_listing_data
):
mock_get_lab_listing_data.return_value = [
(None, *range(9)),
]

query_params = {
"interval_in_days": "7",
"origin": "maestro",
}

request = self.factory.get(self.url, query_params)
response = self.view.get(request)

self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
self.assertIn("lab_name", response.data["error"])
14 changes: 14 additions & 0 deletions backend/kernelCI_app/typeModels/labListing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from pydantic import BaseModel

from kernelCI_app.typeModels.commonListing import ListingStatusCount


class LabListingItem(BaseModel):
lab_name: str
build_status_summary: ListingStatusCount
boot_status_summary: ListingStatusCount
test_status_summary: ListingStatusCount


class LabListingResponse(BaseModel):
labs: list[LabListingItem]
1 change: 1 addition & 0 deletions backend/kernelCI_app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def view_cache(view, timeout: int = settings.CACHE_TIMEOUT):
name="hardwareDetailsTests",
),
path("hardware/", view_cache(views.HardwareView), name="hardware"),
path("labs/", view_cache(views.LabView), name="labs"),
path(
"hardware-by-revision/",
view_cache(views.HardwareByRevisionView),
Expand Down
82 changes: 82 additions & 0 deletions backend/kernelCI_app/views/labView.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from http import HTTPStatus

from drf_spectacular.utils import extend_schema
from pydantic import ValidationError
from rest_framework.request import Request
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.labs import get_lab_listing_data
from kernelCI_app.typeModels.commonListing import (
ListingQueryParameters,
ListingStatusCount,
)
from kernelCI_app.typeModels.labListing import (
LabListingItem,
LabListingResponse,
)


class LabView(APIView):
def _sanitize_records(self, labs_raw: list[tuple]) -> list[LabListingItem]:
labs = []
for lab in labs_raw:
labs.append(
LabListingItem(
lab_name=lab[0],
build_status_summary=ListingStatusCount(
PASS=lab[1],
FAIL=lab[2],
INCONCLUSIVE=lab[3],
),
boot_status_summary=ListingStatusCount(
PASS=lab[4],
FAIL=lab[5],
INCONCLUSIVE=lab[6],
),
test_status_summary=ListingStatusCount(
PASS=lab[7],
FAIL=lab[8],
INCONCLUSIVE=lab[9],
),
)
)

return labs

@extend_schema(
parameters=[ListingQueryParameters],
responses=LabListingResponse,
)
def get(self, request: Request):
try:
query_params = ListingQueryParameters(
origin=request.GET.get("origin"),
interval_in_days=request.GET.get("interval_in_days"),
)
except ValidationError as e:
return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST)

labs_raw = get_lab_listing_data(
origin=query_params.origin,
interval_in_days=query_params.interval_in_days,
)

try:
sanitized_records = self._sanitize_records(labs_raw=labs_raw)
result = LabListingResponse(labs=sanitized_records)

if len(result.labs) < 1:
return create_api_error_response(
error_message=ClientStrings.NO_LABS_FOUND,
status_code=HTTPStatus.OK,
)
except ValidationError as e:
return create_api_error_response(
error_message=e.json(),
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)

return Response(data=result.model_dump(), status=HTTPStatus.OK)
Loading