goosebit 0.2.2__tar.gz → 0.2.4__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 (108) hide show
  1. {goosebit-0.2.2 → goosebit-0.2.4}/PKG-INFO +11 -3
  2. {goosebit-0.2.2 → goosebit-0.2.4}/README.md +10 -1
  3. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/__init__.py +16 -3
  4. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/device/routes.py +8 -2
  5. goosebit-0.2.4/goosebit/api/v1/devices/responses.py +9 -0
  6. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/routes.py +2 -1
  7. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/rollouts/responses.py +2 -7
  8. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/rollouts/routes.py +7 -3
  9. goosebit-0.2.4/goosebit/api/v1/software/responses.py +9 -0
  10. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/software/routes.py +24 -11
  11. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/auth/__init__.py +7 -7
  12. goosebit-0.2.4/goosebit/db/__init__.py +22 -0
  13. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/db/models.py +13 -2
  14. goosebit-0.2.4/goosebit/schema/devices.py +77 -0
  15. goosebit-0.2.4/goosebit/schema/rollouts.py +34 -0
  16. goosebit-0.2.4/goosebit/schema/software.py +42 -0
  17. goosebit-0.2.4/goosebit/ui/bff/common/__init__.py +0 -0
  18. goosebit-0.2.4/goosebit/ui/bff/common/requests.py +56 -0
  19. goosebit-0.2.4/goosebit/ui/bff/common/util.py +32 -0
  20. goosebit-0.2.4/goosebit/ui/bff/devices/responses.py +31 -0
  21. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/devices/routes.py +9 -6
  22. goosebit-0.2.4/goosebit/ui/bff/rollouts/responses.py +29 -0
  23. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/rollouts/routes.py +8 -6
  24. goosebit-0.2.4/goosebit/ui/bff/software/responses.py +37 -0
  25. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/software/routes.py +29 -16
  26. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/nav.py +1 -1
  27. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/routes.py +4 -4
  28. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/devices.js +135 -25
  29. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/rollouts.js +4 -0
  30. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/util.js +23 -14
  31. goosebit-0.2.4/goosebit/ui/templates/devices.html.jinja +123 -0
  32. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/nav.html.jinja +22 -2
  33. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/rollouts.html.jinja +23 -23
  34. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/v1/routes.py +7 -3
  35. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/v1/schema.py +4 -4
  36. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/manager.py +16 -8
  37. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updates/__init__.py +14 -21
  38. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updates/swdesc.py +35 -14
  39. {goosebit-0.2.2 → goosebit-0.2.4}/pyproject.toml +6 -2
  40. goosebit-0.2.2/goosebit/api/v1/devices/responses.py +0 -16
  41. goosebit-0.2.2/goosebit/api/v1/software/responses.py +0 -16
  42. goosebit-0.2.2/goosebit/db/__init__.py +0 -11
  43. goosebit-0.2.2/goosebit/schema/devices.py +0 -73
  44. goosebit-0.2.2/goosebit/schema/rollouts.py +0 -31
  45. goosebit-0.2.2/goosebit/schema/software.py +0 -37
  46. goosebit-0.2.2/goosebit/ui/bff/devices/responses.py +0 -39
  47. goosebit-0.2.2/goosebit/ui/bff/rollouts/responses.py +0 -37
  48. goosebit-0.2.2/goosebit/ui/bff/software/responses.py +0 -37
  49. goosebit-0.2.2/goosebit/ui/templates/devices.html.jinja +0 -75
  50. {goosebit-0.2.2 → goosebit-0.2.4}/LICENSE +0 -0
  51. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/__main__.py +0 -0
  52. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/__init__.py +0 -0
  53. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/responses.py +0 -0
  54. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/routes.py +0 -0
  55. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/__init__.py +0 -0
  56. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/metrics.py +0 -0
  57. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/prometheus/__init__.py +0 -0
  58. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/prometheus/readers.py +0 -0
  59. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/prometheus/routes.py +0 -0
  60. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/routes.py +0 -0
  61. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/__init__.py +0 -0
  62. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/__init__.py +0 -0
  63. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/device/__init__.py +0 -0
  64. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/device/responses.py +0 -0
  65. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/requests.py +0 -0
  66. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/download/__init__.py +0 -0
  67. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/download/routes.py +0 -0
  68. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/rollouts/__init__.py +0 -0
  69. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/rollouts/requests.py +0 -0
  70. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/routes.py +0 -0
  71. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/software/__init__.py +0 -0
  72. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/software/requests.py +0 -0
  73. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/db/config.py +0 -0
  74. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/db/migrations/models/0_20240830054046_init.py +0 -0
  75. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/realtime/__init__.py +0 -0
  76. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/realtime/logs.py +0 -0
  77. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/realtime/routes.py +0 -0
  78. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/schema/__init__.py +0 -0
  79. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/settings/__init__.py +0 -0
  80. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/settings/const.py +0 -0
  81. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/settings/schema.py +0 -0
  82. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/__init__.py +0 -0
  83. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/__init__.py +0 -0
  84. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/devices/__init__.py +0 -0
  85. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/devices/requests.py +0 -0
  86. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/download/__init__.py +0 -0
  87. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/download/routes.py +0 -0
  88. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/rollouts/__init__.py +0 -0
  89. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/routes.py +0 -0
  90. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/software/__init__.py +0 -0
  91. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/__init__.py +0 -0
  92. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/favicon.ico +0 -0
  93. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/favicon.svg +0 -0
  94. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/index.js +0 -0
  95. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/login.js +0 -0
  96. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/logs.js +0 -0
  97. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/software.js +0 -0
  98. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/svg/goosebit-logo.svg +0 -0
  99. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/__init__.py +0 -0
  100. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/index.html.jinja +0 -0
  101. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/login.html.jinja +0 -0
  102. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/logs.html.jinja +0 -0
  103. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/software.html.jinja +0 -0
  104. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/__init__.py +0 -0
  105. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/__init__.py +0 -0
  106. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/routes.py +0 -0
  107. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/v1/__init__.py +0 -0
  108. {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/routes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: goosebit
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary:
5
5
  Author: Upstream Data
6
6
  Author-email: brett@upstreamdata.ca
@@ -11,7 +11,6 @@ Classifier: Programming Language :: Python :: 3.12
11
11
  Provides-Extra: postgresql
12
12
  Requires-Dist: aerich (>=0.7.2,<0.8.0)
13
13
  Requires-Dist: aiocache (>=0.12.2,<0.13.0)
14
- Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
15
14
  Requires-Dist: argon2-cffi (>=23.1.0,<24.0.0)
16
15
  Requires-Dist: asyncpg (>=0.29.0,<0.30.0) ; extra == "postgresql"
17
16
  Requires-Dist: fastapi[uvicorn] (>=0.111.0,<0.112.0)
@@ -43,10 +42,19 @@ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI AP
43
42
  ### Installation
44
43
 
45
44
  1. Install dependencies using [Poetry](https://python-poetry.org/):
45
+
46
46
  ```bash
47
47
  poetry install
48
48
  ```
49
- 2. Launch gooseBit:
49
+
50
+ 2. Create the database:
51
+
52
+ ```bash
53
+ poetry run aerich init -t goosebit.db.config
54
+ poetry run aerich upgrade
55
+ ```
56
+
57
+ 3. Launch gooseBit:
50
58
  ```bash
51
59
  python main.py
52
60
  ```
@@ -11,10 +11,19 @@ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI AP
11
11
  ### Installation
12
12
 
13
13
  1. Install dependencies using [Poetry](https://python-poetry.org/):
14
+
14
15
  ```bash
15
16
  poetry install
16
17
  ```
17
- 2. Launch gooseBit:
18
+
19
+ 2. Create the database:
20
+
21
+ ```bash
22
+ poetry run aerich init -t goosebit.db.config
23
+ poetry run aerich upgrade
24
+ ```
25
+
26
+ 3. Launch gooseBit:
18
27
  ```bash
19
28
  python main.py
20
29
  ```
@@ -1,13 +1,15 @@
1
1
  import importlib.metadata
2
2
  from contextlib import asynccontextmanager
3
+ from logging import getLogger
3
4
  from typing import Annotated
4
5
 
5
- from fastapi import Depends, FastAPI
6
+ from fastapi import Depends, FastAPI, HTTPException
6
7
  from fastapi.openapi.docs import get_swagger_ui_html
7
8
  from fastapi.requests import Request
8
9
  from fastapi.responses import RedirectResponse
9
10
  from fastapi.security import OAuth2PasswordRequestForm
10
11
  from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
12
+ from tortoise.exceptions import ValidationError
11
13
 
12
14
  from goosebit import api, db, realtime, ui, updater
13
15
  from goosebit.api.telemetry import metrics
@@ -16,12 +18,17 @@ from goosebit.ui.nav import nav
16
18
  from goosebit.ui.static import static
17
19
  from goosebit.ui.templates import templates
18
20
 
21
+ logger = getLogger(__name__)
22
+
19
23
 
20
24
  @asynccontextmanager
21
25
  async def lifespan(_: FastAPI):
22
- await db.init()
26
+ db_ready = await db.init()
27
+ if not db_ready:
28
+ logger.exception("DB does not exist, try running `poetry run aerich upgrade`.")
23
29
  await metrics.init()
24
- yield
30
+ if db_ready:
31
+ yield
25
32
  await db.close()
26
33
 
27
34
 
@@ -52,6 +59,12 @@ app.mount("/static", static, name="static")
52
59
  Instrumentor.instrument_app(app)
53
60
 
54
61
 
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
+
55
68
  @app.middleware("http")
56
69
  async def attach_user(request: Request, call_next):
57
70
  request.scope["user"] = await get_user_from_request(request)
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from fastapi import APIRouter, Depends, Security
3
+ from fastapi import APIRouter, Depends, HTTPException, Security
4
4
  from fastapi.requests import Request
5
5
 
6
6
  from goosebit.api.v1.devices.device.responses import DeviceLogResponse, DeviceResponse
@@ -15,7 +15,11 @@ router = APIRouter(prefix="/{dev_id}")
15
15
  dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
16
16
  )
17
17
  async def device_get(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceResponse:
18
- return await DeviceResponse.convert(await updater.get_device())
18
+ device = await updater.get_device()
19
+ if device is None:
20
+ raise HTTPException(404)
21
+ await device.fetch_related("assigned_software", "hardware")
22
+ return DeviceResponse.model_validate(device)
19
23
 
20
24
 
21
25
  @router.get(
@@ -24,4 +28,6 @@ async def device_get(_: Request, updater: UpdateManager = Depends(get_update_man
24
28
  )
25
29
  async def device_logs(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceLogResponse:
26
30
  device = await updater.get_device()
31
+ if device is None:
32
+ raise HTTPException(404)
27
33
  return DeviceLogResponse(log=device.last_log)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from goosebit.schema.devices import DeviceSchema
6
+
7
+
8
+ class DevicesResponse(BaseModel):
9
+ devices: list[DeviceSchema]
@@ -20,7 +20,8 @@ router = APIRouter(prefix="/devices", tags=["devices"])
20
20
  dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
21
21
  )
22
22
  async def devices_get(_: Request) -> DevicesResponse:
23
- return await DevicesResponse.convert(await Device.all().prefetch_related("assigned_software", "hardware"))
23
+ devices = await Device.all().prefetch_related("assigned_software", "hardware")
24
+ return DevicesResponse(devices=devices)
24
25
 
25
26
 
26
27
  @router.delete(
@@ -1,19 +1,14 @@
1
- import asyncio
1
+ from __future__ import annotations
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
5
5
  from goosebit.api.responses import StatusResponse
6
- from goosebit.db.models import Rollout
7
6
  from goosebit.schema.rollouts import RolloutSchema
8
7
 
9
8
 
10
9
  class RolloutsPutResponse(StatusResponse):
11
- id: int
10
+ id: int | None = None
12
11
 
13
12
 
14
13
  class RolloutsResponse(BaseModel):
15
14
  rollouts: list[RolloutSchema]
16
-
17
- @classmethod
18
- async def convert(cls, devices: list[Rollout]):
19
- return cls(rollouts=await asyncio.gather(*[RolloutSchema.convert(d) for d in devices]))
@@ -1,9 +1,9 @@
1
- from fastapi import APIRouter, Security
1
+ from fastapi import APIRouter, HTTPException, Security
2
2
  from fastapi.requests import Request
3
3
 
4
4
  from goosebit.api.responses import StatusResponse
5
5
  from goosebit.auth import validate_user_permissions
6
- from goosebit.db.models import Rollout
6
+ from goosebit.db.models import Rollout, Software
7
7
 
8
8
  from .requests import RolloutsDeleteRequest, RolloutsPatchRequest, RolloutsPutRequest
9
9
  from .responses import RolloutsPutResponse, RolloutsResponse
@@ -16,7 +16,8 @@ router = APIRouter(prefix="/rollouts", tags=["rollouts"])
16
16
  dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
17
17
  )
18
18
  async def rollouts_get(_: Request) -> RolloutsResponse:
19
- return await RolloutsResponse.convert(await Rollout.all().prefetch_related("software"))
19
+ rollouts = await Rollout.all().prefetch_related("software", "software__compatibility")
20
+ return RolloutsResponse(rollouts=rollouts)
20
21
 
21
22
 
22
23
  @router.post(
@@ -24,6 +25,9 @@ async def rollouts_get(_: Request) -> RolloutsResponse:
24
25
  dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
25
26
  )
26
27
  async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse:
28
+ software = await Software.filter(id=rollout.software_id)
29
+ if len(software) == 0:
30
+ raise HTTPException(404, f"No software with ID {rollout.software_id} found")
27
31
  rollout = await Rollout.create(
28
32
  name=rollout.name,
29
33
  feed=rollout.feed,
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from goosebit.schema.software import SoftwareSchema
6
+
7
+
8
+ class SoftwareResponse(BaseModel):
9
+ software: list[SoftwareSchema]
@@ -1,6 +1,9 @@
1
- from pathlib import Path
1
+ from __future__ import annotations
2
2
 
3
- import aiofiles
3
+ import random
4
+ import string
5
+
6
+ from anyio import Path, open_file
4
7
  from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile
5
8
  from fastapi.requests import Request
6
9
 
@@ -21,7 +24,8 @@ router = APIRouter(prefix="/software", tags=["software"])
21
24
  dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
22
25
  )
23
26
  async def software_get(_: Request) -> SoftwareResponse:
24
- return await SoftwareResponse.convert(await Software.all().prefetch_related("compatibility"))
27
+ software = await Software.all().prefetch_related("compatibility")
28
+ return SoftwareResponse(software=software)
25
29
 
26
30
 
27
31
  @router.delete(
@@ -42,8 +46,8 @@ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> Stat
42
46
 
43
47
  if software.local:
44
48
  path = software.path
45
- if path.exists():
46
- path.unlink()
49
+ if await path.exists():
50
+ await path.unlink()
47
51
 
48
52
  await software.delete()
49
53
  success = True
@@ -66,12 +70,21 @@ async def post_update(_: Request, file: UploadFile | None = File(None), url: str
66
70
  raise HTTPException(409, "Software with same URL already exists and is referenced by rollout")
67
71
 
68
72
  software = await create_software_update(url, None)
69
- else:
73
+ elif file is not None:
70
74
  # local file
71
- file_path = config.artifacts_dir.joinpath(file.filename)
72
-
73
- async with aiofiles.tempfile.NamedTemporaryFile("w+b") as f:
74
- await f.write(await file.read())
75
- software = await create_software_update(file_path.absolute().as_uri(), Path(f.name))
75
+ artifacts_dir = Path(config.artifacts_dir)
76
+ file_path = artifacts_dir.joinpath(file.filename)
77
+ tmp_file_path = artifacts_dir.joinpath("tmp", ("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp"))
78
+ await tmp_file_path.parent.mkdir(parents=True, exist_ok=True)
79
+ file_absolute_path = await file_path.absolute()
80
+ tmp_file_absolute_path = await tmp_file_path.absolute()
81
+ try:
82
+ async with await open_file(tmp_file_path, "w+b") as f:
83
+ await f.write(await file.read())
84
+ software = await create_software_update(file_absolute_path.as_uri(), tmp_file_absolute_path)
85
+ finally:
86
+ await tmp_file_path.unlink(missing_ok=True)
87
+ else:
88
+ raise HTTPException(422)
76
89
 
77
90
  return {"id": software.id}
@@ -27,15 +27,15 @@ def create_token(username: str) -> str:
27
27
  return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key)
28
28
 
29
29
 
30
- def get_user_from_token(token: str) -> User | None:
30
+ def get_user_from_token(token: str | None) -> User | None:
31
31
  if token is None:
32
- return
32
+ return None
33
33
  try:
34
34
  token_data = jwt.decode(token, config.secret_key)
35
35
  username = token_data.claims["username"]
36
36
  return USERS.get(username)
37
37
  except (BadSignatureError, LookupError, ValueError):
38
- pass
38
+ return None
39
39
 
40
40
 
41
41
  def login_user(username: str, password: str) -> str:
@@ -58,9 +58,9 @@ def login_user(username: str, password: str) -> str:
58
58
 
59
59
 
60
60
  def get_current_user(
61
- session_token: Annotated[str, Depends(session_auth)] = None,
62
- oauth2_token: Annotated[str, Depends(oauth2_auth)] = None,
63
- ) -> User:
61
+ session_token: Annotated[str | None, Depends(session_auth)] = None,
62
+ oauth2_token: Annotated[str | None, Depends(oauth2_auth)] = None,
63
+ ) -> User | None:
64
64
  session_user = get_user_from_token(session_token)
65
65
  oauth2_user = get_user_from_token(oauth2_token)
66
66
  user = session_user or oauth2_user
@@ -68,7 +68,7 @@ def get_current_user(
68
68
 
69
69
 
70
70
  # using | Request because oauth2_auth.__call__ expects is
71
- async def get_user_from_request(connection: HTTPConnection | Request) -> User:
71
+ async def get_user_from_request(connection: HTTPConnection | Request) -> User | None:
72
72
  token = await session_auth(connection) or await oauth2_auth(connection)
73
73
  return get_user_from_token(token)
74
74
 
@@ -0,0 +1,22 @@
1
+ from logging import getLogger
2
+
3
+ from tortoise import Tortoise
4
+ from tortoise.exceptions import OperationalError
5
+
6
+ from goosebit.db.config import TORTOISE_CONF
7
+ from goosebit.db.models import Device
8
+
9
+ logger = getLogger(__name__)
10
+
11
+
12
+ async def init() -> bool:
13
+ await Tortoise.init(config=TORTOISE_CONF)
14
+ try:
15
+ await Device.first()
16
+ except OperationalError:
17
+ return False
18
+ return True
19
+
20
+
21
+ async def close():
22
+ await Tortoise.close_connections()
@@ -1,11 +1,14 @@
1
+ from __future__ import annotations
2
+
1
3
  from enum import IntEnum
2
- from pathlib import Path
3
4
  from typing import Self
4
5
  from urllib.parse import unquote, urlparse
5
6
  from urllib.request import url2pathname
6
7
 
7
8
  import semver
9
+ from anyio import Path
8
10
  from tortoise import Model, fields
11
+ from tortoise.exceptions import ValidationError
9
12
 
10
13
  from goosebit.api.telemetry.metrics import devices_count
11
14
 
@@ -73,6 +76,14 @@ class Device(Model):
73
76
  tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags")
74
77
 
75
78
  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
+
76
87
  is_new = self._saved_in_db is False
77
88
  await super().save(*args, **kwargs)
78
89
  if is_new:
@@ -132,7 +143,7 @@ class Software(Model):
132
143
  )[0]
133
144
 
134
145
  @property
135
- def path(self):
146
+ def path(self) -> Path:
136
147
  return Path(url2pathname(unquote(urlparse(self.uri).path)))
137
148
 
138
149
  @property
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from enum import Enum, IntEnum, StrEnum
5
+ from typing import Annotated
6
+
7
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, computed_field
8
+
9
+ from goosebit.db.models import UpdateModeEnum, UpdateStateEnum
10
+ from goosebit.schema.software import HardwareSchema, SoftwareSchema
11
+ from goosebit.updater.manager import DeviceUpdateManager
12
+
13
+
14
+ class ConvertableEnum(StrEnum):
15
+ @classmethod
16
+ def convert(cls, value: IntEnum):
17
+ return cls(str(value))
18
+
19
+
20
+ def enum_factory(name: str, base: type[Enum]) -> type[ConvertableEnum]:
21
+ enum_dict = {item.name: str(item) for item in base}
22
+ return ConvertableEnum(name, enum_dict) # type: ignore
23
+
24
+
25
+ UpdateStateSchema = enum_factory("UpdateStateSchema", UpdateStateEnum)
26
+ UpdateModeSchema = enum_factory("UpdateModeSchema", UpdateModeEnum)
27
+
28
+
29
+ class DeviceSchema(BaseModel):
30
+ model_config = ConfigDict(from_attributes=True)
31
+
32
+ uuid: str
33
+ name: str | None
34
+ sw_version: str | None
35
+
36
+ assigned_software: SoftwareSchema | None = Field(exclude=True)
37
+ hardware: HardwareSchema | None = Field(exclude=True)
38
+
39
+ feed: str
40
+ progress: int | None
41
+ last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)] # type: ignore[valid-type]
42
+ update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)] # type: ignore[valid-type]
43
+ force_update: bool
44
+ last_ip: str | None
45
+ last_seen: Annotated[
46
+ int | None, BeforeValidator(lambda last_seen: round(time.time() - last_seen) if last_seen is not None else None)
47
+ ]
48
+
49
+ @computed_field # type: ignore[misc]
50
+ @property
51
+ def online(self) -> bool | None:
52
+ return self.last_seen < self.poll_seconds if self.last_seen is not None else None
53
+
54
+ @computed_field # type: ignore[misc]
55
+ @property
56
+ def sw_target_version(self) -> str | None:
57
+ return self.assigned_software.version if self.assigned_software is not None else None
58
+
59
+ @computed_field # type: ignore[misc]
60
+ @property
61
+ def sw_assigned(self) -> int | None:
62
+ return self.assigned_software.id if self.assigned_software is not None else None
63
+
64
+ @computed_field # type: ignore[misc]
65
+ @property
66
+ def hw_model(self) -> str | None:
67
+ return self.hardware.model if self.hardware is not None else None
68
+
69
+ @computed_field # type: ignore[misc]
70
+ @property
71
+ def hw_revision(self) -> str | None:
72
+ return self.hardware.revision if self.hardware is not None else None
73
+
74
+ @computed_field # type: ignore[misc]
75
+ @property
76
+ def poll_seconds(self) -> int:
77
+ return DeviceUpdateManager(self.uuid).poll_seconds
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, computed_field, field_serializer
6
+
7
+ from goosebit.schema.software import SoftwareSchema
8
+
9
+
10
+ class RolloutSchema(BaseModel):
11
+ model_config = ConfigDict(from_attributes=True)
12
+
13
+ id: int
14
+ created_at: datetime
15
+ name: str | None
16
+ feed: str
17
+ software: SoftwareSchema = Field(exclude=True)
18
+ paused: bool
19
+ success_count: int
20
+ failure_count: int
21
+
22
+ @computed_field # type: ignore[misc]
23
+ @property
24
+ def sw_version(self) -> str:
25
+ return self.software.version
26
+
27
+ @computed_field # type: ignore[misc]
28
+ @property
29
+ def sw_file(self) -> str:
30
+ return self.software.path.name
31
+
32
+ @field_serializer("created_at")
33
+ def serialize_created_at(self, created_at: datetime, _info):
34
+ return int(created_at.timestamp() * 1000)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import unquote, urlparse
4
+ from urllib.request import url2pathname
5
+
6
+ from anyio import Path
7
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
8
+
9
+
10
+ class HardwareSchema(BaseModel):
11
+ model_config = ConfigDict(from_attributes=True)
12
+
13
+ id: int
14
+ model: str
15
+ revision: str
16
+
17
+
18
+ class SoftwareSchema(BaseModel):
19
+ model_config = ConfigDict(from_attributes=True)
20
+
21
+ id: int
22
+ uri: str = Field(exclude=True)
23
+ size: int
24
+ hash: str
25
+ version: str
26
+ compatibility: list[HardwareSchema]
27
+
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
File without changes
@@ -0,0 +1,56 @@
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 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
+ class DataTableOrderDirection(StrEnum):
22
+ ASCENDING = "asc"
23
+ DESCENDING = "desc"
24
+
25
+
26
+ class DataTableOrderSchema(BaseModel):
27
+ column: int | None = None
28
+ dir: DataTableOrderDirection | None = None
29
+ name: str | None = None
30
+
31
+ @computed_field # type: ignore[misc]
32
+ @property
33
+ def direction(self) -> str:
34
+ return "-" if self.dir == DataTableOrderDirection.DESCENDING else ""
35
+
36
+
37
+ class DataTableRequest(BaseModel):
38
+ draw: int = 1
39
+ columns: list[DataTableColumnSchema] = list()
40
+ order: list[DataTableOrderSchema] = list()
41
+ start: int = 0
42
+ length: int = 0
43
+ search: DataTableSearchSchema = DataTableSearchSchema()
44
+
45
+ @computed_field # type: ignore[misc]
46
+ @property
47
+ def order_query(self) -> str | None:
48
+ try:
49
+ column = self.order[0].column
50
+ if column is None:
51
+ return None
52
+ if self.columns[column].name is None:
53
+ return None
54
+ return f"{self.order[0].direction}{self.columns[column].data}"
55
+ except LookupError:
56
+ return None
@@ -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)
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ from pydantic import BaseModel, Field
6
+ from tortoise.queryset import QuerySet
7
+
8
+ from goosebit.schema.devices import DeviceSchema
9
+ from goosebit.ui.bff.common.requests import DataTableRequest
10
+
11
+
12
+ class BFFDeviceResponse(BaseModel):
13
+ data: list[DeviceSchema]
14
+ draw: int
15
+ records_total: int = Field(serialization_alias="recordsTotal")
16
+ records_filtered: int = Field(serialization_alias="recordsFiltered")
17
+
18
+ @classmethod
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))
23
+
24
+ if dt_query.order_query:
25
+ query = query.order_by(dt_query.order_query)
26
+
27
+ filtered_records = await query.count()
28
+ devices = await query.offset(dt_query.start).limit(dt_query.length).all()
29
+ data = [DeviceSchema.model_validate(d) for d in devices]
30
+
31
+ return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)