Skip to content

Commit 48b98a2

Browse files
feat: labs listing
Signed-off-by: Felipe Bergamin <felipebergamin@profusion.mobi>
1 parent 2069443 commit 48b98a2

24 files changed

Lines changed: 907 additions & 7 deletions

File tree

backend/kernelCI_app/constants/localization.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class ClientStrings:
4242
ISSUE_TESTS_NOT_FOUND = "No tests found for this issue"
4343
ISSUE_BUILDS_NOT_FOUND = "No builds found for this issue"
4444
NO_HARDWARE_FOUND = "No hardware found"
45+
NO_LABS_FOUND = "No labs found"
4546
HARDWARE_NOT_FOUND = "Hardware not found"
4647
HARDWARE_NO_COMMITS = "This hardware isn't associated with any commit"
4748
HARDWARE_TEST_NOT_FOUND = "No tests found for this hardware"
@@ -107,6 +108,8 @@ class DocStrings:
107108
"and/or tag strings that appear in checkout.git_commit_tags."
108109
)
109110

111+
LAB_LISTING_ORIGIN_DESCRIPTION = "Origin of the lab results"
112+
110113
ISSUE_DETAILS_VERSION_DESCRIPTION = "Issue version"
111114

112115
ISSUE_EXTRA_ID_LIST_DESCRIPTION = "List of issue ids"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from datetime import datetime
2+
3+
from django.db import connection
4+
5+
6+
def get_lab_listing_data(
7+
*,
8+
origin: str,
9+
start_date: datetime,
10+
end_date: datetime,
11+
) -> list[tuple]:
12+
params = {
13+
"origin": origin,
14+
"start_date": start_date,
15+
"end_date": end_date,
16+
}
17+
18+
query = """
19+
WITH build_counts AS (
20+
SELECT
21+
COALESCE(bl.name, b.misc->>'lab') AS lab_name,
22+
COUNT(DISTINCT b.id) FILTER (WHERE b.status = 'PASS') AS build_pass,
23+
COUNT(DISTINCT b.id) FILTER (WHERE b.status = 'FAIL') AS build_fail,
24+
COUNT(DISTINCT b.id) FILTER (
25+
WHERE b.status IS NULL OR b.status NOT IN ('PASS', 'FAIL')
26+
) AS build_inc
27+
FROM builds b
28+
LEFT JOIN labs bl ON b.lab_id = bl.id
29+
WHERE
30+
b.origin = %(origin)s
31+
AND b.start_time >= %(start_date)s
32+
AND b.start_time <= %(end_date)s
33+
AND b.id NOT LIKE 'maestro:dummy_%%'
34+
AND COALESCE(bl.name, b.misc->>'lab') IS NOT NULL
35+
GROUP BY 1
36+
),
37+
test_counts AS (
38+
SELECT
39+
COALESCE(tl.name, t.misc->>'runtime') AS lab_name,
40+
41+
COUNT(1) FILTER (
42+
WHERE (t.path = 'boot' OR t.path LIKE 'boot.%%') AND t.status = 'PASS'
43+
) AS boot_pass,
44+
COUNT(1) FILTER (
45+
WHERE (t.path = 'boot' OR t.path LIKE 'boot.%%') AND t.status = 'FAIL'
46+
) AS boot_fail,
47+
COUNT(1) FILTER (
48+
WHERE (
49+
t.path = 'boot'
50+
OR t.path LIKE 'boot.%%'
51+
)
52+
AND (
53+
t.status IS NULL OR t.status NOT IN ('PASS', 'FAIL')
54+
)
55+
) AS boot_inc,
56+
57+
COUNT(1) FILTER (
58+
WHERE (t.path <> 'boot' AND t.path NOT LIKE 'boot.%%') AND t.status = 'PASS'
59+
) AS test_pass,
60+
COUNT(1) FILTER (
61+
WHERE (t.path <> 'boot' AND t.path NOT LIKE 'boot.%%') AND t.status = 'FAIL'
62+
) AS test_fail,
63+
COUNT(1) FILTER (
64+
WHERE (t.path <> 'boot' AND t.path NOT LIKE 'boot.%%')
65+
AND (t.status IS NULL OR t.status NOT IN ('PASS', 'FAIL'))
66+
) AS test_inc
67+
FROM tests t
68+
LEFT JOIN labs tl ON t.lab_id = tl.id
69+
WHERE
70+
t.origin = %(origin)s
71+
AND t.start_time >= %(start_date)s
72+
AND t.start_time <= %(end_date)s
73+
AND COALESCE(tl.name, t.misc->>'runtime') IS NOT NULL
74+
GROUP BY 1
75+
)
76+
SELECT
77+
COALESCE(b.lab_name, t.lab_name) AS lab_name,
78+
COALESCE(b.build_pass, 0) AS build_pass,
79+
COALESCE(b.build_fail, 0) AS build_fail,
80+
COALESCE(b.build_inc, 0) AS build_inc,
81+
COALESCE(t.boot_pass, 0) AS boot_pass,
82+
COALESCE(t.boot_fail, 0) AS boot_fail,
83+
COALESCE(t.boot_inc, 0) AS boot_inc,
84+
COALESCE(t.test_pass, 0) AS test_pass,
85+
COALESCE(t.test_fail, 0) AS test_fail,
86+
COALESCE(t.test_inc, 0) AS test_inc
87+
FROM build_counts b
88+
FULL OUTER JOIN test_counts t ON b.lab_name = t.lab_name
89+
ORDER BY lab_name
90+
"""
91+
92+
with connection.cursor() as cursor:
93+
cursor.execute(query, params)
94+
return cursor.fetchall()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from datetime import datetime
2+
from unittest.mock import patch
3+
4+
from kernelCI_app.queries.labs import get_lab_listing_data
5+
6+
7+
class TestGetLabListingData:
8+
@patch("kernelCI_app.queries.labs.connection")
9+
def test_get_lab_listing_data_executes_query(self, mock_connection):
10+
mock_cursor = mock_connection.cursor.return_value.__enter__.return_value
11+
mock_cursor.fetchall.return_value = [
12+
("lab-collabora", 10, 2, 1, 8, 1, 0, 50, 5, 3),
13+
]
14+
15+
result = get_lab_listing_data(
16+
origin="maestro",
17+
start_date=datetime(2025, 1, 1),
18+
end_date=datetime(2025, 1, 8),
19+
)
20+
21+
assert result == [("lab-collabora", 10, 2, 1, 8, 1, 0, 50, 5, 3)]
22+
mock_cursor.execute.assert_called_once()
23+
params = mock_cursor.execute.call_args[0][1]
24+
assert params["origin"] == "maestro"
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from http import HTTPStatus
2+
from unittest.mock import ANY, patch
3+
4+
from django.test.testcases import SimpleTestCase
5+
from rest_framework.test import APIRequestFactory
6+
7+
from kernelCI_app.constants.localization import ClientStrings
8+
from kernelCI_app.views.labView import LabView
9+
10+
11+
class TestLabView(SimpleTestCase):
12+
def setUp(self):
13+
self.factory = APIRequestFactory()
14+
self.view = LabView()
15+
self.url = "/labs"
16+
17+
@patch("kernelCI_app.views.labView.get_lab_listing_data")
18+
def test_get_lab_listing_success(self, mock_get_lab_listing_data):
19+
mock_get_lab_listing_data.return_value = [
20+
("lab-collabora", *range(9)),
21+
]
22+
23+
query_params = {
24+
"startTimestampInSeconds": "1741192200",
25+
"endTimestampInSeconds": "1741624200",
26+
"origin": "maestro",
27+
}
28+
29+
request = self.factory.get(self.url, query_params)
30+
response = self.view.get(request)
31+
32+
self.assertEqual(response.status_code, HTTPStatus.OK)
33+
mock_get_lab_listing_data.assert_called_once_with(
34+
origin="maestro",
35+
start_date=ANY,
36+
end_date=ANY,
37+
)
38+
self.assertEqual(response.data["labs"][0]["lab_name"], "lab-collabora")
39+
self.assertEqual(response.data["labs"][0]["build_status_summary"]["PASS"], 0)
40+
41+
def test_get_lab_listing_invalid_query_params_returns_bad_request(self):
42+
request = self.factory.get(self.url, {"origin": "maestro"})
43+
response = self.view.get(request)
44+
45+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
46+
self.assertIn("start_date", response.data)
47+
self.assertIn("end_date", response.data)
48+
49+
@patch("kernelCI_app.views.labView.get_lab_listing_data")
50+
def test_get_lab_listing_no_labs_found_returns_ok_with_error(
51+
self, mock_get_lab_listing_data
52+
):
53+
mock_get_lab_listing_data.return_value = []
54+
55+
query_params = {
56+
"startTimestampInSeconds": "1741192200",
57+
"endTimestampInSeconds": "1741624200",
58+
"origin": "maestro",
59+
}
60+
61+
request = self.factory.get(self.url, query_params)
62+
response = self.view.get(request)
63+
64+
self.assertEqual(response.status_code, HTTPStatus.OK)
65+
self.assertEqual(response.data, {"error": ClientStrings.NO_LABS_FOUND})
66+
67+
@patch("kernelCI_app.views.labView.get_lab_listing_data")
68+
def test_get_lab_listing_sanitize_validation_error_returns_internal_server_error(
69+
self, mock_get_lab_listing_data
70+
):
71+
mock_get_lab_listing_data.return_value = [
72+
(None, *range(9)),
73+
]
74+
75+
query_params = {
76+
"startTimestampInSeconds": "1741192200",
77+
"endTimestampInSeconds": "1741624200",
78+
"origin": "maestro",
79+
}
80+
81+
request = self.factory.get(self.url, query_params)
82+
response = self.view.get(request)
83+
84+
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
85+
self.assertIn("lab_name", response.data)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from datetime import datetime
2+
from typing import Annotated
3+
4+
from pydantic import BaseModel, BeforeValidator, Field
5+
6+
from kernelCI_app.constants.general import DEFAULT_ORIGIN
7+
from kernelCI_app.constants.localization import DocStrings
8+
from kernelCI_app.typeModels.commonListing import ListingStatusCount
9+
10+
11+
class LabListingItem(BaseModel):
12+
lab_name: str
13+
build_status_summary: ListingStatusCount
14+
boot_status_summary: ListingStatusCount
15+
test_status_summary: ListingStatusCount
16+
17+
18+
class LabListingResponse(BaseModel):
19+
labs: list[LabListingItem]
20+
21+
22+
class LabListingQueryParamsDocumentationOnly(BaseModel):
23+
origin: Annotated[
24+
str,
25+
Field(
26+
default=DEFAULT_ORIGIN,
27+
description=DocStrings.LAB_LISTING_ORIGIN_DESCRIPTION,
28+
),
29+
]
30+
startTimestampInSeconds: str = Field( # noqa: N815
31+
description=DocStrings.DEFAULT_START_TS_DESCRIPTION
32+
)
33+
endTimestampInSeconds: str = Field( # noqa: N815
34+
description=DocStrings.DEFAULT_END_TS_DESCRIPTION
35+
)
36+
37+
38+
class LabListingQueryParams(BaseModel):
39+
origin: Annotated[
40+
str,
41+
Field(default=DEFAULT_ORIGIN),
42+
BeforeValidator(lambda o: DEFAULT_ORIGIN if o is None else o),
43+
]
44+
start_date: datetime
45+
end_date: datetime

backend/kernelCI_app/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def view_cache(view, timeout: int = settings.CACHE_TIMEOUT):
146146
name="hardwareDetailsTests",
147147
),
148148
path("hardware/", view_cache(views.HardwareView), name="hardware"),
149+
path("labs/", view_cache(views.LabView), name="labs"),
149150
path(
150151
"hardware-by-revision/",
151152
view_cache(views.HardwareByRevisionView),
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from datetime import datetime
2+
from http import HTTPStatus
3+
4+
from drf_spectacular.utils import extend_schema
5+
from pydantic import ValidationError
6+
from rest_framework.request import Request
7+
from rest_framework.response import Response
8+
from rest_framework.views import APIView
9+
10+
from kernelCI_app.constants.localization import ClientStrings
11+
from kernelCI_app.helpers.errorHandling import create_api_error_response
12+
from kernelCI_app.queries.labs import get_lab_listing_data
13+
from kernelCI_app.typeModels.commonListing import ListingStatusCount
14+
from kernelCI_app.typeModels.labListing import (
15+
LabListingItem,
16+
LabListingQueryParams,
17+
LabListingQueryParamsDocumentationOnly,
18+
LabListingResponse,
19+
)
20+
21+
22+
class LabView(APIView):
23+
def _sanitize_records(self, labs_raw: list[tuple]) -> list[LabListingItem]:
24+
labs = []
25+
for lab in labs_raw:
26+
labs.append(
27+
LabListingItem(
28+
lab_name=lab[0],
29+
build_status_summary=ListingStatusCount(
30+
PASS=lab[1],
31+
FAIL=lab[2],
32+
INCONCLUSIVE=lab[3],
33+
),
34+
boot_status_summary=ListingStatusCount(
35+
PASS=lab[4],
36+
FAIL=lab[5],
37+
INCONCLUSIVE=lab[6],
38+
),
39+
test_status_summary=ListingStatusCount(
40+
PASS=lab[7],
41+
FAIL=lab[8],
42+
INCONCLUSIVE=lab[9],
43+
),
44+
)
45+
)
46+
47+
return labs
48+
49+
@extend_schema(
50+
parameters=[LabListingQueryParamsDocumentationOnly],
51+
responses=LabListingResponse,
52+
)
53+
def get(self, request: Request):
54+
try:
55+
query_params = LabListingQueryParams(
56+
start_date=request.GET.get("startTimestampInSeconds"),
57+
end_date=request.GET.get("endTimestampInSeconds"),
58+
origin=request.GET.get("origin"),
59+
)
60+
61+
start_date: datetime = query_params.start_date
62+
end_date: datetime = query_params.end_date
63+
origin = query_params.origin
64+
except ValidationError as e:
65+
return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST)
66+
67+
labs_raw = get_lab_listing_data(
68+
origin=origin,
69+
start_date=start_date,
70+
end_date=end_date,
71+
)
72+
73+
try:
74+
sanitized_records = self._sanitize_records(labs_raw=labs_raw)
75+
result = LabListingResponse(labs=sanitized_records)
76+
77+
if len(result.labs) < 1:
78+
return create_api_error_response(
79+
error_message=ClientStrings.NO_LABS_FOUND,
80+
status_code=HTTPStatus.OK,
81+
)
82+
except ValidationError as e:
83+
return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR)
84+
85+
return Response(data=result.model_dump(), status=HTTPStatus.OK)

0 commit comments

Comments
 (0)