Skip to content

Commit 853d8b7

Browse files
authored
Merge pull request #138 from UpstreamDataInc/dev_selects
2 parents e59bc2a + 1a954a4 commit 853d8b7

File tree

13 files changed

+332
-105
lines changed

13 files changed

+332
-105
lines changed

goosebit/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
from logging import getLogger
44
from typing import Annotated
55

6-
from fastapi import Depends, FastAPI
6+
from fastapi import Depends, FastAPI, HTTPException
77
from fastapi.openapi.docs import get_swagger_ui_html
88
from fastapi.requests import Request
99
from fastapi.responses import RedirectResponse
1010
from fastapi.security import OAuth2PasswordRequestForm
1111
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
12+
from tortoise.exceptions import ValidationError
1213

1314
from goosebit import api, db, realtime, ui, updater
1415
from goosebit.api.telemetry import metrics
@@ -58,6 +59,12 @@ async def lifespan(_: FastAPI):
5859
Instrumentor.instrument_app(app)
5960

6061

62+
# Custom exception handler for Tortoise ValidationError
63+
@app.exception_handler(ValidationError)
64+
async def tortoise_validation_exception_handler(request: Request, exc: ValidationError):
65+
raise HTTPException(422, str(exc))
66+
67+
6168
@app.middleware("http")
6269
async def attach_user(request: Request, call_next):
6370
request.scope["user"] = await get_user_from_request(request)

goosebit/db/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import semver
99
from anyio import Path
1010
from tortoise import Model, fields
11+
from tortoise.exceptions import ValidationError
1112

1213
from goosebit.api.telemetry.metrics import devices_count
1314

@@ -75,6 +76,14 @@ class Device(Model):
7576
tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags")
7677

7778
async def save(self, *args, **kwargs):
79+
# Check if the software is compatible with the hardware before saving
80+
if self.assigned_software and self.hardware:
81+
# Check if the assigned software is compatible with the hardware
82+
await self.fetch_related("assigned_software", "hardware")
83+
is_compatible = await self.assigned_software.compatibility.filter(id=self.hardware.id).exists()
84+
if not is_compatible:
85+
raise ValidationError("The assigned software is not compatible with the device's hardware.")
86+
7887
is_new = self._saved_in_db is False
7988
await super().save(*args, **kwargs)
8089
if is_new:

goosebit/ui/bff/common/requests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class DataTableRequest(BaseModel):
3939
columns: list[DataTableColumnSchema] = list()
4040
order: list[DataTableOrderSchema] = list()
4141
start: int = 0
42-
length: int = 10
42+
length: int = 0
4343
search: DataTableSearchSchema = DataTableSearchSchema()
4444

4545
@computed_field # type: ignore[misc]

goosebit/ui/bff/devices/routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def search_filter(search_value: str):
3535
| Q(last_state=int(UpdateStateEnum.from_str(search_value)))
3636
)
3737

38-
query = Device.all().prefetch_related("assigned_software", "hardware")
38+
query = Device.all().prefetch_related("assigned_software", "hardware", "assigned_software__compatibility")
3939

4040
return await BFFDeviceResponse.convert(dt_query, query, search_filter)
4141

goosebit/ui/bff/software/responses.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Callable
22

33
from pydantic import BaseModel, Field
4+
from tortoise.expressions import Q
45
from tortoise.queryset import QuerySet
56

67
from goosebit.schema.software import SoftwareSchema
@@ -14,16 +15,23 @@ class BFFSoftwareResponse(BaseModel):
1415
records_filtered: int = Field(serialization_alias="recordsFiltered")
1516

1617
@classmethod
17-
async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable):
18+
async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable, alt_filter: Q):
1819
total_records = await query.count()
20+
query = query.filter(alt_filter)
1921
if dt_query.search.value:
2022
query = query.filter(search_filter(dt_query.search.value))
2123

2224
if dt_query.order_query:
2325
query = query.order_by(dt_query.order_query)
2426

2527
filtered_records = await query.count()
26-
devices = await query.offset(dt_query.start).limit(dt_query.length).all()
28+
29+
query = query.offset(dt_query.start)
30+
31+
if not dt_query.length == 0:
32+
query = query.limit(dt_query.length)
33+
34+
devices = await query.all()
2735
data = [SoftwareSchema.model_validate(d) for d in devices]
2836

2937
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)

goosebit/ui/bff/software/routes.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
from typing import Annotated
44

55
from anyio import Path, open_file
6-
from fastapi import APIRouter, Depends, Form, HTTPException, Security, UploadFile
6+
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Security, UploadFile
77
from fastapi.requests import Request
88
from tortoise.expressions import Q
99

1010
from goosebit.api.v1.software import routes
1111
from goosebit.auth import validate_user_permissions
12-
from goosebit.db.models import Rollout, Software
12+
from goosebit.db.models import Hardware, Rollout, Software
1313
from goosebit.settings import config
1414
from goosebit.ui.bff.common.requests import DataTableRequest
1515
from goosebit.ui.bff.common.util import parse_datatables_query
@@ -24,13 +24,23 @@
2424
"",
2525
dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
2626
)
27-
async def software_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFSoftwareResponse:
28-
def search_filter(search_value: str):
29-
return Q(uri__icontains=search_value) | Q(version__icontains=search_value)
27+
async def software_get(
28+
dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)],
29+
uuids: list[str] = Query(default=None),
30+
) -> BFFSoftwareResponse:
31+
filters: list[Q] = []
32+
33+
def search_filter(search_value):
34+
base_filter = Q(Q(uri__icontains=search_value), Q(version__icontains=search_value), join_type="OR")
35+
return Q(base_filter, *filters, join_type="AND")
3036

3137
query = Software.all().prefetch_related("compatibility")
3238

33-
return await BFFSoftwareResponse.convert(dt_query, query, search_filter)
39+
if uuids:
40+
hardware = await Hardware.filter(devices__uuid__in=uuids).distinct()
41+
filters.append(Q(*[Q(compatibility__id=c.id) for c in hardware], join_type="AND"))
42+
43+
return await BFFSoftwareResponse.convert(dt_query, query, search_filter, Q(*filters))
3444

3545

3646
router.add_api_route(

goosebit/ui/static/js/devices.js

Lines changed: 135 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -124,20 +124,11 @@ document.addEventListener("DOMContentLoaded", async () => {
124124
{
125125
text: '<i class="bi bi-pen" ></i>',
126126
action: () => {
127-
const selectedDevice = dataTable.rows({ selected: true }).data().toArray()[0];
128-
$("#device-selected-name").val(selectedDevice.name);
127+
const selectedDevices = dataTable.rows({ selected: true }).data().toArray();
128+
const selectedDevice = selectedDevices[0];
129+
updateSoftwareSelection(selectedDevices);
130+
$("#device-name").val(selectedDevice.name);
129131
$("#device-selected-feed").val(selectedDevice.feed);
130-
131-
let selectedValue;
132-
if (selectedDevice.update_mode === "Rollout") {
133-
selectedValue = "rollout";
134-
} else if (selectedDevice.update_mode === "Latest") {
135-
selectedValue = "latest";
136-
} else {
137-
selectedValue = selectedDevice.sw_assigned;
138-
}
139-
$("#selected-sw").val(selectedValue);
140-
141132
new bootstrap.Modal("#device-config-modal").show();
142133
},
143134
className: "buttons-config",
@@ -199,22 +190,89 @@ document.addEventListener("DOMContentLoaded", async () => {
199190
dataTable.ajax.reload(null, false);
200191
}, TABLE_UPDATE_TIME);
201192

202-
await updateSoftwareSelection(true);
193+
await updateSoftwareSelection();
194+
195+
// Name update form submit
196+
const nameForm = document.getElementById("device-name-form");
197+
nameForm.addEventListener(
198+
"submit",
199+
async (event) => {
200+
if (nameForm.checkValidity() === false) {
201+
event.preventDefault();
202+
event.stopPropagation();
203+
nameForm.classList.add("was-validated");
204+
} else {
205+
event.preventDefault();
206+
await updateDeviceName();
207+
nameForm.classList.remove("was-validated");
208+
nameForm.reset();
209+
const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal"));
210+
modal.hide();
211+
}
212+
},
213+
false,
214+
);
215+
216+
// Rollout form submit
217+
const rolloutForm = document.getElementById("device-software-rollout-form");
218+
rolloutForm.addEventListener(
219+
"submit",
220+
async (event) => {
221+
if (rolloutForm.checkValidity() === false) {
222+
event.preventDefault();
223+
event.stopPropagation();
224+
rolloutForm.classList.add("was-validated");
225+
} else {
226+
event.preventDefault();
227+
await updateDeviceRollout();
228+
rolloutForm.classList.remove("was-validated");
229+
rolloutForm.reset();
230+
const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal"));
231+
modal.hide();
232+
}
233+
},
234+
false,
235+
);
236+
237+
// Manual software form submit
238+
const manualSoftwareForm = document.getElementById("device-software-manual-form");
239+
manualSoftwareForm.addEventListener(
240+
"submit",
241+
async (event) => {
242+
if (manualSoftwareForm.checkValidity() === false) {
243+
event.preventDefault();
244+
event.stopPropagation();
245+
manualSoftwareForm.classList.add("was-validated");
246+
if (document.getElementById("selected-sw").value === "") {
247+
document.getElementById("selected-sw").parentElement.classList.add("is-invalid");
248+
}
249+
} else {
250+
event.preventDefault();
251+
await updateDeviceManualSoftware();
252+
manualSoftwareForm.classList.remove("was-validated");
253+
document.getElementById("selected-sw").parentElement.classList.remove("is-invalid");
254+
manualSoftwareForm.reset();
255+
const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal"));
256+
modal.hide();
257+
}
258+
},
259+
false,
260+
);
203261

204-
// Config form submit
205-
const configForm = document.getElementById("device-config-form");
206-
configForm.addEventListener(
262+
// Latest software form submit
263+
const latestSoftwareForm = document.getElementById("device-software-latest-form");
264+
latestSoftwareForm.addEventListener(
207265
"submit",
208266
async (event) => {
209-
if (configForm.checkValidity() === false) {
267+
if (latestSoftwareForm.checkValidity() === false) {
210268
event.preventDefault();
211269
event.stopPropagation();
212-
configForm.classList.add("was-validated");
270+
latestSoftwareForm.classList.add("was-validated");
213271
} else {
214272
event.preventDefault();
215-
await updateDeviceConfig();
216-
configForm.classList.remove("was-validated");
217-
configForm.reset();
273+
await updateDeviceLatest();
274+
latestSoftwareForm.classList.remove("was-validated");
275+
latestSoftwareForm.reset();
218276
const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal"));
219277
modal.hide();
220278
}
@@ -244,18 +302,70 @@ function updateBtnState() {
244302
}
245303
}
246304

247-
async function updateDeviceConfig() {
305+
async function updateDeviceName() {
306+
const devices = dataTable
307+
.rows({ selected: true })
308+
.data()
309+
.toArray()
310+
.map((d) => d.uuid);
311+
const name = document.getElementById("device-name").value;
312+
313+
try {
314+
await patch_request("/ui/bff/devices", { devices, name });
315+
} catch (error) {
316+
console.error("Update device config failed:", error);
317+
}
318+
319+
setTimeout(updateDeviceList, 50);
320+
}
321+
322+
async function updateDeviceRollout() {
248323
const devices = dataTable
249324
.rows({ selected: true })
250325
.data()
251326
.toArray()
252327
.map((d) => d.uuid);
253-
const name = document.getElementById("device-selected-name").value;
254328
const feed = document.getElementById("device-selected-feed").value;
329+
const software = "rollout";
330+
331+
try {
332+
await patch_request("/ui/bff/devices", { devices, feed, software });
333+
} catch (error) {
334+
console.error("Update device config failed:", error);
335+
}
336+
337+
setTimeout(updateDeviceList, 50);
338+
}
339+
340+
async function updateDeviceManualSoftware() {
341+
const devices = dataTable
342+
.rows({ selected: true })
343+
.data()
344+
.toArray()
345+
.map((d) => d.uuid);
346+
const feed = null;
255347
const software = document.getElementById("selected-sw").value;
256348

257349
try {
258-
await patch_request("/ui/bff/devices", { devices, name, feed, software });
350+
await patch_request("/ui/bff/devices", { devices, feed, software });
351+
} catch (error) {
352+
console.error("Update device config failed:", error);
353+
}
354+
355+
setTimeout(updateDeviceList, 50);
356+
}
357+
358+
async function updateDeviceLatest() {
359+
const devices = dataTable
360+
.rows({ selected: true })
361+
.data()
362+
.toArray()
363+
.map((d) => d.uuid);
364+
const feed = null;
365+
const software = "latest";
366+
367+
try {
368+
await patch_request("/ui/bff/devices", { devices, feed, software });
259369
} catch (error) {
260370
console.error("Update device config failed:", error);
261371
}

goosebit/ui/static/js/rollouts.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,17 @@ document.addEventListener("DOMContentLoaded", async () => {
136136
"submit",
137137
(event) => {
138138
if (form.checkValidity() === false) {
139+
if (document.getElementById("selected-sw").value === "") {
140+
document.getElementById("selected-sw").parentElement.classList.add("is-invalid");
141+
}
139142
event.preventDefault();
140143
event.stopPropagation();
141144
form.classList.add("was-validated");
142145
} else {
143146
event.preventDefault();
144147
createRollout();
145148
form.classList.remove("was-validated");
149+
document.getElementById("selected-sw").parentElement.classList.remove("is-invalid");
146150
form.reset();
147151
const modal = bootstrap.Modal.getInstance(document.getElementById("rollout-create-modal"));
148152
modal.hide();

0 commit comments

Comments
 (0)