-
Notifications
You must be signed in to change notification settings - Fork 30
feat: labs listing #1985
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?
feat: labs listing #1985
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,94 @@ | ||
| from datetime import datetime | ||
|
|
||
| from django.db import connection | ||
|
|
||
|
|
||
| def get_lab_listing_data( | ||
| *, | ||
| origin: str, | ||
| start_date: datetime, | ||
| end_date: datetime, | ||
| ) -> list[tuple]: | ||
| params = { | ||
| "origin": origin, | ||
| "start_date": start_date, | ||
| "end_date": end_date, | ||
| } | ||
|
|
||
| query = """ | ||
| WITH build_counts AS ( | ||
| SELECT | ||
| COALESCE(bl.name, b.misc->>'lab') AS lab_name, | ||
| COUNT(DISTINCT b.id) FILTER (WHERE b.status = 'PASS') AS build_pass, | ||
| COUNT(DISTINCT b.id) FILTER (WHERE b.status = 'FAIL') AS build_fail, | ||
| COUNT(DISTINCT b.id) FILTER ( | ||
| WHERE b.status IS NULL OR b.status NOT IN ('PASS', 'FAIL') | ||
| ) AS build_inc | ||
| FROM builds b | ||
| LEFT JOIN labs bl ON b.lab_id = bl.id | ||
| WHERE | ||
| b.origin = %(origin)s | ||
| AND b.start_time >= %(start_date)s | ||
| AND b.start_time <= %(end_date)s | ||
| AND b.id NOT LIKE 'maestro:dummy_%%' | ||
| AND COALESCE(bl.name, b.misc->>'lab') IS NOT NULL | ||
| GROUP BY 1 | ||
| ), | ||
| test_counts AS ( | ||
| SELECT | ||
| COALESCE(tl.name, t.misc->>'runtime') AS lab_name, | ||
|
|
||
| COUNT(1) FILTER ( | ||
| WHERE (t.path = 'boot' OR t.path LIKE 'boot.%%') AND t.status = 'PASS' | ||
| ) AS boot_pass, | ||
| COUNT(1) FILTER ( | ||
| WHERE (t.path = 'boot' OR t.path LIKE 'boot.%%') AND t.status = 'FAIL' | ||
| ) AS boot_fail, | ||
| COUNT(1) FILTER ( | ||
| WHERE ( | ||
| t.path = 'boot' | ||
| OR t.path LIKE 'boot.%%' | ||
| ) | ||
| AND ( | ||
| t.status IS NULL OR t.status NOT IN ('PASS', 'FAIL') | ||
| ) | ||
| ) AS boot_inc, | ||
|
|
||
| COUNT(1) FILTER ( | ||
| WHERE (t.path <> 'boot' AND t.path NOT LIKE 'boot.%%') AND t.status = 'PASS' | ||
| ) AS test_pass, | ||
| COUNT(1) FILTER ( | ||
| WHERE (t.path <> 'boot' AND t.path NOT LIKE 'boot.%%') AND t.status = 'FAIL' | ||
| ) AS test_fail, | ||
| COUNT(1) FILTER ( | ||
| WHERE (t.path <> 'boot' AND t.path NOT LIKE 'boot.%%') | ||
| AND (t.status IS NULL OR t.status NOT IN ('PASS', 'FAIL')) | ||
| ) AS test_inc | ||
| FROM tests t | ||
| LEFT JOIN labs tl ON t.lab_id = tl.id | ||
| WHERE | ||
| t.origin = %(origin)s | ||
| AND t.start_time >= %(start_date)s | ||
| AND t.start_time <= %(end_date)s | ||
| AND COALESCE(tl.name, t.misc->>'runtime') IS NOT NULL | ||
| GROUP BY 1 | ||
| ) | ||
| SELECT | ||
| COALESCE(b.lab_name, t.lab_name) AS lab_name, | ||
| COALESCE(b.build_pass, 0) AS build_pass, | ||
| COALESCE(b.build_fail, 0) AS build_fail, | ||
| COALESCE(b.build_inc, 0) AS build_inc, | ||
| COALESCE(t.boot_pass, 0) AS boot_pass, | ||
| COALESCE(t.boot_fail, 0) AS boot_fail, | ||
| COALESCE(t.boot_inc, 0) AS boot_inc, | ||
| COALESCE(t.test_pass, 0) AS test_pass, | ||
| COALESCE(t.test_fail, 0) AS test_fail, | ||
| COALESCE(t.test_inc, 0) AS test_inc | ||
| FROM build_counts b | ||
| FULL OUTER JOIN test_counts t ON b.lab_name = t.lab_name | ||
| ORDER BY lab_name | ||
| """ | ||
|
|
||
| with connection.cursor() as cursor: | ||
| cursor.execute(query, params) | ||
| return cursor.fetchall() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| from datetime import datetime | ||
| from unittest.mock import patch | ||
|
|
||
| from kernelCI_app.queries.labs import get_lab_listing_data | ||
|
|
||
|
|
||
| class TestGetLabListingData: | ||
|
Contributor
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. I dont know if this test aggregates much. Might be better to add Integration tests for database query tests. |
||
| @patch("kernelCI_app.queries.labs.connection") | ||
| def test_get_lab_listing_data_executes_query(self, mock_connection): | ||
| mock_cursor = mock_connection.cursor.return_value.__enter__.return_value | ||
| mock_cursor.fetchall.return_value = [ | ||
| ("lab-collabora", 10, 2, 1, 8, 1, 0, 50, 5, 3), | ||
| ] | ||
|
|
||
| result = get_lab_listing_data( | ||
| origin="maestro", | ||
| start_date=datetime(2025, 1, 1), | ||
| end_date=datetime(2025, 1, 8), | ||
| ) | ||
|
|
||
| assert result == [("lab-collabora", 10, 2, 1, 8, 1, 0, 50, 5, 3)] | ||
| mock_cursor.execute.assert_called_once() | ||
| params = mock_cursor.execute.call_args[0][1] | ||
| assert params["origin"] == "maestro" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| from http import HTTPStatus | ||
| from unittest.mock import ANY, 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 = { | ||
| "startTimestampInSeconds": "1741192200", | ||
| "endTimestampInSeconds": "1741624200", | ||
| "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", | ||
| start_date=ANY, | ||
| end_date=ANY, | ||
| ) | ||
| self.assertEqual(response.data["labs"][0]["lab_name"], "lab-collabora") | ||
| self.assertEqual(response.data["labs"][0]["build_status_summary"]["PASS"], 0) | ||
|
Contributor
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. might be worth it checking asserting response here |
||
|
|
||
| def test_get_lab_listing_invalid_query_params_returns_bad_request(self): | ||
| request = self.factory.get(self.url, {"origin": "maestro"}) | ||
| response = self.view.get(request) | ||
|
|
||
| self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) | ||
| self.assertIn("start_date", response.data) | ||
| self.assertIn("end_date", 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 = { | ||
| "startTimestampInSeconds": "1741192200", | ||
| "endTimestampInSeconds": "1741624200", | ||
| "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 = { | ||
| "startTimestampInSeconds": "1741192200", | ||
| "endTimestampInSeconds": "1741624200", | ||
| "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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| from datetime import datetime | ||
| from typing import Annotated | ||
|
|
||
| from pydantic import BaseModel, BeforeValidator, Field | ||
|
|
||
| from kernelCI_app.constants.general import DEFAULT_ORIGIN | ||
| from kernelCI_app.constants.localization import DocStrings | ||
| 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] | ||
|
|
||
|
|
||
| class LabListingQueryParamsDocumentationOnly(BaseModel): | ||
| origin: Annotated[ | ||
| str, | ||
| Field( | ||
| default=DEFAULT_ORIGIN, | ||
| description=DocStrings.LAB_LISTING_ORIGIN_DESCRIPTION, | ||
| ), | ||
| ] | ||
| startTimestampInSeconds: str = Field( # noqa: N815 | ||
| description=DocStrings.DEFAULT_START_TS_DESCRIPTION | ||
| ) | ||
| endTimestampInSeconds: str = Field( # noqa: N815 | ||
| description=DocStrings.DEFAULT_END_TS_DESCRIPTION | ||
| ) | ||
|
|
||
|
|
||
| class LabListingQueryParams(BaseModel): | ||
|
Contributor
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. Might be worth to checking if we can use ListingQueryParams, this way we would keep the same interface as similar listings. |
||
| origin: Annotated[ | ||
| str, | ||
| Field(default=DEFAULT_ORIGIN), | ||
| BeforeValidator(lambda o: DEFAULT_ORIGIN if o is None else o), | ||
| ] | ||
| start_date: datetime | ||
| end_date: datetime | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| from datetime import datetime | ||
| 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 ListingStatusCount | ||
| from kernelCI_app.typeModels.labListing import ( | ||
| LabListingItem, | ||
| LabListingQueryParams, | ||
| LabListingQueryParamsDocumentationOnly, | ||
| 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=[LabListingQueryParamsDocumentationOnly], | ||
| responses=LabListingResponse, | ||
| ) | ||
| def get(self, request: Request): | ||
| try: | ||
| query_params = LabListingQueryParams( | ||
| start_date=request.GET.get("startTimestampInSeconds"), | ||
| end_date=request.GET.get("endTimestampInSeconds"), | ||
| origin=request.GET.get("origin"), | ||
| ) | ||
|
|
||
| start_date: datetime = query_params.start_date | ||
| end_date: datetime = query_params.end_date | ||
| origin = query_params.origin | ||
| except ValidationError as e: | ||
| return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST) | ||
|
|
||
| labs_raw = get_lab_listing_data( | ||
| origin=origin, | ||
| start_date=start_date, | ||
| end_date=end_date, | ||
| ) | ||
|
|
||
| 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 Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) | ||
|
Contributor
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. nit: there is a helper that other views are using |
||
|
|
||
| return Response(data=result.model_dump(), status=HTTPStatus.OK) | ||
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.
we might be able to simplify those queries with something on the lines of