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.
Files changed (50) hide show
  1. goosebit/__init__.py +32 -3
  2. goosebit/api/v1/devices/device/routes.py +10 -4
  3. goosebit/api/v1/devices/responses.py +0 -7
  4. goosebit/api/v1/devices/routes.py +19 -3
  5. goosebit/api/v1/rollouts/responses.py +2 -7
  6. goosebit/api/v1/rollouts/routes.py +7 -3
  7. goosebit/api/v1/software/responses.py +0 -7
  8. goosebit/api/v1/software/routes.py +24 -11
  9. goosebit/auth/__init__.py +12 -8
  10. goosebit/db/__init__.py +12 -1
  11. goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  12. goosebit/db/models.py +19 -4
  13. goosebit/realtime/logs.py +1 -1
  14. goosebit/schema/devices.py +42 -38
  15. goosebit/schema/rollouts.py +21 -18
  16. goosebit/schema/software.py +24 -19
  17. goosebit/settings/schema.py +2 -0
  18. goosebit/ui/bff/common/__init__.py +0 -0
  19. goosebit/ui/bff/common/requests.py +44 -0
  20. goosebit/ui/bff/common/responses.py +16 -0
  21. goosebit/ui/bff/common/util.py +32 -0
  22. goosebit/ui/bff/devices/responses.py +15 -19
  23. goosebit/ui/bff/devices/routes.py +61 -7
  24. goosebit/ui/bff/rollouts/responses.py +15 -19
  25. goosebit/ui/bff/rollouts/routes.py +8 -6
  26. goosebit/ui/bff/routes.py +4 -2
  27. goosebit/ui/bff/software/responses.py +29 -19
  28. goosebit/ui/bff/software/routes.py +29 -16
  29. goosebit/ui/nav.py +1 -1
  30. goosebit/ui/routes.py +10 -19
  31. goosebit/ui/static/js/devices.js +188 -94
  32. goosebit/ui/static/js/rollouts.js +20 -13
  33. goosebit/ui/static/js/software.js +5 -11
  34. goosebit/ui/static/js/util.js +43 -14
  35. goosebit/ui/templates/devices.html.jinja +77 -49
  36. goosebit/ui/templates/nav.html.jinja +35 -4
  37. goosebit/ui/templates/rollouts.html.jinja +23 -23
  38. goosebit/updater/controller/v1/routes.py +33 -23
  39. goosebit/updater/controller/v1/schema.py +4 -4
  40. goosebit/updater/manager.py +28 -52
  41. goosebit/updater/routes.py +6 -2
  42. goosebit/updates/__init__.py +14 -21
  43. goosebit/updates/swdesc.py +36 -15
  44. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/METADATA +23 -7
  45. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/RECORD +48 -44
  46. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/WHEEL +1 -1
  47. goosebit-0.2.5.dist-info/entry_points.txt +3 -0
  48. goosebit/ui/static/js/index.js +0 -155
  49. goosebit/ui/templates/index.html.jinja +0 -25
  50. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/LICENSE +0 -0
@@ -1,37 +1,42 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
3
+ from urllib.parse import unquote, urlparse
4
+ from urllib.request import url2pathname
4
5
 
5
- from pydantic import BaseModel
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
- name: str
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
- @classmethod
29
- async def convert(cls, software: Software):
30
- return cls(
31
- id=software.id,
32
- name=software.path_user,
33
- size=software.size,
34
- hash=software.hash,
35
- version=software.version,
36
- compatibility=await asyncio.gather(*[HardwareSchema.convert(h) for h in software.compatibility]),
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
@@ -52,6 +52,8 @@ class GooseBitSettings(BaseSettings):
52
52
 
53
53
  logging: dict = LOGGING_DEFAULT
54
54
 
55
+ track_device_ip: bool = True
56
+
55
57
  @classmethod
56
58
  def settings_customise_sources(
57
59
  cls,
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 asyncio
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, request: Request, query, search_filter, total_records):
19
- params = request.query_params
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
- draw = int(params.get("draw", 1))
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 search_value:
30
- query = query.filter(search_filter(search_value))
26
+ if dt_query.order_query:
27
+ query = query.order_by(dt_query.order_query)
31
28
 
32
- if order_column:
33
- query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
29
+ if dt_query.length is not None:
30
+ query = query.limit(dt_query.length)
34
31
 
35
- filtered_records = await query.count()
36
- devices = await query.offset(start).limit(length).all()
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
- from fastapi import APIRouter, Security
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=["home.read"])],
30
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
22
31
  )
23
- async def devices_get(request: Request) -> BFFDeviceResponse:
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
- total_records = await Device.all().count()
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
- return await BFFDeviceResponse.convert(request, query, search_filter, total_records)
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 asyncio
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, request: Request, query, search_filter, total_records):
17
- params = request.query_params
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
- draw = int(params.get("draw", 1))
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 search_value:
28
- query = query.filter(search_filter(search_value))
24
+ if dt_query.order_query:
25
+ query = query.order_by(dt_query.order_query)
29
26
 
30
- if order_column:
31
- query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
27
+ if dt_query.length is not None:
28
+ query = query.limit(dt_query.length)
32
29
 
33
- filtered_records = await query.count()
34
- rollouts = await query.offset(start).limit(length).all()
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 fastapi import APIRouter, Security
2
- from fastapi.requests import Request
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(request: Request) -> BFFRolloutsResponse:
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(request, query, search_filter, total_records)
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 asyncio
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, request: Request, query, search_filter, total_records):
17
- params = request.query_params
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
- draw = int(params.get("draw", 1))
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 search_value:
28
- query = query.filter(search_filter(search_value))
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
- if order_column:
31
- query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
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
- filtered_records = await query.count()
34
- devices = await query.offset(start).limit(length).all()
35
- data = await asyncio.gather(*[SoftwareSchema.convert(d) for d in devices])
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 aiofiles
4
- from fastapi import APIRouter, Form, HTTPException, Security, UploadFile
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(request: Request) -> BFFSoftwareResponse:
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
- return Q(uri__icontains=search_value) | Q(version__icontains=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")
26
36
 
27
37
  query = Software.all().prefetch_related("compatibility")
28
- total_records = await Software.all().count()
29
38
 
30
- return await BFFSoftwareResponse.convert(request, query, search_filter, total_records)
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
- file = config.artifacts_dir.joinpath(filename)
68
- config.artifacts_dir.mkdir(parents=True, exist_ok=True)
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 aiofiles.open(temp_file, mode="ab") as f:
77
- await f.write(contents)
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 create_software_update(file.absolute().as_uri(), temp_file)
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", dependencies=[Depends(redirect_if_unauthenticated)], include_in_schema=False)
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("home_ui"))
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=["device.read"])
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=["software.read"])
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=["rollout.read"])
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})