diff --git a/goosebit/__init__.py b/goosebit/__init__.py index 1c72e5a9..4034c943 100644 --- a/goosebit/__init__.py +++ b/goosebit/__init__.py @@ -3,12 +3,13 @@ from logging import getLogger from typing import Annotated -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, HTTPException from fastapi.openapi.docs import get_swagger_ui_html from fastapi.requests import Request from fastapi.responses import RedirectResponse from fastapi.security import OAuth2PasswordRequestForm from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor +from tortoise.exceptions import ValidationError from goosebit import api, db, realtime, ui, updater from goosebit.api.telemetry import metrics @@ -58,6 +59,12 @@ async def lifespan(_: FastAPI): Instrumentor.instrument_app(app) +# Custom exception handler for Tortoise ValidationError +@app.exception_handler(ValidationError) +async def tortoise_validation_exception_handler(request: Request, exc: ValidationError): + raise HTTPException(422, str(exc)) + + @app.middleware("http") async def attach_user(request: Request, call_next): request.scope["user"] = await get_user_from_request(request) diff --git a/goosebit/db/models.py b/goosebit/db/models.py index 91e60ec0..941316e5 100644 --- a/goosebit/db/models.py +++ b/goosebit/db/models.py @@ -8,6 +8,7 @@ import semver from anyio import Path from tortoise import Model, fields +from tortoise.exceptions import ValidationError from goosebit.api.telemetry.metrics import devices_count @@ -75,6 +76,14 @@ class Device(Model): tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags") async def save(self, *args, **kwargs): + # Check if the software is compatible with the hardware before saving + if self.assigned_software and self.hardware: + # Check if the assigned software is compatible with the hardware + await self.fetch_related("assigned_software", "hardware") + is_compatible = await self.assigned_software.compatibility.filter(id=self.hardware.id).exists() + if not is_compatible: + raise ValidationError("The assigned software is not compatible with the device's hardware.") + is_new = self._saved_in_db is False await super().save(*args, **kwargs) if is_new: diff --git a/goosebit/ui/bff/common/requests.py b/goosebit/ui/bff/common/requests.py index feb629bd..f19ac7f8 100644 --- a/goosebit/ui/bff/common/requests.py +++ b/goosebit/ui/bff/common/requests.py @@ -39,7 +39,7 @@ class DataTableRequest(BaseModel): columns: list[DataTableColumnSchema] = list() order: list[DataTableOrderSchema] = list() start: int = 0 - length: int = 10 + length: int = 0 search: DataTableSearchSchema = DataTableSearchSchema() @computed_field # type: ignore[misc] diff --git a/goosebit/ui/bff/devices/routes.py b/goosebit/ui/bff/devices/routes.py index cee953c7..27e6d36f 100644 --- a/goosebit/ui/bff/devices/routes.py +++ b/goosebit/ui/bff/devices/routes.py @@ -35,7 +35,7 @@ def search_filter(search_value: str): | Q(last_state=int(UpdateStateEnum.from_str(search_value))) ) - query = Device.all().prefetch_related("assigned_software", "hardware") + query = Device.all().prefetch_related("assigned_software", "hardware", "assigned_software__compatibility") return await BFFDeviceResponse.convert(dt_query, query, search_filter) diff --git a/goosebit/ui/bff/software/responses.py b/goosebit/ui/bff/software/responses.py index 7c71ac4b..7c3cd55b 100644 --- a/goosebit/ui/bff/software/responses.py +++ b/goosebit/ui/bff/software/responses.py @@ -1,6 +1,7 @@ from typing import Callable from pydantic import BaseModel, Field +from tortoise.expressions import Q from tortoise.queryset import QuerySet from goosebit.schema.software import SoftwareSchema @@ -14,8 +15,9 @@ class BFFSoftwareResponse(BaseModel): records_filtered: int = Field(serialization_alias="recordsFiltered") @classmethod - async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable): + async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable, alt_filter: Q): total_records = await query.count() + query = query.filter(alt_filter) if dt_query.search.value: query = query.filter(search_filter(dt_query.search.value)) @@ -23,7 +25,13 @@ async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filte query = query.order_by(dt_query.order_query) filtered_records = await query.count() - devices = await query.offset(dt_query.start).limit(dt_query.length).all() + + query = query.offset(dt_query.start) + + if not dt_query.length == 0: + query = query.limit(dt_query.length) + + devices = await query.all() data = [SoftwareSchema.model_validate(d) for d in devices] return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records) diff --git a/goosebit/ui/bff/software/routes.py b/goosebit/ui/bff/software/routes.py index 35ae9748..1edb6d7f 100644 --- a/goosebit/ui/bff/software/routes.py +++ b/goosebit/ui/bff/software/routes.py @@ -3,13 +3,13 @@ from typing import Annotated from anyio import Path, open_file -from fastapi import APIRouter, Depends, Form, HTTPException, Security, UploadFile +from fastapi import APIRouter, Depends, Form, HTTPException, Query, Security, UploadFile from fastapi.requests import Request from tortoise.expressions import Q from goosebit.api.v1.software import routes from goosebit.auth import validate_user_permissions -from goosebit.db.models import Rollout, Software +from goosebit.db.models import Hardware, Rollout, Software from goosebit.settings import config from goosebit.ui.bff.common.requests import DataTableRequest from goosebit.ui.bff.common.util import parse_datatables_query @@ -24,13 +24,23 @@ "", dependencies=[Security(validate_user_permissions, scopes=["software.read"])], ) -async def software_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFSoftwareResponse: - def search_filter(search_value: str): - return Q(uri__icontains=search_value) | Q(version__icontains=search_value) +async def software_get( + dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)], + uuids: list[str] = Query(default=None), +) -> BFFSoftwareResponse: + filters: list[Q] = [] + + def search_filter(search_value): + base_filter = Q(Q(uri__icontains=search_value), Q(version__icontains=search_value), join_type="OR") + return Q(base_filter, *filters, join_type="AND") query = Software.all().prefetch_related("compatibility") - return await BFFSoftwareResponse.convert(dt_query, query, search_filter) + if uuids: + hardware = await Hardware.filter(devices__uuid__in=uuids).distinct() + filters.append(Q(*[Q(compatibility__id=c.id) for c in hardware], join_type="AND")) + + return await BFFSoftwareResponse.convert(dt_query, query, search_filter, Q(*filters)) router.add_api_route( diff --git a/goosebit/ui/static/js/devices.js b/goosebit/ui/static/js/devices.js index 09de81b7..f30ef674 100644 --- a/goosebit/ui/static/js/devices.js +++ b/goosebit/ui/static/js/devices.js @@ -124,20 +124,11 @@ document.addEventListener("DOMContentLoaded", async () => { { text: '', action: () => { - const selectedDevice = dataTable.rows({ selected: true }).data().toArray()[0]; - $("#device-selected-name").val(selectedDevice.name); + const selectedDevices = dataTable.rows({ selected: true }).data().toArray(); + const selectedDevice = selectedDevices[0]; + updateSoftwareSelection(selectedDevices); + $("#device-name").val(selectedDevice.name); $("#device-selected-feed").val(selectedDevice.feed); - - let selectedValue; - if (selectedDevice.update_mode === "Rollout") { - selectedValue = "rollout"; - } else if (selectedDevice.update_mode === "Latest") { - selectedValue = "latest"; - } else { - selectedValue = selectedDevice.sw_assigned; - } - $("#selected-sw").val(selectedValue); - new bootstrap.Modal("#device-config-modal").show(); }, className: "buttons-config", @@ -199,22 +190,89 @@ document.addEventListener("DOMContentLoaded", async () => { dataTable.ajax.reload(null, false); }, TABLE_UPDATE_TIME); - await updateSoftwareSelection(true); + await updateSoftwareSelection(); + + // Name update form submit + const nameForm = document.getElementById("device-name-form"); + nameForm.addEventListener( + "submit", + async (event) => { + if (nameForm.checkValidity() === false) { + event.preventDefault(); + event.stopPropagation(); + nameForm.classList.add("was-validated"); + } else { + event.preventDefault(); + await updateDeviceName(); + nameForm.classList.remove("was-validated"); + nameForm.reset(); + const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal")); + modal.hide(); + } + }, + false, + ); + + // Rollout form submit + const rolloutForm = document.getElementById("device-software-rollout-form"); + rolloutForm.addEventListener( + "submit", + async (event) => { + if (rolloutForm.checkValidity() === false) { + event.preventDefault(); + event.stopPropagation(); + rolloutForm.classList.add("was-validated"); + } else { + event.preventDefault(); + await updateDeviceRollout(); + rolloutForm.classList.remove("was-validated"); + rolloutForm.reset(); + const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal")); + modal.hide(); + } + }, + false, + ); + + // Manual software form submit + const manualSoftwareForm = document.getElementById("device-software-manual-form"); + manualSoftwareForm.addEventListener( + "submit", + async (event) => { + if (manualSoftwareForm.checkValidity() === false) { + event.preventDefault(); + event.stopPropagation(); + manualSoftwareForm.classList.add("was-validated"); + if (document.getElementById("selected-sw").value === "") { + document.getElementById("selected-sw").parentElement.classList.add("is-invalid"); + } + } else { + event.preventDefault(); + await updateDeviceManualSoftware(); + manualSoftwareForm.classList.remove("was-validated"); + document.getElementById("selected-sw").parentElement.classList.remove("is-invalid"); + manualSoftwareForm.reset(); + const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal")); + modal.hide(); + } + }, + false, + ); - // Config form submit - const configForm = document.getElementById("device-config-form"); - configForm.addEventListener( + // Latest software form submit + const latestSoftwareForm = document.getElementById("device-software-latest-form"); + latestSoftwareForm.addEventListener( "submit", async (event) => { - if (configForm.checkValidity() === false) { + if (latestSoftwareForm.checkValidity() === false) { event.preventDefault(); event.stopPropagation(); - configForm.classList.add("was-validated"); + latestSoftwareForm.classList.add("was-validated"); } else { event.preventDefault(); - await updateDeviceConfig(); - configForm.classList.remove("was-validated"); - configForm.reset(); + await updateDeviceLatest(); + latestSoftwareForm.classList.remove("was-validated"); + latestSoftwareForm.reset(); const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal")); modal.hide(); } @@ -244,18 +302,70 @@ function updateBtnState() { } } -async function updateDeviceConfig() { +async function updateDeviceName() { + const devices = dataTable + .rows({ selected: true }) + .data() + .toArray() + .map((d) => d.uuid); + const name = document.getElementById("device-name").value; + + try { + await patch_request("/ui/bff/devices", { devices, name }); + } catch (error) { + console.error("Update device config failed:", error); + } + + setTimeout(updateDeviceList, 50); +} + +async function updateDeviceRollout() { const devices = dataTable .rows({ selected: true }) .data() .toArray() .map((d) => d.uuid); - const name = document.getElementById("device-selected-name").value; const feed = document.getElementById("device-selected-feed").value; + const software = "rollout"; + + try { + await patch_request("/ui/bff/devices", { devices, feed, software }); + } catch (error) { + console.error("Update device config failed:", error); + } + + setTimeout(updateDeviceList, 50); +} + +async function updateDeviceManualSoftware() { + const devices = dataTable + .rows({ selected: true }) + .data() + .toArray() + .map((d) => d.uuid); + const feed = null; const software = document.getElementById("selected-sw").value; try { - await patch_request("/ui/bff/devices", { devices, name, feed, software }); + await patch_request("/ui/bff/devices", { devices, feed, software }); + } catch (error) { + console.error("Update device config failed:", error); + } + + setTimeout(updateDeviceList, 50); +} + +async function updateDeviceLatest() { + const devices = dataTable + .rows({ selected: true }) + .data() + .toArray() + .map((d) => d.uuid); + const feed = null; + const software = "latest"; + + try { + await patch_request("/ui/bff/devices", { devices, feed, software }); } catch (error) { console.error("Update device config failed:", error); } diff --git a/goosebit/ui/static/js/rollouts.js b/goosebit/ui/static/js/rollouts.js index a61d3ffd..76618d7e 100644 --- a/goosebit/ui/static/js/rollouts.js +++ b/goosebit/ui/static/js/rollouts.js @@ -136,6 +136,9 @@ document.addEventListener("DOMContentLoaded", async () => { "submit", (event) => { if (form.checkValidity() === false) { + if (document.getElementById("selected-sw").value === "") { + document.getElementById("selected-sw").parentElement.classList.add("is-invalid"); + } event.preventDefault(); event.stopPropagation(); form.classList.add("was-validated"); @@ -143,6 +146,7 @@ document.addEventListener("DOMContentLoaded", async () => { event.preventDefault(); createRollout(); form.classList.remove("was-validated"); + document.getElementById("selected-sw").parentElement.classList.remove("is-invalid"); form.reset(); const modal = bootstrap.Modal.getInstance(document.getElementById("rollout-create-modal")); modal.hide(); diff --git a/goosebit/ui/static/js/util.js b/goosebit/ui/static/js/util.js index 6c1a00e6..dd968687 100644 --- a/goosebit/ui/static/js/util.js +++ b/goosebit/ui/static/js/util.js @@ -20,27 +20,22 @@ function secondsToRecentDate(t) { return s + (s === 1 ? " second" : " seconds"); } -async function updateSoftwareSelection(addSpecialMode = false) { +async function updateSoftwareSelection(devices = null) { try { - const response = await fetch("/ui/bff/software"); + const url = new URL("/ui/bff/software", window.location.origin); + if (devices != null) { + for (const device of devices) { + url.searchParams.append("uuids", device.uuid); + } + } + const response = await fetch(url.toString()); if (!response.ok) { console.error("Retrieving software list failed."); return; } const data = (await response.json()).data; const selectElem = document.getElementById("selected-sw"); - - if (addSpecialMode) { - let optionElem = document.createElement("option"); - optionElem.value = "rollout"; - optionElem.textContent = "Rollout"; - selectElem.appendChild(optionElem); - - optionElem = document.createElement("option"); - optionElem.value = "latest"; - optionElem.textContent = "Latest"; - selectElem.appendChild(optionElem); - } + selectElem.innerHTML = ""; for (const item of data) { const optionElem = document.createElement("option"); @@ -50,6 +45,20 @@ async function updateSoftwareSelection(addSpecialMode = false) { optionElem.textContent = `${item.version} (${models})`; selectElem.appendChild(optionElem); } + $("#selected-sw").selectpicker("destroy"); + if (data.length === 0) { + selectElem.title = "No valid software found for selected device"; + if (devices != null) { + if (devices.length > 1) { + selectElem.title += "s"; + } + } + selectElem.disabled = true; + } else { + selectElem.disabled = false; + selectElem.title = "Select Software"; + } + $("#selected-sw").selectpicker(); } catch (error) { console.error("Failed to fetch device data:", error); } diff --git a/goosebit/ui/templates/devices.html.jinja b/goosebit/ui/templates/devices.html.jinja index 83b8555d..ea0ab65f 100644 --- a/goosebit/ui/templates/devices.html.jinja +++ b/goosebit/ui/templates/devices.html.jinja @@ -28,46 +28,94 @@ -