goosebit 0.2.4__tar.gz → 0.2.5__tar.gz

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 (102) hide show
  1. {goosebit-0.2.4 → goosebit-0.2.5}/PKG-INFO +14 -6
  2. {goosebit-0.2.4 → goosebit-0.2.5}/README.md +9 -2
  3. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/__init__.py +16 -0
  4. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/device/routes.py +2 -2
  5. goosebit-0.2.5/goosebit/api/v1/devices/routes.py +51 -0
  6. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/auth/__init__.py +5 -1
  7. goosebit-0.2.5/goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  8. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/db/models.py +6 -2
  9. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/realtime/logs.py +1 -1
  10. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/schema/devices.py +1 -1
  11. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/settings/schema.py +2 -0
  12. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/common/requests.py +3 -15
  13. goosebit-0.2.5/goosebit/ui/bff/common/responses.py +16 -0
  14. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/devices/responses.py +6 -2
  15. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/devices/routes.py +53 -2
  16. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/rollouts/responses.py +6 -2
  17. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/routes.py +4 -2
  18. goosebit-0.2.5/goosebit/ui/bff/software/responses.py +47 -0
  19. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/routes.py +7 -16
  20. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/devices.js +53 -69
  21. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/rollouts.js +16 -13
  22. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/software.js +5 -11
  23. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/util.js +21 -1
  24. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/devices.html.jinja +0 -20
  25. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/nav.html.jinja +13 -2
  26. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/v1/routes.py +26 -20
  27. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/manager.py +20 -52
  28. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/routes.py +6 -2
  29. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updates/swdesc.py +1 -1
  30. {goosebit-0.2.4 → goosebit-0.2.5}/pyproject.toml +8 -4
  31. goosebit-0.2.4/goosebit/api/v1/devices/routes.py +0 -36
  32. goosebit-0.2.4/goosebit/ui/bff/software/responses.py +0 -37
  33. goosebit-0.2.4/goosebit/ui/static/js/index.js +0 -155
  34. goosebit-0.2.4/goosebit/ui/templates/index.html.jinja +0 -25
  35. {goosebit-0.2.4 → goosebit-0.2.5}/LICENSE +0 -0
  36. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/__main__.py +0 -0
  37. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/__init__.py +0 -0
  38. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/responses.py +0 -0
  39. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/routes.py +0 -0
  40. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/__init__.py +0 -0
  41. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/metrics.py +0 -0
  42. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/__init__.py +0 -0
  43. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/readers.py +0 -0
  44. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/routes.py +0 -0
  45. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/routes.py +0 -0
  46. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/__init__.py +0 -0
  47. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/__init__.py +0 -0
  48. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/device/__init__.py +0 -0
  49. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/device/responses.py +0 -0
  50. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/requests.py +0 -0
  51. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/responses.py +0 -0
  52. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/download/__init__.py +0 -0
  53. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/download/routes.py +0 -0
  54. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/rollouts/__init__.py +0 -0
  55. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/rollouts/requests.py +0 -0
  56. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/rollouts/responses.py +0 -0
  57. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/rollouts/routes.py +0 -0
  58. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/routes.py +0 -0
  59. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/software/__init__.py +0 -0
  60. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/software/requests.py +0 -0
  61. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/software/responses.py +0 -0
  62. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/software/routes.py +0 -0
  63. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/db/__init__.py +0 -0
  64. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/db/config.py +0 -0
  65. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/db/migrations/models/0_20240830054046_init.py +0 -0
  66. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/realtime/__init__.py +0 -0
  67. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/realtime/routes.py +0 -0
  68. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/schema/__init__.py +0 -0
  69. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/schema/rollouts.py +0 -0
  70. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/schema/software.py +0 -0
  71. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/settings/__init__.py +0 -0
  72. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/settings/const.py +0 -0
  73. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/__init__.py +0 -0
  74. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/__init__.py +0 -0
  75. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/common/__init__.py +0 -0
  76. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/common/util.py +0 -0
  77. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/devices/__init__.py +0 -0
  78. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/devices/requests.py +0 -0
  79. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/download/__init__.py +0 -0
  80. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/download/routes.py +0 -0
  81. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/rollouts/__init__.py +0 -0
  82. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/rollouts/routes.py +0 -0
  83. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/software/__init__.py +0 -0
  84. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/software/routes.py +0 -0
  85. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/nav.py +0 -0
  86. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/__init__.py +0 -0
  87. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/favicon.ico +0 -0
  88. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/favicon.svg +0 -0
  89. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/login.js +0 -0
  90. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/logs.js +0 -0
  91. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/svg/goosebit-logo.svg +0 -0
  92. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/__init__.py +0 -0
  93. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/login.html.jinja +0 -0
  94. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/logs.html.jinja +0 -0
  95. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/rollouts.html.jinja +0 -0
  96. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/software.html.jinja +0 -0
  97. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/__init__.py +0 -0
  98. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/__init__.py +0 -0
  99. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/routes.py +0 -0
  100. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/v1/__init__.py +0 -0
  101. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/v1/schema.py +0 -0
  102. {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updates/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: goosebit
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary:
5
5
  Author: Upstream Data
6
6
  Author-email: brett@upstreamdata.ca
@@ -8,6 +8,7 @@ Requires-Python: >=3.11,<4.0
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.11
10
10
  Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
11
12
  Provides-Extra: postgresql
12
13
  Requires-Dist: aerich (>=0.7.2,<0.8.0)
13
14
  Requires-Dist: aiocache (>=0.12.2,<0.13.0)
@@ -19,9 +20,9 @@ Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
19
20
  Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
20
21
  Requires-Dist: joserfc (>=1.0.0,<2.0.0)
21
22
  Requires-Dist: libconf (>=2.0.1,<3.0.0)
22
- Requires-Dist: opentelemetry-distro (>=0.47b0,<0.48)
23
- Requires-Dist: opentelemetry-exporter-prometheus (>=0.47b0,<0.48)
24
- Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.47b0,<0.48)
23
+ Requires-Dist: opentelemetry-distro (>=0.49b1,<0.50)
24
+ Requires-Dist: opentelemetry-exporter-prometheus (>=0.49b1,<0.50)
25
+ Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.49b1,<0.50)
25
26
  Requires-Dist: pydantic-settings (>=2.4.0,<3.0.0)
26
27
  Requires-Dist: python-multipart (>=0.0.9,<0.0.10)
27
28
  Requires-Dist: semver (>=3.0.2,<4.0.0)
@@ -33,6 +34,8 @@ Description-Content-Type: text/markdown
33
34
 
34
35
  <img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
35
36
 
37
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/UpstreamDataInc/goosebit/badge)](https://scorecard.dev/viewer/?uri=github.com/UpstreamDataInc/goosebit)
38
+
36
39
  ---
37
40
 
38
41
  A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
@@ -50,7 +53,6 @@ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI AP
50
53
  2. Create the database:
51
54
 
52
55
  ```bash
53
- poetry run aerich init -t goosebit.db.config
54
56
  poetry run aerich upgrade
55
57
  ```
56
58
 
@@ -125,6 +127,12 @@ After a model change create the migration
125
127
  poetry run aerich migrate
126
128
  ```
127
129
 
130
+ To seed some sample data (attention: drops all current data) use
131
+
132
+ ```bash
133
+ poetry run generate-sample-data
134
+ ```
135
+
128
136
  ### Code formatting and linting
129
137
 
130
138
  Code is formatted using different tools
@@ -147,7 +155,7 @@ poetry run pre-commit install
147
155
  To manually apply the hooks to all files use:
148
156
 
149
157
  ```bash
150
- pre-commit run --all-files
158
+ poetry run pre-commit run --all-files
151
159
  ```
152
160
 
153
161
  ### Testing
@@ -2,6 +2,8 @@
2
2
 
3
3
  <img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
4
4
 
5
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/UpstreamDataInc/goosebit/badge)](https://scorecard.dev/viewer/?uri=github.com/UpstreamDataInc/goosebit)
6
+
5
7
  ---
6
8
 
7
9
  A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
@@ -19,7 +21,6 @@ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI AP
19
21
  2. Create the database:
20
22
 
21
23
  ```bash
22
- poetry run aerich init -t goosebit.db.config
23
24
  poetry run aerich upgrade
24
25
  ```
25
26
 
@@ -94,6 +95,12 @@ After a model change create the migration
94
95
  poetry run aerich migrate
95
96
  ```
96
97
 
98
+ To seed some sample data (attention: drops all current data) use
99
+
100
+ ```bash
101
+ poetry run generate-sample-data
102
+ ```
103
+
97
104
  ### Code formatting and linting
98
105
 
99
106
  Code is formatted using different tools
@@ -116,7 +123,7 @@ poetry run pre-commit install
116
123
  To manually apply the hooks to all files use:
117
124
 
118
125
  ```bash
119
- pre-commit run --all-files
126
+ poetry run pre-commit run --all-files
120
127
  ```
121
128
 
122
129
  ### Testing
@@ -4,16 +4,19 @@ from logging import getLogger
4
4
  from typing import Annotated
5
5
 
6
6
  from fastapi import Depends, FastAPI, HTTPException
7
+ from fastapi.exception_handlers import http_exception_handler
7
8
  from fastapi.openapi.docs import get_swagger_ui_html
8
9
  from fastapi.requests import Request
9
10
  from fastapi.responses import RedirectResponse
10
11
  from fastapi.security import OAuth2PasswordRequestForm
11
12
  from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
13
+ from starlette.exceptions import HTTPException as StarletteHTTPException
12
14
  from tortoise.exceptions import ValidationError
13
15
 
14
16
  from goosebit import api, db, realtime, ui, updater
15
17
  from goosebit.api.telemetry import metrics
16
18
  from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated
19
+ from goosebit.settings import config
17
20
  from goosebit.ui.nav import nav
18
21
  from goosebit.ui.static import static
19
22
  from goosebit.ui.templates import templates
@@ -65,6 +68,13 @@ async def tortoise_validation_exception_handler(request: Request, exc: Validatio
65
68
  raise HTTPException(422, str(exc))
66
69
 
67
70
 
71
+ # Extend default handler to do logging
72
+ @app.exception_handler(StarletteHTTPException)
73
+ async def custom_http_exception_handler(request, exc):
74
+ logger.warning(f"HTTPException, request={request.url}, status={exc.status_code}, detail={exc.detail}")
75
+ return await http_exception_handler(request, exc)
76
+
77
+
68
78
  @app.middleware("http")
69
79
  async def attach_user(request: Request, call_next):
70
80
  request.scope["user"] = await get_user_from_request(request)
@@ -77,6 +87,12 @@ async def attach_nav(request: Request, call_next):
77
87
  return await call_next(request)
78
88
 
79
89
 
90
+ @app.middleware("http")
91
+ async def attach_config(request: Request, call_next):
92
+ request.scope["config"] = config
93
+ return await call_next(request)
94
+
95
+
80
96
  @app.get("/", include_in_schema=False)
81
97
  def root_redirect(request: Request):
82
98
  return RedirectResponse(request.url_for("ui_root"))
@@ -12,7 +12,7 @@ router = APIRouter(prefix="/{dev_id}")
12
12
 
13
13
  @router.get(
14
14
  "",
15
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
15
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
16
16
  )
17
17
  async def device_get(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceResponse:
18
18
  device = await updater.get_device()
@@ -24,7 +24,7 @@ async def device_get(_: Request, updater: UpdateManager = Depends(get_update_man
24
24
 
25
25
  @router.get(
26
26
  "/log",
27
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
27
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
28
28
  )
29
29
  async def device_logs(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceLogResponse:
30
30
  device = await updater.get_device()
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, Security
6
+ from fastapi.requests import Request
7
+
8
+ from goosebit.api.responses import StatusResponse
9
+ from goosebit.auth import validate_user_permissions
10
+ from goosebit.db.models import Device
11
+ from goosebit.schema.devices import DeviceSchema
12
+ from goosebit.schema.software import SoftwareSchema
13
+ from goosebit.updater.manager import delete_devices, get_update_manager
14
+
15
+ from . import device
16
+ from .requests import DevicesDeleteRequest
17
+ from .responses import DevicesResponse
18
+
19
+ router = APIRouter(prefix="/devices", tags=["devices"])
20
+
21
+
22
+ @router.get(
23
+ "",
24
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
25
+ )
26
+ async def devices_get(_: Request) -> DevicesResponse:
27
+ devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility")
28
+ response = DevicesResponse(devices=devices)
29
+
30
+ async def set_assigned_sw(d: DeviceSchema):
31
+ updater = await get_update_manager(d.uuid)
32
+ _, target = await updater.get_update()
33
+ if target is not None:
34
+ await target.fetch_related("compatibility")
35
+ d.assigned_software = SoftwareSchema.model_validate(target)
36
+ return d
37
+
38
+ response.devices = await asyncio.gather(*[set_assigned_sw(d) for d in response.devices])
39
+ return response
40
+
41
+
42
+ @router.delete(
43
+ "",
44
+ dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
45
+ )
46
+ async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse:
47
+ await delete_devices(config.devices)
48
+ return StatusResponse(success=True)
49
+
50
+
51
+ router.include_router(device.router)
@@ -16,7 +16,11 @@ from goosebit.settings.schema import User
16
16
  logger = logging.getLogger(__name__)
17
17
 
18
18
 
19
- oauth2_auth = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
19
+ oauth2_bearer = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
20
+
21
+
22
+ async def oauth2_auth(connection: HTTPConnection):
23
+ return await oauth2_bearer(connection)
20
24
 
21
25
 
22
26
  async def session_auth(connection: HTTPConnection) -> str:
@@ -0,0 +1,11 @@
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ return """
6
+ ALTER TABLE "device" DROP COLUMN "log_complete";"""
7
+
8
+
9
+ async def downgrade(db: BaseDBAsyncClient) -> str:
10
+ return """
11
+ ALTER TABLE "device" ADD "log_complete" INT NOT NULL DEFAULT 0;"""
@@ -7,6 +7,7 @@ from urllib.request import url2pathname
7
7
 
8
8
  import semver
9
9
  from anyio import Path
10
+ from semver import Version
10
11
  from tortoise import Model, fields
11
12
  from tortoise.exceptions import ValidationError
12
13
 
@@ -68,7 +69,6 @@ class Device(Model):
68
69
  update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
69
70
  last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
70
71
  progress = fields.IntField(null=True)
71
- log_complete = fields.BooleanField(default=False)
72
72
  last_log = fields.TextField(null=True)
73
73
  last_seen = fields.BigIntField(null=True)
74
74
  last_ip = fields.CharField(max_length=15, null=True)
@@ -138,7 +138,7 @@ class Software(Model):
138
138
  return None
139
139
  return sorted(
140
140
  updates,
141
- key=lambda x: semver.Version.parse(x.version),
141
+ key=lambda x: semver.Version.parse(x.version, optional_minor_and_patch=True),
142
142
  reverse=True,
143
143
  )[0]
144
144
 
@@ -156,3 +156,7 @@ class Software(Model):
156
156
  return self.path.name
157
157
  else:
158
158
  return self.uri
159
+
160
+ @property
161
+ def parsed_version(self) -> Version:
162
+ return semver.Version.parse(self.version, optional_minor_and_patch=True)
@@ -19,7 +19,7 @@ class RealtimeLogModel(BaseModel):
19
19
 
20
20
  @router.websocket(
21
21
  "/{dev_id}",
22
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
22
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
23
23
  )
24
24
  async def device_logs(websocket: WebSocket, dev_id: str):
25
25
  await websocket.accept()
@@ -49,7 +49,7 @@ class DeviceSchema(BaseModel):
49
49
  @computed_field # type: ignore[misc]
50
50
  @property
51
51
  def online(self) -> bool | None:
52
- return self.last_seen < self.poll_seconds if self.last_seen is not None else None
52
+ return self.last_seen < (self.poll_seconds + 10) if self.last_seen is not None else None
53
53
 
54
54
  @computed_field # type: ignore[misc]
55
55
  @property
@@ -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,
@@ -10,14 +10,6 @@ class DataTableSearchSchema(BaseModel):
10
10
  regex: bool | None = False
11
11
 
12
12
 
13
- class DataTableColumnSchema(BaseModel):
14
- data: str | None
15
- name: str | None = None
16
- searchable: bool | None = None
17
- orderable: bool | None = None
18
- search: DataTableSearchSchema = DataTableSearchSchema()
19
-
20
-
21
13
  class DataTableOrderDirection(StrEnum):
22
14
  ASCENDING = "asc"
23
15
  DESCENDING = "desc"
@@ -36,21 +28,17 @@ class DataTableOrderSchema(BaseModel):
36
28
 
37
29
  class DataTableRequest(BaseModel):
38
30
  draw: int = 1
39
- columns: list[DataTableColumnSchema] = list()
40
31
  order: list[DataTableOrderSchema] = list()
41
32
  start: int = 0
42
- length: int = 0
33
+ length: int | None = None
43
34
  search: DataTableSearchSchema = DataTableSearchSchema()
44
35
 
45
36
  @computed_field # type: ignore[misc]
46
37
  @property
47
38
  def order_query(self) -> str | None:
48
39
  try:
49
- column = self.order[0].column
50
- if column is None:
51
- return None
52
- if self.columns[column].name is None:
40
+ if len(self.order) == 0 or self.order[0].direction is None or self.order[0].name is None:
53
41
  return None
54
- return f"{self.order[0].direction}{self.columns[column].data}"
42
+ return f"{self.order[0].direction}{self.order[0].name}"
55
43
  except LookupError:
56
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]
@@ -21,11 +21,15 @@ class BFFDeviceResponse(BaseModel):
21
21
  if dt_query.search.value:
22
22
  query = query.filter(search_filter(dt_query.search.value))
23
23
 
24
+ filtered_records = await query.count()
25
+
24
26
  if dt_query.order_query:
25
27
  query = query.order_by(dt_query.order_query)
26
28
 
27
- filtered_records = await query.count()
28
- devices = await query.offset(dt_query.start).limit(dt_query.length).all()
29
+ if dt_query.length is not None:
30
+ query = query.limit(dt_query.length)
31
+
32
+ devices = await query.offset(dt_query.start).all()
29
33
  data = [DeviceSchema.model_validate(d) for d in devices]
30
34
 
31
35
  return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  from typing import Annotated
4
5
 
5
6
  from fastapi import APIRouter, Depends, Security
@@ -10,10 +11,14 @@ from goosebit.api.responses import StatusResponse
10
11
  from goosebit.api.v1.devices import routes
11
12
  from goosebit.auth import validate_user_permissions
12
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
13
17
  from goosebit.ui.bff.common.requests import DataTableRequest
14
18
  from goosebit.ui.bff.common.util import parse_datatables_query
15
19
  from goosebit.updater.manager import get_update_manager
16
20
 
21
+ from ..common.responses import DTColumnDescription, DTColumns
17
22
  from .requests import DevicesPatchRequest
18
23
  from .responses import BFFDeviceResponse
19
24
 
@@ -22,7 +27,7 @@ router = APIRouter(prefix="/devices")
22
27
 
23
28
  @router.get(
24
29
  "",
25
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
30
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
26
31
  )
27
32
  async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse:
28
33
  def search_filter(search_value: str):
@@ -37,7 +42,18 @@ async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datata
37
42
 
38
43
  query = Device.all().prefetch_related("assigned_software", "hardware", "assigned_software__compatibility")
39
44
 
40
- return await BFFDeviceResponse.convert(dt_query, query, search_filter)
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
54
+
55
+ response.data = await asyncio.gather(*[set_assigned_sw(d) for d in response.data])
56
+ return response
41
57
 
42
58
 
43
59
  @router.patch(
@@ -73,3 +89,38 @@ router.add_api_route(
73
89
  dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
74
90
  name="bff_devices_delete",
75
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)
@@ -19,11 +19,15 @@ class BFFRolloutsResponse(BaseModel):
19
19
  if dt_query.search.value:
20
20
  query = query.filter(search_filter(dt_query.search.value))
21
21
 
22
+ filtered_records = await query.count()
23
+
22
24
  if dt_query.order_query:
23
25
  query = query.order_by(dt_query.order_query)
24
26
 
25
- filtered_records = await query.count()
26
- rollouts = await query.offset(dt_query.start).limit(dt_query.length).all()
27
+ if dt_query.length is not None:
28
+ query = query.limit(dt_query.length)
29
+
30
+ rollouts = await query.offset(dt_query.start).all()
27
31
  data = [RolloutSchema.model_validate(r) for r in rollouts]
28
32
 
29
33
  return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
@@ -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)
@@ -0,0 +1,47 @@
1
+ from typing import Callable
2
+
3
+ from pydantic import BaseModel, Field
4
+ from tortoise.expressions import Q
5
+ from tortoise.queryset import QuerySet
6
+
7
+ from goosebit.schema.software import SoftwareSchema
8
+ from goosebit.ui.bff.common.requests import DataTableOrderDirection, DataTableRequest
9
+
10
+
11
+ class BFFSoftwareResponse(BaseModel):
12
+ data: list[SoftwareSchema]
13
+ draw: int
14
+ records_total: int = Field(serialization_alias="recordsTotal")
15
+ records_filtered: int = Field(serialization_alias="recordsFiltered")
16
+
17
+ @classmethod
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))
23
+
24
+ filtered_records = await query.count()
25
+
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)
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]
37
+
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]
46
+
47
+ return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
@@ -11,27 +11,18 @@ 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
27
  @nav.route("Devices", permissions="device.read")
37
28
  async def devices_ui(request: Request):
@@ -40,7 +31,7 @@ async def devices_ui(request: Request):
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
36
  @nav.route("Software", permissions="software.read")
46
37
  async def software_ui(request: Request):
@@ -49,7 +40,7 @@ async def software_ui(request: Request):
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
45
  @nav.route("Rollouts", permissions="rollout.read")
55
46
  async def rollouts_ui(request: Request):
@@ -58,7 +49,7 @@ async def rollouts_ui(request: Request):
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})