goosebit 0.2.3__py3-none-any.whl → 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- goosebit/__init__.py +32 -3
- goosebit/api/v1/devices/device/routes.py +10 -4
- goosebit/api/v1/devices/responses.py +0 -7
- goosebit/api/v1/devices/routes.py +19 -3
- goosebit/api/v1/rollouts/responses.py +2 -7
- goosebit/api/v1/rollouts/routes.py +7 -3
- goosebit/api/v1/software/responses.py +0 -7
- goosebit/api/v1/software/routes.py +24 -11
- goosebit/auth/__init__.py +12 -8
- goosebit/db/__init__.py +12 -1
- goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
- goosebit/db/models.py +19 -4
- goosebit/realtime/logs.py +1 -1
- goosebit/schema/devices.py +42 -38
- goosebit/schema/rollouts.py +21 -18
- goosebit/schema/software.py +24 -19
- goosebit/settings/schema.py +2 -0
- goosebit/ui/bff/common/__init__.py +0 -0
- goosebit/ui/bff/common/requests.py +44 -0
- goosebit/ui/bff/common/responses.py +16 -0
- goosebit/ui/bff/common/util.py +32 -0
- goosebit/ui/bff/devices/responses.py +15 -19
- goosebit/ui/bff/devices/routes.py +61 -7
- goosebit/ui/bff/rollouts/responses.py +15 -19
- goosebit/ui/bff/rollouts/routes.py +8 -6
- goosebit/ui/bff/routes.py +4 -2
- goosebit/ui/bff/software/responses.py +29 -19
- goosebit/ui/bff/software/routes.py +29 -16
- goosebit/ui/nav.py +1 -1
- goosebit/ui/routes.py +10 -19
- goosebit/ui/static/js/devices.js +188 -94
- goosebit/ui/static/js/rollouts.js +20 -13
- goosebit/ui/static/js/software.js +5 -11
- goosebit/ui/static/js/util.js +43 -14
- goosebit/ui/templates/devices.html.jinja +77 -49
- goosebit/ui/templates/nav.html.jinja +35 -4
- goosebit/ui/templates/rollouts.html.jinja +23 -23
- goosebit/updater/controller/v1/routes.py +33 -23
- goosebit/updater/controller/v1/schema.py +4 -4
- goosebit/updater/manager.py +28 -52
- goosebit/updater/routes.py +6 -2
- goosebit/updates/__init__.py +14 -21
- goosebit/updates/swdesc.py +36 -15
- {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/METADATA +23 -7
- {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/RECORD +48 -44
- {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/WHEEL +1 -1
- goosebit-0.2.5.dist-info/entry_points.txt +3 -0
- goosebit/ui/static/js/index.js +0 -155
- goosebit/ui/templates/index.html.jinja +0 -25
- {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/LICENSE +0 -0
goosebit/schema/software.py
CHANGED
@@ -1,37 +1,42 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import
|
3
|
+
from urllib.parse import unquote, urlparse
|
4
|
+
from urllib.request import url2pathname
|
4
5
|
|
5
|
-
from
|
6
|
-
|
7
|
-
from goosebit.db.models import Hardware, Software
|
6
|
+
from anyio import Path
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
8
8
|
|
9
9
|
|
10
10
|
class HardwareSchema(BaseModel):
|
11
|
+
model_config = ConfigDict(from_attributes=True)
|
12
|
+
|
11
13
|
id: int
|
12
14
|
model: str
|
13
15
|
revision: str
|
14
16
|
|
15
|
-
@classmethod
|
16
|
-
async def convert(cls, hardware: Hardware):
|
17
|
-
return cls(id=hardware.id, model=hardware.model, revision=hardware.revision)
|
18
|
-
|
19
17
|
|
20
18
|
class SoftwareSchema(BaseModel):
|
19
|
+
model_config = ConfigDict(from_attributes=True)
|
20
|
+
|
21
21
|
id: int
|
22
|
-
|
22
|
+
uri: str = Field(exclude=True)
|
23
23
|
size: int
|
24
24
|
hash: str
|
25
25
|
version: str
|
26
26
|
compatibility: list[HardwareSchema]
|
27
27
|
|
28
|
-
@
|
29
|
-
|
30
|
-
return
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
28
|
+
@property
|
29
|
+
def path(self) -> Path:
|
30
|
+
return Path(url2pathname(unquote(urlparse(self.uri).path)))
|
31
|
+
|
32
|
+
@property
|
33
|
+
def local(self) -> bool:
|
34
|
+
return urlparse(self.uri).scheme == "file"
|
35
|
+
|
36
|
+
@computed_field # type: ignore[misc]
|
37
|
+
@property
|
38
|
+
def name(self) -> str:
|
39
|
+
if self.local:
|
40
|
+
return self.path.name
|
41
|
+
else:
|
42
|
+
return self.uri
|
goosebit/settings/schema.py
CHANGED
File without changes
|
@@ -0,0 +1,44 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from enum import StrEnum
|
4
|
+
|
5
|
+
from pydantic import BaseModel, computed_field
|
6
|
+
|
7
|
+
|
8
|
+
class DataTableSearchSchema(BaseModel):
|
9
|
+
value: str | None = None
|
10
|
+
regex: bool | None = False
|
11
|
+
|
12
|
+
|
13
|
+
class DataTableOrderDirection(StrEnum):
|
14
|
+
ASCENDING = "asc"
|
15
|
+
DESCENDING = "desc"
|
16
|
+
|
17
|
+
|
18
|
+
class DataTableOrderSchema(BaseModel):
|
19
|
+
column: int | None = None
|
20
|
+
dir: DataTableOrderDirection | None = None
|
21
|
+
name: str | None = None
|
22
|
+
|
23
|
+
@computed_field # type: ignore[misc]
|
24
|
+
@property
|
25
|
+
def direction(self) -> str:
|
26
|
+
return "-" if self.dir == DataTableOrderDirection.DESCENDING else ""
|
27
|
+
|
28
|
+
|
29
|
+
class DataTableRequest(BaseModel):
|
30
|
+
draw: int = 1
|
31
|
+
order: list[DataTableOrderSchema] = list()
|
32
|
+
start: int = 0
|
33
|
+
length: int | None = None
|
34
|
+
search: DataTableSearchSchema = DataTableSearchSchema()
|
35
|
+
|
36
|
+
@computed_field # type: ignore[misc]
|
37
|
+
@property
|
38
|
+
def order_query(self) -> str | None:
|
39
|
+
try:
|
40
|
+
if len(self.order) == 0 or self.order[0].direction is None or self.order[0].name is None:
|
41
|
+
return None
|
42
|
+
return f"{self.order[0].direction}{self.order[0].name}"
|
43
|
+
except LookupError:
|
44
|
+
return None
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from pydantic import BaseModel
|
4
|
+
|
5
|
+
|
6
|
+
class DTColumnDescription(BaseModel):
|
7
|
+
title: str
|
8
|
+
data: str
|
9
|
+
name: str | None = None
|
10
|
+
|
11
|
+
searchable: bool | None = None
|
12
|
+
orderable: bool | None = None
|
13
|
+
|
14
|
+
|
15
|
+
class DTColumns(BaseModel):
|
16
|
+
columns: list[DTColumnDescription]
|
@@ -0,0 +1,32 @@
|
|
1
|
+
from fastapi.requests import Request
|
2
|
+
|
3
|
+
from goosebit.ui.bff.common.requests import DataTableRequest
|
4
|
+
|
5
|
+
|
6
|
+
def parse_datatables_query(request: Request):
|
7
|
+
# parsing adapted from https://github.com/ziiiio/datatable_ajax_request_parser
|
8
|
+
|
9
|
+
result = {}
|
10
|
+
for key, value in request.query_params.items():
|
11
|
+
key_list = key.replace("][", ";").replace("[", ";").replace("]", "").split(";")
|
12
|
+
|
13
|
+
if len(key_list) == 0:
|
14
|
+
continue
|
15
|
+
|
16
|
+
if len(key_list) == 1:
|
17
|
+
result[key] = value[0] if len(value) == 1 else value
|
18
|
+
continue
|
19
|
+
|
20
|
+
temp_dict = result
|
21
|
+
for inner_key in key_list[:-1]:
|
22
|
+
if inner_key not in temp_dict:
|
23
|
+
temp_dict.update({inner_key: {}})
|
24
|
+
temp_dict = temp_dict[inner_key]
|
25
|
+
temp_dict[key_list[-1]] = value[0] if len(value) == 1 else value
|
26
|
+
|
27
|
+
if result.get("columns"):
|
28
|
+
result["columns"] = [result["columns"][str(idx)] for idx, _ in enumerate(result["columns"])]
|
29
|
+
if result.get("order"):
|
30
|
+
result["order"] = [result["order"][str(idx)] for idx, _ in enumerate(result["order"])]
|
31
|
+
|
32
|
+
return DataTableRequest.model_validate(result)
|
@@ -1,11 +1,12 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import
|
3
|
+
from typing import Callable
|
4
4
|
|
5
|
-
from fastapi.requests import Request
|
6
5
|
from pydantic import BaseModel, Field
|
6
|
+
from tortoise.queryset import QuerySet
|
7
7
|
|
8
8
|
from goosebit.schema.devices import DeviceSchema
|
9
|
+
from goosebit.ui.bff.common.requests import DataTableRequest
|
9
10
|
|
10
11
|
|
11
12
|
class BFFDeviceResponse(BaseModel):
|
@@ -15,25 +16,20 @@ class BFFDeviceResponse(BaseModel):
|
|
15
16
|
records_filtered: int = Field(serialization_alias="recordsFiltered")
|
16
17
|
|
17
18
|
@classmethod
|
18
|
-
async def convert(cls,
|
19
|
-
|
19
|
+
async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable):
|
20
|
+
total_records = await query.count()
|
21
|
+
if dt_query.search.value:
|
22
|
+
query = query.filter(search_filter(dt_query.search.value))
|
20
23
|
|
21
|
-
|
22
|
-
start = int(params.get("start", 0))
|
23
|
-
length = int(params.get("length", 10))
|
24
|
-
search_value = params.get("search[value]", None)
|
25
|
-
order_column_index = params.get("order[0][column]", None)
|
26
|
-
order_column = params.get(f"columns[{order_column_index}][data]", None)
|
27
|
-
order_dir = params.get("order[0][dir]", None)
|
24
|
+
filtered_records = await query.count()
|
28
25
|
|
29
|
-
if
|
30
|
-
query = query.
|
26
|
+
if dt_query.order_query:
|
27
|
+
query = query.order_by(dt_query.order_query)
|
31
28
|
|
32
|
-
if
|
33
|
-
query = query.
|
29
|
+
if dt_query.length is not None:
|
30
|
+
query = query.limit(dt_query.length)
|
34
31
|
|
35
|
-
|
36
|
-
|
37
|
-
data = await asyncio.gather(*[DeviceSchema.convert(d) for d in devices])
|
32
|
+
devices = await query.offset(dt_query.start).all()
|
33
|
+
data = [DeviceSchema.model_validate(d) for d in devices]
|
38
34
|
|
39
|
-
return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
|
35
|
+
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
|
@@ -1,6 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
|
3
|
+
import asyncio
|
4
|
+
from typing import Annotated
|
5
|
+
|
6
|
+
from fastapi import APIRouter, Depends, Security
|
4
7
|
from fastapi.requests import Request
|
5
8
|
from tortoise.expressions import Q
|
6
9
|
|
@@ -8,8 +11,14 @@ from goosebit.api.responses import StatusResponse
|
|
8
11
|
from goosebit.api.v1.devices import routes
|
9
12
|
from goosebit.auth import validate_user_permissions
|
10
13
|
from goosebit.db.models import Device, Software, UpdateModeEnum, UpdateStateEnum
|
14
|
+
from goosebit.schema.devices import DeviceSchema
|
15
|
+
from goosebit.schema.software import SoftwareSchema
|
16
|
+
from goosebit.settings import config
|
17
|
+
from goosebit.ui.bff.common.requests import DataTableRequest
|
18
|
+
from goosebit.ui.bff.common.util import parse_datatables_query
|
11
19
|
from goosebit.updater.manager import get_update_manager
|
12
20
|
|
21
|
+
from ..common.responses import DTColumnDescription, DTColumns
|
13
22
|
from .requests import DevicesPatchRequest
|
14
23
|
from .responses import BFFDeviceResponse
|
15
24
|
|
@@ -18,10 +27,10 @@ router = APIRouter(prefix="/devices")
|
|
18
27
|
|
19
28
|
@router.get(
|
20
29
|
"",
|
21
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
30
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
22
31
|
)
|
23
|
-
async def devices_get(
|
24
|
-
def search_filter(search_value):
|
32
|
+
async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse:
|
33
|
+
def search_filter(search_value: str):
|
25
34
|
return (
|
26
35
|
Q(uuid__icontains=search_value)
|
27
36
|
| Q(name__icontains=search_value)
|
@@ -31,10 +40,20 @@ async def devices_get(request: Request) -> BFFDeviceResponse:
|
|
31
40
|
| Q(last_state=int(UpdateStateEnum.from_str(search_value)))
|
32
41
|
)
|
33
42
|
|
34
|
-
query = Device.all().prefetch_related("assigned_software", "hardware")
|
35
|
-
|
43
|
+
query = Device.all().prefetch_related("assigned_software", "hardware", "assigned_software__compatibility")
|
44
|
+
|
45
|
+
response = await BFFDeviceResponse.convert(dt_query, query, search_filter)
|
46
|
+
|
47
|
+
async def set_assigned_sw(d: DeviceSchema):
|
48
|
+
updater = await get_update_manager(d.uuid)
|
49
|
+
_, target = await updater.get_update()
|
50
|
+
if target is not None:
|
51
|
+
await target.fetch_related("compatibility")
|
52
|
+
d.assigned_software = SoftwareSchema.model_validate(target)
|
53
|
+
return d
|
36
54
|
|
37
|
-
|
55
|
+
response.data = await asyncio.gather(*[set_assigned_sw(d) for d in response.data])
|
56
|
+
return response
|
38
57
|
|
39
58
|
|
40
59
|
@router.patch(
|
@@ -70,3 +89,38 @@ router.add_api_route(
|
|
70
89
|
dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
|
71
90
|
name="bff_devices_delete",
|
72
91
|
)
|
92
|
+
|
93
|
+
|
94
|
+
@router.get(
|
95
|
+
"/columns",
|
96
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
97
|
+
response_model_exclude_none=True,
|
98
|
+
)
|
99
|
+
async def devices_get_columns() -> DTColumns:
|
100
|
+
columns = []
|
101
|
+
columns.append(DTColumnDescription(title="Online", data="online"))
|
102
|
+
columns.append(DTColumnDescription(title="UUID", data="uuid", name="uuid", searchable=True, orderable=True))
|
103
|
+
columns.append(DTColumnDescription(title="Name", data="name", name="name", searchable=True, orderable=True))
|
104
|
+
columns.append(DTColumnDescription(title="Model", data="hw_model"))
|
105
|
+
columns.append(DTColumnDescription(title="Revision", data="hw_revision"))
|
106
|
+
columns.append(DTColumnDescription(title="Feed", data="feed", name="feed", searchable=True, orderable=True))
|
107
|
+
columns.append(
|
108
|
+
DTColumnDescription(
|
109
|
+
title="Installed Software", data="sw_version", name="sw_version", searchable=True, orderable=True
|
110
|
+
)
|
111
|
+
)
|
112
|
+
columns.append(DTColumnDescription(title="Target Software", data="sw_target_version"))
|
113
|
+
columns.append(
|
114
|
+
DTColumnDescription(
|
115
|
+
title="Update Mode", data="update_mode", name="update_mode", searchable=True, orderable=True
|
116
|
+
)
|
117
|
+
)
|
118
|
+
columns.append(
|
119
|
+
DTColumnDescription(title="State", data="last_state", name="last_state", searchable=True, orderable=True)
|
120
|
+
)
|
121
|
+
columns.append(DTColumnDescription(title="Force Update", data="force_update"))
|
122
|
+
columns.append(DTColumnDescription(title="Progress", data="progress"))
|
123
|
+
if config.track_device_ip:
|
124
|
+
columns.append(DTColumnDescription(title="Last IP", data="last_ip"))
|
125
|
+
columns.append(DTColumnDescription(title="Last Seen", data="last_seen"))
|
126
|
+
return DTColumns(columns=columns)
|
@@ -1,9 +1,10 @@
|
|
1
|
-
import
|
1
|
+
from typing import Callable
|
2
2
|
|
3
|
-
from fastapi.requests import Request
|
4
3
|
from pydantic import BaseModel, Field
|
4
|
+
from tortoise.queryset import QuerySet
|
5
5
|
|
6
6
|
from goosebit.schema.rollouts import RolloutSchema
|
7
|
+
from goosebit.ui.bff.common.requests import DataTableRequest
|
7
8
|
|
8
9
|
|
9
10
|
class BFFRolloutsResponse(BaseModel):
|
@@ -13,25 +14,20 @@ class BFFRolloutsResponse(BaseModel):
|
|
13
14
|
records_filtered: int = Field(serialization_alias="recordsFiltered")
|
14
15
|
|
15
16
|
@classmethod
|
16
|
-
async def convert(cls,
|
17
|
-
|
17
|
+
async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable):
|
18
|
+
total_records = await query.count()
|
19
|
+
if dt_query.search.value:
|
20
|
+
query = query.filter(search_filter(dt_query.search.value))
|
18
21
|
|
19
|
-
|
20
|
-
start = int(params.get("start", 0))
|
21
|
-
length = int(params.get("length", 10))
|
22
|
-
search_value = params.get("search[value]", None)
|
23
|
-
order_column_index = params.get("order[0][column]", None)
|
24
|
-
order_column = params.get(f"columns[{order_column_index}][data]", None)
|
25
|
-
order_dir = params.get("order[0][dir]", None)
|
22
|
+
filtered_records = await query.count()
|
26
23
|
|
27
|
-
if
|
28
|
-
query = query.
|
24
|
+
if dt_query.order_query:
|
25
|
+
query = query.order_by(dt_query.order_query)
|
29
26
|
|
30
|
-
if
|
31
|
-
query = query.
|
27
|
+
if dt_query.length is not None:
|
28
|
+
query = query.limit(dt_query.length)
|
32
29
|
|
33
|
-
|
34
|
-
|
35
|
-
data = await asyncio.gather(*[RolloutSchema.convert(r) for r in rollouts])
|
30
|
+
rollouts = await query.offset(dt_query.start).all()
|
31
|
+
data = [RolloutSchema.model_validate(r) for r in rollouts]
|
36
32
|
|
37
|
-
return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
|
33
|
+
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
|
@@ -1,10 +1,13 @@
|
|
1
|
-
from
|
2
|
-
|
1
|
+
from typing import Annotated
|
2
|
+
|
3
|
+
from fastapi import APIRouter, Depends, Security
|
3
4
|
from tortoise.expressions import Q
|
4
5
|
|
5
6
|
from goosebit.api.v1.rollouts import routes
|
6
7
|
from goosebit.auth import validate_user_permissions
|
7
8
|
from goosebit.db.models import Rollout
|
9
|
+
from goosebit.ui.bff.common.requests import DataTableRequest
|
10
|
+
from goosebit.ui.bff.common.util import parse_datatables_query
|
8
11
|
|
9
12
|
from .responses import BFFRolloutsResponse
|
10
13
|
|
@@ -15,14 +18,13 @@ router = APIRouter(prefix="/rollouts")
|
|
15
18
|
"",
|
16
19
|
dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
|
17
20
|
)
|
18
|
-
async def rollouts_get(
|
21
|
+
async def rollouts_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFRolloutsResponse:
|
19
22
|
def search_filter(search_value):
|
20
23
|
return Q(name__icontains=search_value) | Q(feed__icontains=search_value)
|
21
24
|
|
22
|
-
query = Rollout.all().prefetch_related("software")
|
23
|
-
total_records = await Rollout.all().count()
|
25
|
+
query = Rollout.all().prefetch_related("software", "software__compatibility")
|
24
26
|
|
25
|
-
return await BFFRolloutsResponse.convert(
|
27
|
+
return await BFFRolloutsResponse.convert(dt_query, query, search_filter)
|
26
28
|
|
27
29
|
|
28
30
|
router.add_api_route(
|
goosebit/ui/bff/routes.py
CHANGED
@@ -1,10 +1,12 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from fastapi import APIRouter
|
3
|
+
from fastapi import APIRouter, Depends
|
4
|
+
|
5
|
+
from goosebit.auth import validate_current_user
|
4
6
|
|
5
7
|
from . import devices, download, rollouts, software
|
6
8
|
|
7
|
-
router = APIRouter(prefix="/bff", tags=["bff"])
|
9
|
+
router = APIRouter(prefix="/bff", tags=["bff"], dependencies=[Depends(validate_current_user)])
|
8
10
|
router.include_router(devices.router)
|
9
11
|
router.include_router(software.router)
|
10
12
|
router.include_router(rollouts.router)
|
@@ -1,9 +1,11 @@
|
|
1
|
-
import
|
1
|
+
from typing import Callable
|
2
2
|
|
3
|
-
from fastapi.requests import Request
|
4
3
|
from pydantic import BaseModel, Field
|
4
|
+
from tortoise.expressions import Q
|
5
|
+
from tortoise.queryset import QuerySet
|
5
6
|
|
6
7
|
from goosebit.schema.software import SoftwareSchema
|
8
|
+
from goosebit.ui.bff.common.requests import DataTableOrderDirection, DataTableRequest
|
7
9
|
|
8
10
|
|
9
11
|
class BFFSoftwareResponse(BaseModel):
|
@@ -13,25 +15,33 @@ class BFFSoftwareResponse(BaseModel):
|
|
13
15
|
records_filtered: int = Field(serialization_alias="recordsFiltered")
|
14
16
|
|
15
17
|
@classmethod
|
16
|
-
async def convert(cls,
|
17
|
-
|
18
|
+
async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable, alt_filter: Q):
|
19
|
+
total_records = await query.count()
|
20
|
+
query = query.filter(alt_filter)
|
21
|
+
if dt_query.search.value:
|
22
|
+
query = query.filter(search_filter(dt_query.search.value))
|
18
23
|
|
19
|
-
|
20
|
-
start = int(params.get("start", 0))
|
21
|
-
length = int(params.get("length", 10))
|
22
|
-
search_value = params.get("search[value]", None)
|
23
|
-
order_column_index = params.get("order[0][column]", None)
|
24
|
-
order_column = params.get(f"columns[{order_column_index}][data]", None)
|
25
|
-
order_dir = params.get("order[0][dir]", None)
|
24
|
+
filtered_records = await query.count()
|
26
25
|
|
27
|
-
if
|
28
|
-
|
26
|
+
if len(dt_query.order) > 0 and dt_query.order[0].name == "version":
|
27
|
+
# ordering cannot be delegated to database as semantic versioning sorting is not supported
|
28
|
+
software = await query.all()
|
29
|
+
reverse = dt_query.order[0].dir == DataTableOrderDirection.DESCENDING
|
30
|
+
software.sort(key=lambda s: s.parsed_version, reverse=reverse)
|
29
31
|
|
30
|
-
|
31
|
-
|
32
|
+
# in-memory paging
|
33
|
+
if dt_query.length is None:
|
34
|
+
software = software[dt_query.start :]
|
35
|
+
else:
|
36
|
+
software = software[dt_query.start : dt_query.start + dt_query.length]
|
32
37
|
|
33
|
-
|
34
|
-
|
35
|
-
|
38
|
+
else:
|
39
|
+
# if no ordering is specified, database-side paging can be used
|
40
|
+
if dt_query.length is not None:
|
41
|
+
query = query.limit(dt_query.length)
|
42
|
+
|
43
|
+
software = await query.offset(dt_query.start).all()
|
44
|
+
|
45
|
+
data = [SoftwareSchema.model_validate(s) for s in software]
|
36
46
|
|
37
|
-
return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
|
47
|
+
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
|
@@ -1,14 +1,18 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import
|
4
|
-
|
3
|
+
from typing import Annotated
|
4
|
+
|
5
|
+
from anyio import Path, open_file
|
6
|
+
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Security, UploadFile
|
5
7
|
from fastapi.requests import Request
|
6
8
|
from tortoise.expressions import Q
|
7
9
|
|
8
10
|
from goosebit.api.v1.software import routes
|
9
11
|
from goosebit.auth import validate_user_permissions
|
10
|
-
from goosebit.db.models import Rollout, Software
|
12
|
+
from goosebit.db.models import Hardware, Rollout, Software
|
11
13
|
from goosebit.settings import config
|
14
|
+
from goosebit.ui.bff.common.requests import DataTableRequest
|
15
|
+
from goosebit.ui.bff.common.util import parse_datatables_query
|
12
16
|
from goosebit.updates import create_software_update
|
13
17
|
|
14
18
|
from .responses import BFFSoftwareResponse
|
@@ -20,14 +24,23 @@ router = APIRouter(prefix="/software")
|
|
20
24
|
"",
|
21
25
|
dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
|
22
26
|
)
|
23
|
-
async def software_get(
|
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
|
+
|
24
33
|
def search_filter(search_value):
|
25
|
-
|
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")
|
26
36
|
|
27
37
|
query = Software.all().prefetch_related("compatibility")
|
28
|
-
total_records = await Software.all().count()
|
29
38
|
|
30
|
-
|
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))
|
31
44
|
|
32
45
|
|
33
46
|
router.add_api_route(
|
@@ -64,20 +77,20 @@ async def post_update(
|
|
64
77
|
await create_software_update(url, None)
|
65
78
|
else:
|
66
79
|
# local file
|
67
|
-
|
68
|
-
|
80
|
+
artifacts_dir = Path(config.artifacts_dir)
|
81
|
+
file = artifacts_dir.joinpath(filename)
|
82
|
+
await artifacts_dir.mkdir(parents=True, exist_ok=True)
|
69
83
|
|
70
84
|
temp_file = file.with_suffix(".tmp")
|
71
85
|
if init:
|
72
|
-
temp_file.unlink(missing_ok=True)
|
73
|
-
|
74
|
-
contents = await chunk.read()
|
86
|
+
await temp_file.unlink(missing_ok=True)
|
75
87
|
|
76
|
-
async with
|
77
|
-
await f.write(
|
88
|
+
async with await open_file(temp_file, "ab") as f:
|
89
|
+
await f.write(await chunk.read())
|
78
90
|
|
79
91
|
if done:
|
80
92
|
try:
|
81
|
-
await
|
93
|
+
absolute = await file.absolute()
|
94
|
+
await create_software_update(absolute.as_uri(), temp_file)
|
82
95
|
finally:
|
83
|
-
temp_file.unlink(missing_ok=True)
|
96
|
+
await temp_file.unlink(missing_ok=True)
|
goosebit/ui/nav.py
CHANGED
@@ -2,7 +2,7 @@ class Navigation:
|
|
2
2
|
def __init__(self):
|
3
3
|
self.items = []
|
4
4
|
|
5
|
-
def route(self, text: str, permissions: str = None):
|
5
|
+
def route(self, text: str, permissions: str | None = None):
|
6
6
|
def decorator(func):
|
7
7
|
self.items.append({"function": func.__name__, "text": text, "permissions": permissions})
|
8
8
|
return func
|
goosebit/ui/routes.py
CHANGED
@@ -11,54 +11,45 @@ from .templates import templates
|
|
11
11
|
|
12
12
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
|
13
13
|
|
14
|
-
router = APIRouter(prefix="/ui",
|
14
|
+
router = APIRouter(prefix="/ui", include_in_schema=False)
|
15
15
|
router.include_router(bff.router)
|
16
16
|
|
17
17
|
|
18
|
-
@router.get("")
|
18
|
+
@router.get("", dependencies=[Depends(redirect_if_unauthenticated)])
|
19
19
|
async def ui_root(request: Request):
|
20
|
-
return RedirectResponse(request.url_for("
|
21
|
-
|
22
|
-
|
23
|
-
@router.get(
|
24
|
-
"/home",
|
25
|
-
dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
|
26
|
-
)
|
27
|
-
@nav.route("Home", permissions=["home.read"])
|
28
|
-
async def home_ui(request: Request):
|
29
|
-
return templates.TemplateResponse(request, "index.html.jinja", context={"title": "Home"})
|
20
|
+
return RedirectResponse(request.url_for("devices_ui"))
|
30
21
|
|
31
22
|
|
32
23
|
@router.get(
|
33
24
|
"/devices",
|
34
|
-
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
25
|
+
dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["device.read"])],
|
35
26
|
)
|
36
|
-
@nav.route("Devices", permissions=
|
27
|
+
@nav.route("Devices", permissions="device.read")
|
37
28
|
async def devices_ui(request: Request):
|
38
29
|
return templates.TemplateResponse(request, "devices.html.jinja", context={"title": "Devices"})
|
39
30
|
|
40
31
|
|
41
32
|
@router.get(
|
42
33
|
"/software",
|
43
|
-
dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
|
34
|
+
dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["software.read"])],
|
44
35
|
)
|
45
|
-
@nav.route("Software", permissions=
|
36
|
+
@nav.route("Software", permissions="software.read")
|
46
37
|
async def software_ui(request: Request):
|
47
38
|
return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"})
|
48
39
|
|
49
40
|
|
50
41
|
@router.get(
|
51
42
|
"/rollouts",
|
52
|
-
dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
|
43
|
+
dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["rollout.read"])],
|
53
44
|
)
|
54
|
-
@nav.route("Rollouts", permissions=
|
45
|
+
@nav.route("Rollouts", permissions="rollout.read")
|
55
46
|
async def rollouts_ui(request: Request):
|
56
47
|
return templates.TemplateResponse(request, "rollouts.html.jinja", context={"title": "Rollouts"})
|
57
48
|
|
58
49
|
|
59
50
|
@router.get(
|
60
51
|
"/logs/{dev_id}",
|
61
|
-
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
52
|
+
dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["device.read"])],
|
62
53
|
)
|
63
54
|
async def logs_ui(request: Request, dev_id: str):
|
64
55
|
return templates.TemplateResponse(request, "logs.html.jinja", context={"title": "Log", "device": dev_id})
|