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.
- {goosebit-0.2.2 → goosebit-0.2.4}/PKG-INFO +11 -3
- {goosebit-0.2.2 → goosebit-0.2.4}/README.md +10 -1
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/__init__.py +16 -3
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/device/routes.py +8 -2
- goosebit-0.2.4/goosebit/api/v1/devices/responses.py +9 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/routes.py +2 -1
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/rollouts/responses.py +2 -7
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/rollouts/routes.py +7 -3
- goosebit-0.2.4/goosebit/api/v1/software/responses.py +9 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/software/routes.py +24 -11
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/auth/__init__.py +7 -7
- goosebit-0.2.4/goosebit/db/__init__.py +22 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/db/models.py +13 -2
- goosebit-0.2.4/goosebit/schema/devices.py +77 -0
- goosebit-0.2.4/goosebit/schema/rollouts.py +34 -0
- goosebit-0.2.4/goosebit/schema/software.py +42 -0
- goosebit-0.2.4/goosebit/ui/bff/common/__init__.py +0 -0
- goosebit-0.2.4/goosebit/ui/bff/common/requests.py +56 -0
- goosebit-0.2.4/goosebit/ui/bff/common/util.py +32 -0
- goosebit-0.2.4/goosebit/ui/bff/devices/responses.py +31 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/devices/routes.py +9 -6
- goosebit-0.2.4/goosebit/ui/bff/rollouts/responses.py +29 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/rollouts/routes.py +8 -6
- goosebit-0.2.4/goosebit/ui/bff/software/responses.py +37 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/software/routes.py +29 -16
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/nav.py +1 -1
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/routes.py +4 -4
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/devices.js +135 -25
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/rollouts.js +4 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/util.js +23 -14
- goosebit-0.2.4/goosebit/ui/templates/devices.html.jinja +123 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/nav.html.jinja +22 -2
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/rollouts.html.jinja +23 -23
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/v1/routes.py +7 -3
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/v1/schema.py +4 -4
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/manager.py +16 -8
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updates/__init__.py +14 -21
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updates/swdesc.py +35 -14
- {goosebit-0.2.2 → goosebit-0.2.4}/pyproject.toml +6 -2
- goosebit-0.2.2/goosebit/api/v1/devices/responses.py +0 -16
- goosebit-0.2.2/goosebit/api/v1/software/responses.py +0 -16
- goosebit-0.2.2/goosebit/db/__init__.py +0 -11
- goosebit-0.2.2/goosebit/schema/devices.py +0 -73
- goosebit-0.2.2/goosebit/schema/rollouts.py +0 -31
- goosebit-0.2.2/goosebit/schema/software.py +0 -37
- goosebit-0.2.2/goosebit/ui/bff/devices/responses.py +0 -39
- goosebit-0.2.2/goosebit/ui/bff/rollouts/responses.py +0 -37
- goosebit-0.2.2/goosebit/ui/bff/software/responses.py +0 -37
- goosebit-0.2.2/goosebit/ui/templates/devices.html.jinja +0 -75
- {goosebit-0.2.2 → goosebit-0.2.4}/LICENSE +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/__main__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/responses.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/metrics.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/prometheus/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/prometheus/readers.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/prometheus/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/telemetry/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/device/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/device/responses.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/devices/requests.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/download/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/download/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/rollouts/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/rollouts/requests.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/software/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/api/v1/software/requests.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/db/config.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/db/migrations/models/0_20240830054046_init.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/realtime/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/realtime/logs.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/realtime/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/schema/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/settings/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/settings/const.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/settings/schema.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/devices/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/devices/requests.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/download/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/download/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/rollouts/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/bff/software/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/favicon.ico +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/favicon.svg +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/index.js +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/login.js +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/logs.js +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/js/software.js +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/static/svg/goosebit-logo.svg +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/index.html.jinja +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/login.html.jinja +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/logs.html.jinja +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/ui/templates/software.html.jinja +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/__init__.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/routes.py +0 -0
- {goosebit-0.2.2 → goosebit-0.2.4}/goosebit/updater/controller/v1/__init__.py +0 -0
- {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.
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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)
|
@@ -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
|
-
|
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
|
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
|
-
|
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,
|
@@ -1,6 +1,9 @@
|
|
1
|
-
from
|
1
|
+
from __future__ import annotations
|
2
2
|
|
3
|
-
import
|
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
|
-
|
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
|
-
|
73
|
+
elif file is not None:
|
70
74
|
# local file
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
-
|
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)
|