goosebit 0.2.3__py3-none-any.whl → 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- goosebit/__init__.py +32 -3
- goosebit/api/v1/devices/device/routes.py +10 -4
- goosebit/api/v1/devices/responses.py +0 -7
- goosebit/api/v1/devices/routes.py +19 -3
- goosebit/api/v1/rollouts/responses.py +2 -7
- goosebit/api/v1/rollouts/routes.py +7 -3
- goosebit/api/v1/software/responses.py +0 -7
- goosebit/api/v1/software/routes.py +24 -11
- goosebit/auth/__init__.py +12 -8
- goosebit/db/__init__.py +12 -1
- goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
- goosebit/db/models.py +19 -4
- goosebit/realtime/logs.py +1 -1
- goosebit/schema/devices.py +42 -38
- goosebit/schema/rollouts.py +21 -18
- goosebit/schema/software.py +24 -19
- goosebit/settings/schema.py +2 -0
- goosebit/ui/bff/common/__init__.py +0 -0
- goosebit/ui/bff/common/requests.py +44 -0
- goosebit/ui/bff/common/responses.py +16 -0
- goosebit/ui/bff/common/util.py +32 -0
- goosebit/ui/bff/devices/responses.py +15 -19
- goosebit/ui/bff/devices/routes.py +61 -7
- goosebit/ui/bff/rollouts/responses.py +15 -19
- goosebit/ui/bff/rollouts/routes.py +8 -6
- goosebit/ui/bff/routes.py +4 -2
- goosebit/ui/bff/software/responses.py +29 -19
- goosebit/ui/bff/software/routes.py +29 -16
- goosebit/ui/nav.py +1 -1
- goosebit/ui/routes.py +10 -19
- goosebit/ui/static/js/devices.js +188 -94
- goosebit/ui/static/js/rollouts.js +20 -13
- goosebit/ui/static/js/software.js +5 -11
- goosebit/ui/static/js/util.js +43 -14
- goosebit/ui/templates/devices.html.jinja +77 -49
- goosebit/ui/templates/nav.html.jinja +35 -4
- goosebit/ui/templates/rollouts.html.jinja +23 -23
- goosebit/updater/controller/v1/routes.py +33 -23
- goosebit/updater/controller/v1/schema.py +4 -4
- goosebit/updater/manager.py +28 -52
- goosebit/updater/routes.py +6 -2
- goosebit/updates/__init__.py +14 -21
- goosebit/updates/swdesc.py +36 -15
- {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/METADATA +23 -7
- {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/RECORD +48 -44
- {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/WHEEL +1 -1
- goosebit-0.2.5.dist-info/entry_points.txt +3 -0
- goosebit/ui/static/js/index.js +0 -155
- goosebit/ui/templates/index.html.jinja +0 -25
- {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/LICENSE +0 -0
goosebit/__init__.py
CHANGED
@@ -1,27 +1,37 @@
|
|
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
|
7
|
+
from fastapi.exception_handlers import http_exception_handler
|
6
8
|
from fastapi.openapi.docs import get_swagger_ui_html
|
7
9
|
from fastapi.requests import Request
|
8
10
|
from fastapi.responses import RedirectResponse
|
9
11
|
from fastapi.security import OAuth2PasswordRequestForm
|
10
12
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
|
13
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
14
|
+
from tortoise.exceptions import ValidationError
|
11
15
|
|
12
16
|
from goosebit import api, db, realtime, ui, updater
|
13
17
|
from goosebit.api.telemetry import metrics
|
14
18
|
from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated
|
19
|
+
from goosebit.settings import config
|
15
20
|
from goosebit.ui.nav import nav
|
16
21
|
from goosebit.ui.static import static
|
17
22
|
from goosebit.ui.templates import templates
|
18
23
|
|
24
|
+
logger = getLogger(__name__)
|
25
|
+
|
19
26
|
|
20
27
|
@asynccontextmanager
|
21
28
|
async def lifespan(_: FastAPI):
|
22
|
-
await db.init()
|
29
|
+
db_ready = await db.init()
|
30
|
+
if not db_ready:
|
31
|
+
logger.exception("DB does not exist, try running `poetry run aerich upgrade`.")
|
23
32
|
await metrics.init()
|
24
|
-
|
33
|
+
if db_ready:
|
34
|
+
yield
|
25
35
|
await db.close()
|
26
36
|
|
27
37
|
|
@@ -52,6 +62,19 @@ app.mount("/static", static, name="static")
|
|
52
62
|
Instrumentor.instrument_app(app)
|
53
63
|
|
54
64
|
|
65
|
+
# Custom exception handler for Tortoise ValidationError
|
66
|
+
@app.exception_handler(ValidationError)
|
67
|
+
async def tortoise_validation_exception_handler(request: Request, exc: ValidationError):
|
68
|
+
raise HTTPException(422, str(exc))
|
69
|
+
|
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
|
+
|
55
78
|
@app.middleware("http")
|
56
79
|
async def attach_user(request: Request, call_next):
|
57
80
|
request.scope["user"] = await get_user_from_request(request)
|
@@ -64,6 +87,12 @@ async def attach_nav(request: Request, call_next):
|
|
64
87
|
return await call_next(request)
|
65
88
|
|
66
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
|
+
|
67
96
|
@app.get("/", include_in_schema=False)
|
68
97
|
def root_redirect(request: Request):
|
69
98
|
return RedirectResponse(request.url_for("ui_root"))
|
@@ -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
|
@@ -12,16 +12,22 @@ router = APIRouter(prefix="/{dev_id}")
|
|
12
12
|
|
13
13
|
@router.get(
|
14
14
|
"",
|
15
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
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()
|
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(
|
22
26
|
"/log",
|
23
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
27
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
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)
|
@@ -1,16 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import asyncio
|
4
|
-
|
5
3
|
from pydantic import BaseModel
|
6
4
|
|
7
|
-
from goosebit.db.models import Device
|
8
5
|
from goosebit.schema.devices import DeviceSchema
|
9
6
|
|
10
7
|
|
11
8
|
class DevicesResponse(BaseModel):
|
12
9
|
devices: list[DeviceSchema]
|
13
|
-
|
14
|
-
@classmethod
|
15
|
-
async def convert(cls, devices: list[Device]):
|
16
|
-
return cls(devices=await asyncio.gather(*[DeviceSchema.convert(d) for d in devices]))
|
@@ -1,12 +1,16 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import asyncio
|
4
|
+
|
3
5
|
from fastapi import APIRouter, Security
|
4
6
|
from fastapi.requests import Request
|
5
7
|
|
6
8
|
from goosebit.api.responses import StatusResponse
|
7
9
|
from goosebit.auth import validate_user_permissions
|
8
10
|
from goosebit.db.models import Device
|
9
|
-
from goosebit.
|
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
|
10
14
|
|
11
15
|
from . import device
|
12
16
|
from .requests import DevicesDeleteRequest
|
@@ -17,10 +21,22 @@ router = APIRouter(prefix="/devices", tags=["devices"])
|
|
17
21
|
|
18
22
|
@router.get(
|
19
23
|
"",
|
20
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
24
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
21
25
|
)
|
22
26
|
async def devices_get(_: Request) -> DevicesResponse:
|
23
|
-
|
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
|
24
40
|
|
25
41
|
|
26
42
|
@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,16 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import asyncio
|
4
|
-
|
5
3
|
from pydantic import BaseModel
|
6
4
|
|
7
|
-
from goosebit.db.models import Software
|
8
5
|
from goosebit.schema.software import SoftwareSchema
|
9
6
|
|
10
7
|
|
11
8
|
class SoftwareResponse(BaseModel):
|
12
9
|
software: list[SoftwareSchema]
|
13
|
-
|
14
|
-
@classmethod
|
15
|
-
async def convert(cls, software: list[Software]):
|
16
|
-
return cls(software=await asyncio.gather(*[SoftwareSchema.convert(f) for f in software]))
|
@@ -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}
|
goosebit/auth/__init__.py
CHANGED
@@ -16,7 +16,11 @@ from goosebit.settings.schema import User
|
|
16
16
|
logger = logging.getLogger(__name__)
|
17
17
|
|
18
18
|
|
19
|
-
|
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:
|
@@ -27,15 +31,15 @@ def create_token(username: str) -> str:
|
|
27
31
|
return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key)
|
28
32
|
|
29
33
|
|
30
|
-
def get_user_from_token(token: str) -> User | None:
|
34
|
+
def get_user_from_token(token: str | None) -> User | None:
|
31
35
|
if token is None:
|
32
|
-
return
|
36
|
+
return None
|
33
37
|
try:
|
34
38
|
token_data = jwt.decode(token, config.secret_key)
|
35
39
|
username = token_data.claims["username"]
|
36
40
|
return USERS.get(username)
|
37
41
|
except (BadSignatureError, LookupError, ValueError):
|
38
|
-
|
42
|
+
return None
|
39
43
|
|
40
44
|
|
41
45
|
def login_user(username: str, password: str) -> str:
|
@@ -58,9 +62,9 @@ def login_user(username: str, password: str) -> str:
|
|
58
62
|
|
59
63
|
|
60
64
|
def get_current_user(
|
61
|
-
session_token: Annotated[str, Depends(session_auth)] = None,
|
62
|
-
oauth2_token: Annotated[str, Depends(oauth2_auth)] = None,
|
63
|
-
) -> User:
|
65
|
+
session_token: Annotated[str | None, Depends(session_auth)] = None,
|
66
|
+
oauth2_token: Annotated[str | None, Depends(oauth2_auth)] = None,
|
67
|
+
) -> User | None:
|
64
68
|
session_user = get_user_from_token(session_token)
|
65
69
|
oauth2_user = get_user_from_token(oauth2_token)
|
66
70
|
user = session_user or oauth2_user
|
@@ -68,7 +72,7 @@ def get_current_user(
|
|
68
72
|
|
69
73
|
|
70
74
|
# using | Request because oauth2_auth.__call__ expects is
|
71
|
-
async def get_user_from_request(connection: HTTPConnection | Request) -> User:
|
75
|
+
async def get_user_from_request(connection: HTTPConnection | Request) -> User | None:
|
72
76
|
token = await session_auth(connection) or await oauth2_auth(connection)
|
73
77
|
return get_user_from_token(token)
|
74
78
|
|
goosebit/db/__init__.py
CHANGED
@@ -1,10 +1,21 @@
|
|
1
|
+
from logging import getLogger
|
2
|
+
|
1
3
|
from tortoise import Tortoise
|
4
|
+
from tortoise.exceptions import OperationalError
|
2
5
|
|
3
6
|
from goosebit.db.config import TORTOISE_CONF
|
7
|
+
from goosebit.db.models import Device
|
8
|
+
|
9
|
+
logger = getLogger(__name__)
|
4
10
|
|
5
11
|
|
6
|
-
async def init():
|
12
|
+
async def init() -> bool:
|
7
13
|
await Tortoise.init(config=TORTOISE_CONF)
|
14
|
+
try:
|
15
|
+
await Device.first()
|
16
|
+
except OperationalError:
|
17
|
+
return False
|
18
|
+
return True
|
8
19
|
|
9
20
|
|
10
21
|
async def close():
|
@@ -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;"""
|
goosebit/db/models.py
CHANGED
@@ -1,11 +1,15 @@
|
|
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
|
10
|
+
from semver import Version
|
8
11
|
from tortoise import Model, fields
|
12
|
+
from tortoise.exceptions import ValidationError
|
9
13
|
|
10
14
|
from goosebit.api.telemetry.metrics import devices_count
|
11
15
|
|
@@ -65,7 +69,6 @@ class Device(Model):
|
|
65
69
|
update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
|
66
70
|
last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
|
67
71
|
progress = fields.IntField(null=True)
|
68
|
-
log_complete = fields.BooleanField(default=False)
|
69
72
|
last_log = fields.TextField(null=True)
|
70
73
|
last_seen = fields.BigIntField(null=True)
|
71
74
|
last_ip = fields.CharField(max_length=15, null=True)
|
@@ -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:
|
@@ -127,12 +138,12 @@ class Software(Model):
|
|
127
138
|
return None
|
128
139
|
return sorted(
|
129
140
|
updates,
|
130
|
-
key=lambda x: semver.Version.parse(x.version),
|
141
|
+
key=lambda x: semver.Version.parse(x.version, optional_minor_and_patch=True),
|
131
142
|
reverse=True,
|
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
|
@@ -145,3 +156,7 @@ class Software(Model):
|
|
145
156
|
return self.path.name
|
146
157
|
else:
|
147
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)
|
goosebit/realtime/logs.py
CHANGED
@@ -19,7 +19,7 @@ class RealtimeLogModel(BaseModel):
|
|
19
19
|
|
20
20
|
@router.websocket(
|
21
21
|
"/{dev_id}",
|
22
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
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()
|
goosebit/schema/devices.py
CHANGED
@@ -4,10 +4,11 @@ import time
|
|
4
4
|
from enum import Enum, IntEnum, StrEnum
|
5
5
|
from typing import Annotated
|
6
6
|
|
7
|
-
from pydantic import BaseModel, BeforeValidator, computed_field
|
7
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, computed_field
|
8
8
|
|
9
|
-
from goosebit.db.models import
|
10
|
-
from goosebit.
|
9
|
+
from goosebit.db.models import UpdateModeEnum, UpdateStateEnum
|
10
|
+
from goosebit.schema.software import HardwareSchema, SoftwareSchema
|
11
|
+
from goosebit.updater.manager import DeviceUpdateManager
|
11
12
|
|
12
13
|
|
13
14
|
class ConvertableEnum(StrEnum):
|
@@ -26,48 +27,51 @@ UpdateModeSchema = enum_factory("UpdateModeSchema", UpdateModeEnum)
|
|
26
27
|
|
27
28
|
|
28
29
|
class DeviceSchema(BaseModel):
|
30
|
+
model_config = ConfigDict(from_attributes=True)
|
31
|
+
|
29
32
|
uuid: str
|
30
33
|
name: str | None
|
31
34
|
sw_version: str | None
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
35
|
+
|
36
|
+
assigned_software: SoftwareSchema | None = Field(exclude=True)
|
37
|
+
hardware: HardwareSchema | None = Field(exclude=True)
|
38
|
+
|
36
39
|
feed: str
|
37
40
|
progress: int | None
|
38
|
-
last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)]
|
39
|
-
update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)]
|
41
|
+
last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)] # type: ignore[valid-type]
|
42
|
+
update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)] # type: ignore[valid-type]
|
40
43
|
force_update: bool
|
41
44
|
last_ip: str | None
|
42
|
-
last_seen:
|
43
|
-
|
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
|
+
]
|
44
48
|
|
45
|
-
@computed_field
|
49
|
+
@computed_field # type: ignore[misc]
|
50
|
+
@property
|
46
51
|
def online(self) -> bool | None:
|
47
|
-
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
|
48
53
|
|
49
|
-
@
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
return
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
)
|
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
|
goosebit/schema/rollouts.py
CHANGED
@@ -1,31 +1,34 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from
|
3
|
+
from datetime import datetime
|
4
4
|
|
5
|
-
from
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_serializer
|
6
|
+
|
7
|
+
from goosebit.schema.software import SoftwareSchema
|
6
8
|
|
7
9
|
|
8
10
|
class RolloutSchema(BaseModel):
|
11
|
+
model_config = ConfigDict(from_attributes=True)
|
12
|
+
|
9
13
|
id: int
|
10
|
-
created_at:
|
14
|
+
created_at: datetime
|
11
15
|
name: str | None
|
12
16
|
feed: str
|
13
|
-
|
14
|
-
sw_version: str
|
17
|
+
software: SoftwareSchema = Field(exclude=True)
|
15
18
|
paused: bool
|
16
19
|
success_count: int
|
17
20
|
failure_count: int
|
18
21
|
|
19
|
-
@
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
)
|
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)
|