goosebit 0.2.5__py3-none-any.whl → 0.2.6__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 +41 -7
- goosebit/api/telemetry/metrics.py +1 -5
- goosebit/api/v1/devices/device/responses.py +1 -0
- goosebit/api/v1/devices/device/routes.py +8 -8
- goosebit/api/v1/devices/requests.py +20 -0
- goosebit/api/v1/devices/routes.py +68 -8
- goosebit/api/v1/download/routes.py +14 -3
- goosebit/api/v1/rollouts/routes.py +5 -4
- goosebit/api/v1/routes.py +2 -1
- goosebit/api/v1/settings/routes.py +14 -0
- goosebit/api/v1/settings/users/__init__.py +1 -0
- goosebit/api/v1/settings/users/requests.py +16 -0
- goosebit/api/v1/settings/users/responses.py +7 -0
- goosebit/api/v1/settings/users/routes.py +56 -0
- goosebit/api/v1/software/routes.py +18 -14
- goosebit/auth/__init__.py +49 -13
- goosebit/auth/permissions.py +80 -0
- goosebit/db/config.py +57 -1
- goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
- goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
- goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
- goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
- goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
- goosebit/db/models.py +19 -8
- goosebit/db/pg_ssl_context.py +51 -0
- goosebit/device_manager.py +262 -0
- goosebit/plugins/__init__.py +32 -0
- goosebit/schema/devices.py +8 -5
- goosebit/schema/plugins.py +67 -0
- goosebit/schema/updates.py +15 -0
- goosebit/schema/users.py +9 -0
- goosebit/settings/__init__.py +0 -3
- goosebit/settings/schema.py +60 -14
- goosebit/storage/__init__.py +62 -0
- goosebit/storage/base.py +14 -0
- goosebit/storage/filesystem.py +111 -0
- goosebit/storage/s3.py +104 -0
- goosebit/ui/bff/common/columns.py +50 -0
- goosebit/ui/bff/common/responses.py +1 -0
- goosebit/ui/bff/devices/device/__init__.py +1 -0
- goosebit/ui/bff/devices/device/routes.py +17 -0
- goosebit/ui/bff/devices/requests.py +1 -0
- goosebit/ui/bff/devices/routes.py +49 -46
- goosebit/ui/bff/download/routes.py +14 -3
- goosebit/ui/bff/rollouts/routes.py +32 -4
- goosebit/ui/bff/routes.py +2 -1
- goosebit/ui/bff/settings/__init__.py +1 -0
- goosebit/ui/bff/settings/routes.py +20 -0
- goosebit/ui/bff/settings/users/__init__.py +1 -0
- goosebit/ui/bff/settings/users/responses.py +33 -0
- goosebit/ui/bff/settings/users/routes.py +80 -0
- goosebit/ui/bff/software/routes.py +40 -12
- goosebit/ui/nav.py +12 -2
- goosebit/ui/routes.py +66 -13
- goosebit/ui/static/js/devices.js +32 -24
- goosebit/ui/static/js/login.js +21 -5
- goosebit/ui/static/js/logs.js +7 -22
- goosebit/ui/static/js/rollouts.js +31 -30
- goosebit/ui/static/js/settings.js +322 -0
- goosebit/ui/static/js/setup.js +28 -0
- goosebit/ui/static/js/software.js +127 -121
- goosebit/ui/static/js/util.js +25 -4
- goosebit/ui/templates/__init__.py +10 -1
- goosebit/ui/templates/login.html.jinja +5 -0
- goosebit/ui/templates/nav.html.jinja +13 -5
- goosebit/ui/templates/rollouts.html.jinja +4 -22
- goosebit/ui/templates/settings.html.jinja +88 -0
- goosebit/ui/templates/setup.html.jinja +71 -0
- goosebit/ui/templates/software.html.jinja +0 -11
- goosebit/updater/controller/v1/routes.py +119 -77
- goosebit/updater/routes.py +83 -8
- goosebit/updates/__init__.py +24 -31
- goosebit/updates/swdesc.py +15 -8
- goosebit/users/__init__.py +63 -0
- goosebit/util/__init__.py +0 -0
- goosebit/util/path.py +42 -0
- goosebit/util/version.py +92 -0
- goosebit-0.2.6.dist-info/METADATA +280 -0
- goosebit-0.2.6.dist-info/RECORD +133 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
- goosebit/realtime/logs.py +0 -42
- goosebit/realtime/routes.py +0 -13
- goosebit/updater/manager.py +0 -325
- goosebit-0.2.5.dist-info/METADATA +0 -189
- goosebit-0.2.5.dist-info/RECORD +0 -99
- /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/LICENSE +0 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,111 @@
|
|
1
|
+
import shutil
|
2
|
+
from pathlib import Path
|
3
|
+
from typing import AsyncIterable
|
4
|
+
from urllib.parse import urlparse
|
5
|
+
|
6
|
+
import httpx
|
7
|
+
from anyio import Path as AnyioPath
|
8
|
+
from anyio import open_file
|
9
|
+
|
10
|
+
from .base import StorageProtocol
|
11
|
+
|
12
|
+
|
13
|
+
class FilesystemStorageBackend(StorageProtocol):
|
14
|
+
def __init__(self, base_path: Path):
|
15
|
+
self.base_path = Path(base_path)
|
16
|
+
self.base_path.mkdir(parents=True, exist_ok=True)
|
17
|
+
|
18
|
+
async def store_file(self, source_path: Path, dest_path: Path) -> str:
|
19
|
+
final_dest_path = self._validate_dest_path(dest_path)
|
20
|
+
final_dest_path.parent.mkdir(parents=True, exist_ok=True)
|
21
|
+
|
22
|
+
shutil.copy2(source_path, final_dest_path)
|
23
|
+
|
24
|
+
return final_dest_path.resolve().as_uri()
|
25
|
+
|
26
|
+
async def get_file_stream(self, uri: str) -> AsyncIterable[bytes]: # type: ignore[override]
|
27
|
+
parsed = urlparse(uri)
|
28
|
+
|
29
|
+
if parsed.scheme in ("http", "https"):
|
30
|
+
async with httpx.AsyncClient() as client:
|
31
|
+
async with client.stream("GET", uri) as response:
|
32
|
+
response.raise_for_status()
|
33
|
+
async for chunk in response.aiter_bytes(8192):
|
34
|
+
yield chunk
|
35
|
+
|
36
|
+
elif parsed.scheme == "file":
|
37
|
+
file_path = self._extract_path_from_uri(uri)
|
38
|
+
if not file_path.exists():
|
39
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
40
|
+
|
41
|
+
async with await open_file(file_path, "rb") as f:
|
42
|
+
while True:
|
43
|
+
chunk = await f.read(8192)
|
44
|
+
if not chunk:
|
45
|
+
break
|
46
|
+
yield chunk
|
47
|
+
else:
|
48
|
+
raise ValueError(f"Unsupported URI scheme '{parsed.scheme}' for filesystem backend: {uri}")
|
49
|
+
|
50
|
+
async def get_download_url(self, uri: str) -> str:
|
51
|
+
parsed = urlparse(uri)
|
52
|
+
|
53
|
+
if parsed.scheme in ("http", "https"):
|
54
|
+
return uri
|
55
|
+
|
56
|
+
elif parsed.scheme == "file":
|
57
|
+
file_path = self._extract_path_from_uri(uri)
|
58
|
+
if not file_path.exists():
|
59
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
60
|
+
|
61
|
+
return file_path.resolve().as_uri()
|
62
|
+
|
63
|
+
else:
|
64
|
+
raise ValueError(f"Unsupported URI scheme '{parsed.scheme}' for filesystem backend: {uri}")
|
65
|
+
|
66
|
+
def get_temp_dir(self) -> Path:
|
67
|
+
temp_dir = self.base_path / "tmp"
|
68
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
69
|
+
return temp_dir
|
70
|
+
|
71
|
+
async def delete_file(self, uri: str) -> bool:
|
72
|
+
parsed = urlparse(uri)
|
73
|
+
|
74
|
+
if parsed.scheme == "file":
|
75
|
+
file_path = self._extract_path_from_uri(uri)
|
76
|
+
if file_path.exists():
|
77
|
+
file_path.unlink()
|
78
|
+
return True
|
79
|
+
return False
|
80
|
+
else:
|
81
|
+
raise ValueError(f"Cannot delete remote file: {uri}")
|
82
|
+
|
83
|
+
def _extract_path_from_uri(self, uri: str) -> Path:
|
84
|
+
parsed = urlparse(uri)
|
85
|
+
|
86
|
+
if parsed.scheme != "file":
|
87
|
+
raise ValueError(f"Expected file:// URI, got: {uri}")
|
88
|
+
|
89
|
+
return Path(parsed.path)
|
90
|
+
|
91
|
+
def _validate_dest_path(self, dest_path: Path) -> Path:
|
92
|
+
if not isinstance(dest_path, (Path, AnyioPath)):
|
93
|
+
raise ValueError("Destination path must be a Path object")
|
94
|
+
|
95
|
+
if isinstance(dest_path, AnyioPath):
|
96
|
+
dest_path = Path(str(dest_path))
|
97
|
+
|
98
|
+
if dest_path.is_absolute():
|
99
|
+
raise ValueError("Destination path cannot be absolute")
|
100
|
+
|
101
|
+
final_dest_path = self.base_path / dest_path
|
102
|
+
|
103
|
+
resolved_dest = final_dest_path.resolve()
|
104
|
+
resolved_base = self.base_path.resolve()
|
105
|
+
|
106
|
+
try:
|
107
|
+
resolved_dest.relative_to(resolved_base)
|
108
|
+
except ValueError:
|
109
|
+
raise ValueError("Destination path contains invalid path traversal components")
|
110
|
+
|
111
|
+
return final_dest_path
|
goosebit/storage/s3.py
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
import asyncio
|
2
|
+
from pathlib import Path
|
3
|
+
from typing import AsyncIterable
|
4
|
+
from urllib.parse import urlparse
|
5
|
+
|
6
|
+
from boto3.session import Session
|
7
|
+
from botocore.config import Config
|
8
|
+
from botocore.exceptions import ClientError
|
9
|
+
|
10
|
+
from .base import StorageProtocol
|
11
|
+
|
12
|
+
|
13
|
+
class S3StorageBackend(StorageProtocol):
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
bucket: str,
|
17
|
+
region: str = "us-east-1",
|
18
|
+
endpoint_url: str | None = None,
|
19
|
+
access_key_id: str | None = None,
|
20
|
+
secret_access_key: str | None = None,
|
21
|
+
):
|
22
|
+
self.bucket = bucket
|
23
|
+
|
24
|
+
config = Config(
|
25
|
+
region_name=region,
|
26
|
+
connect_timeout=10,
|
27
|
+
read_timeout=60,
|
28
|
+
retries={"max_attempts": 5, "mode": "adaptive"},
|
29
|
+
signature_version="s3v4",
|
30
|
+
)
|
31
|
+
|
32
|
+
session_config = {}
|
33
|
+
if access_key_id is not None:
|
34
|
+
session_config["aws_access_key_id"] = access_key_id
|
35
|
+
if secret_access_key is not None:
|
36
|
+
session_config["aws_secret_access_key"] = secret_access_key
|
37
|
+
|
38
|
+
session = Session(**session_config)
|
39
|
+
|
40
|
+
self.s3_client = session.client("s3", config=config, endpoint_url=endpoint_url)
|
41
|
+
|
42
|
+
async def store_file(self, source_path: Path, dest_path: Path) -> str:
|
43
|
+
key = str(dest_path).replace("\\", "/").lstrip("/") # Convert path to S3 key
|
44
|
+
|
45
|
+
try:
|
46
|
+
loop = asyncio.get_event_loop()
|
47
|
+
await loop.run_in_executor(None, self.s3_client.upload_file, str(source_path), self.bucket, key)
|
48
|
+
return f"s3://{self.bucket}/{key}"
|
49
|
+
except ClientError as e:
|
50
|
+
raise ValueError(f"S3 upload failed: {e}")
|
51
|
+
|
52
|
+
async def get_file_stream(self, uri: str) -> AsyncIterable[bytes]: # type: ignore[override]
|
53
|
+
key = self._extract_key_from_uri(uri)
|
54
|
+
|
55
|
+
try:
|
56
|
+
loop = asyncio.get_running_loop()
|
57
|
+
response = await loop.run_in_executor(None, lambda: self.s3_client.get_object(Bucket=self.bucket, Key=key))
|
58
|
+
|
59
|
+
body = response["Body"]
|
60
|
+
try:
|
61
|
+
while True:
|
62
|
+
chunk = await loop.run_in_executor(None, body.read, 8192)
|
63
|
+
if not chunk:
|
64
|
+
break
|
65
|
+
yield chunk
|
66
|
+
finally:
|
67
|
+
await loop.run_in_executor(None, body.close)
|
68
|
+
|
69
|
+
except ClientError as e:
|
70
|
+
raise ValueError(f"S3 download failed: {e}")
|
71
|
+
|
72
|
+
async def get_download_url(self, uri: str) -> str:
|
73
|
+
parsed = urlparse(uri)
|
74
|
+
if parsed.scheme in ("http", "https"):
|
75
|
+
return uri
|
76
|
+
else:
|
77
|
+
raise ValueError(f"Fallback to streaming as S3 service might not be exposed externally: {uri}")
|
78
|
+
|
79
|
+
def get_temp_dir(self) -> Path:
|
80
|
+
import tempfile
|
81
|
+
|
82
|
+
temp_dir = Path(tempfile.gettempdir()).joinpath("goosebit-s3-temp")
|
83
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
84
|
+
return temp_dir
|
85
|
+
|
86
|
+
async def delete_file(self, uri: str) -> bool:
|
87
|
+
parsed = urlparse(uri)
|
88
|
+
if parsed.scheme != "s3":
|
89
|
+
raise ValueError(f"Cannot delete remote file: {uri}")
|
90
|
+
|
91
|
+
key = self._extract_key_from_uri(uri)
|
92
|
+
|
93
|
+
try:
|
94
|
+
loop = asyncio.get_event_loop()
|
95
|
+
await loop.run_in_executor(None, lambda: self.s3_client.delete_object(Bucket=self.bucket, Key=key))
|
96
|
+
return True
|
97
|
+
except ClientError as e:
|
98
|
+
raise ValueError(f"S3 delete failed: {e}")
|
99
|
+
|
100
|
+
def _extract_key_from_uri(self, uri: str) -> str:
|
101
|
+
if not uri.startswith(f"s3://{self.bucket}/"):
|
102
|
+
raise ValueError(f"Invalid S3 URI for bucket {self.bucket}: {uri}")
|
103
|
+
|
104
|
+
return uri.replace(f"s3://{self.bucket}/", "")
|
@@ -0,0 +1,50 @@
|
|
1
|
+
from .responses import DTColumnDescription
|
2
|
+
|
3
|
+
|
4
|
+
class DeviceColumns:
|
5
|
+
id = DTColumnDescription(title="ID", data="id", name="id", searchable=True, orderable=True)
|
6
|
+
name = DTColumnDescription(title="Name", data="name", name="name", searchable=True, orderable=True)
|
7
|
+
hw_model = DTColumnDescription(title="Model", data="hw_model")
|
8
|
+
hw_revision = DTColumnDescription(title="Revision", data="hw_revision")
|
9
|
+
feed = DTColumnDescription(title="Feed", data="feed", name="feed", searchable=True, orderable=True)
|
10
|
+
sw_version = DTColumnDescription(
|
11
|
+
title="Installed Software", data="sw_version", name="sw_version", searchable=True, orderable=True
|
12
|
+
)
|
13
|
+
sw_target_version = DTColumnDescription(title="Target Software", data="sw_target_version")
|
14
|
+
update_mode = DTColumnDescription(
|
15
|
+
title="Update Mode", data="update_mode", name="update_mode", searchable=True, orderable=True
|
16
|
+
)
|
17
|
+
last_state = DTColumnDescription(
|
18
|
+
title="State", data="last_state", name="last_state", searchable=True, orderable=True
|
19
|
+
)
|
20
|
+
force_update = DTColumnDescription(title="Force Update", data="force_update")
|
21
|
+
progress = DTColumnDescription(title="Progress", data="progress")
|
22
|
+
last_ip = DTColumnDescription(title="Last IP", data="last_ip")
|
23
|
+
polling = DTColumnDescription(title="Polling", data="polling")
|
24
|
+
last_seen = DTColumnDescription(title="Last Seen", data="last_seen")
|
25
|
+
|
26
|
+
|
27
|
+
class RolloutColumns:
|
28
|
+
id = DTColumnDescription(title="ID", data="id", visible=False)
|
29
|
+
created_at = DTColumnDescription(title="Created", data="created_at", name="created_at", orderable=True)
|
30
|
+
name = DTColumnDescription(title="Name", data="name", name="name", searchable=True, orderable=True)
|
31
|
+
feed = DTColumnDescription(title="Feed", data="feed", name="feed", searchable=True, orderable=True)
|
32
|
+
sw_file = DTColumnDescription(title="Software File", data="sw_file", name="sw_file")
|
33
|
+
sw_version = DTColumnDescription(title="Software Version", data="sw_version", name="sw_version")
|
34
|
+
paused = DTColumnDescription(title="Paused", name="paused", data="paused")
|
35
|
+
success_count = DTColumnDescription(title="Success Count", data="success_count", name="success_count")
|
36
|
+
failure_count = DTColumnDescription(title="Failure Count", data="failure_count", name="failure_count")
|
37
|
+
|
38
|
+
|
39
|
+
class SoftwareColumns:
|
40
|
+
id = DTColumnDescription(title="ID", data="id", visible=False)
|
41
|
+
name = DTColumnDescription(title="Name", data="name", name="name")
|
42
|
+
version = DTColumnDescription(title="Version", data="version", name="version", searchable=True, orderable=True)
|
43
|
+
compatibility = DTColumnDescription(title="Compatibility", name="compatibility", data="compatibility")
|
44
|
+
size = DTColumnDescription(title="Size", name="size", data="size")
|
45
|
+
|
46
|
+
|
47
|
+
class SettingsUsersColumns:
|
48
|
+
username = DTColumnDescription(title="Username", data="username", searchable=True, orderable=True)
|
49
|
+
enabled = DTColumnDescription(title="Enabled", data="enabled")
|
50
|
+
permissions = DTColumnDescription(title="Permissions", data="permissions")
|
@@ -0,0 +1 @@
|
|
1
|
+
from .routes import router # noqa : F401
|
@@ -0,0 +1,17 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from fastapi import APIRouter, Security
|
4
|
+
|
5
|
+
from goosebit.api.v1.devices.device import routes
|
6
|
+
from goosebit.auth import validate_user_permissions
|
7
|
+
from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
|
8
|
+
|
9
|
+
router = APIRouter(prefix="/{dev_id}")
|
10
|
+
|
11
|
+
router.add_api_route(
|
12
|
+
"/log",
|
13
|
+
routes.device_logs,
|
14
|
+
methods=["GET"],
|
15
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
|
16
|
+
name="bff_device_logs",
|
17
|
+
)
|
@@ -3,36 +3,39 @@ from __future__ import annotations
|
|
3
3
|
import asyncio
|
4
4
|
from typing import Annotated
|
5
5
|
|
6
|
-
from fastapi import APIRouter, Depends, Security
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Security
|
7
7
|
from fastapi.requests import Request
|
8
8
|
from tortoise.expressions import Q
|
9
9
|
|
10
10
|
from goosebit.api.responses import StatusResponse
|
11
11
|
from goosebit.api.v1.devices import routes
|
12
12
|
from goosebit.auth import validate_user_permissions
|
13
|
+
from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
|
13
14
|
from goosebit.db.models import Device, Software, UpdateModeEnum, UpdateStateEnum
|
15
|
+
from goosebit.device_manager import DeviceManager, get_device
|
14
16
|
from goosebit.schema.devices import DeviceSchema
|
15
17
|
from goosebit.schema.software import SoftwareSchema
|
16
|
-
from goosebit.settings import config
|
17
18
|
from goosebit.ui.bff.common.requests import DataTableRequest
|
18
19
|
from goosebit.ui.bff.common.util import parse_datatables_query
|
19
|
-
from goosebit.updater.manager import get_update_manager
|
20
20
|
|
21
|
-
from ..common.
|
21
|
+
from ..common.columns import DeviceColumns
|
22
|
+
from ..common.responses import DTColumns
|
23
|
+
from . import device
|
22
24
|
from .requests import DevicesPatchRequest
|
23
25
|
from .responses import BFFDeviceResponse
|
24
26
|
|
25
27
|
router = APIRouter(prefix="/devices")
|
28
|
+
router.include_router(device.router)
|
26
29
|
|
27
30
|
|
28
31
|
@router.get(
|
29
32
|
"",
|
30
|
-
dependencies=[Security(validate_user_permissions, scopes=["device
|
33
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
|
31
34
|
)
|
32
35
|
async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse:
|
33
36
|
def search_filter(search_value: str):
|
34
37
|
return (
|
35
|
-
Q(
|
38
|
+
Q(id__icontains=search_value)
|
36
39
|
| Q(name__icontains=search_value)
|
37
40
|
| Q(feed__icontains=search_value)
|
38
41
|
| Q(sw_version__icontains=search_value)
|
@@ -45,8 +48,8 @@ async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datata
|
|
45
48
|
response = await BFFDeviceResponse.convert(dt_query, query, search_filter)
|
46
49
|
|
47
50
|
async def set_assigned_sw(d: DeviceSchema):
|
48
|
-
|
49
|
-
_, target = await
|
51
|
+
device = await get_device(d.id)
|
52
|
+
_, target = await DeviceManager.get_update(device)
|
50
53
|
if target is not None:
|
51
54
|
await target.fetch_related("compatibility")
|
52
55
|
d.assigned_software = SoftwareSchema.model_validate(target)
|
@@ -58,27 +61,31 @@ async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datata
|
|
58
61
|
|
59
62
|
@router.patch(
|
60
63
|
"",
|
61
|
-
dependencies=[Security(validate_user_permissions, scopes=["device
|
64
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])],
|
62
65
|
)
|
63
66
|
async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse:
|
64
|
-
for
|
65
|
-
|
67
|
+
for dev_id in config.devices:
|
68
|
+
if await Device.get_or_none(id=dev_id) is None:
|
69
|
+
raise HTTPException(404, f"Device with ID {dev_id} not found")
|
70
|
+
device = await get_device(dev_id)
|
71
|
+
if config.feed is not None:
|
72
|
+
await DeviceManager.update_feed(device, config.feed)
|
66
73
|
if config.software is not None:
|
67
74
|
if config.software == "rollout":
|
68
|
-
await
|
75
|
+
await DeviceManager.update_update(device, UpdateModeEnum.ROLLOUT, None)
|
69
76
|
elif config.software == "latest":
|
70
|
-
await
|
77
|
+
await DeviceManager.update_update(device, UpdateModeEnum.LATEST, None)
|
71
78
|
else:
|
72
79
|
software = await Software.get_or_none(id=config.software)
|
73
|
-
await
|
80
|
+
await DeviceManager.update_update(device, UpdateModeEnum.ASSIGNED, software)
|
74
81
|
if config.pinned is not None:
|
75
|
-
await
|
82
|
+
await DeviceManager.update_update(device, UpdateModeEnum.PINNED, None)
|
76
83
|
if config.name is not None:
|
77
|
-
await
|
78
|
-
if config.feed is not None:
|
79
|
-
await updater.update_feed(config.feed)
|
84
|
+
await DeviceManager.update_name(device, config.name)
|
80
85
|
if config.force_update is not None:
|
81
|
-
await
|
86
|
+
await DeviceManager.update_force_update(device, config.force_update)
|
87
|
+
if config.auth_token is not None:
|
88
|
+
await DeviceManager.update_auth_token(device, config.auth_token)
|
82
89
|
return StatusResponse(success=True)
|
83
90
|
|
84
91
|
|
@@ -86,41 +93,37 @@ router.add_api_route(
|
|
86
93
|
"",
|
87
94
|
routes.devices_delete,
|
88
95
|
methods=["DELETE"],
|
89
|
-
dependencies=[Security(validate_user_permissions, scopes=["device
|
96
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["delete"]()])],
|
90
97
|
name="bff_devices_delete",
|
91
98
|
)
|
92
99
|
|
93
100
|
|
94
101
|
@router.get(
|
95
102
|
"/columns",
|
96
|
-
dependencies=[Security(validate_user_permissions, scopes=["device
|
103
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
|
97
104
|
response_model_exclude_none=True,
|
98
105
|
)
|
99
|
-
async def devices_get_columns() -> DTColumns:
|
100
|
-
|
101
|
-
columns
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
106
|
+
async def devices_get_columns(request: Request) -> DTColumns:
|
107
|
+
config = request.scope["config"]
|
108
|
+
columns = list(
|
109
|
+
filter(
|
110
|
+
None,
|
111
|
+
[
|
112
|
+
DeviceColumns.id,
|
113
|
+
DeviceColumns.name,
|
114
|
+
DeviceColumns.hw_model,
|
115
|
+
DeviceColumns.hw_revision,
|
116
|
+
DeviceColumns.feed,
|
117
|
+
DeviceColumns.sw_version,
|
118
|
+
DeviceColumns.sw_target_version,
|
119
|
+
DeviceColumns.update_mode,
|
120
|
+
DeviceColumns.last_state,
|
121
|
+
DeviceColumns.force_update,
|
122
|
+
DeviceColumns.progress,
|
123
|
+
DeviceColumns.last_ip if config.track_device_ip else None,
|
124
|
+
DeviceColumns.polling,
|
125
|
+
DeviceColumns.last_seen,
|
126
|
+
],
|
110
127
|
)
|
111
128
|
)
|
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
129
|
return DTColumns(columns=columns)
|
@@ -1,8 +1,9 @@
|
|
1
1
|
from fastapi import APIRouter, HTTPException
|
2
2
|
from fastapi.requests import Request
|
3
|
-
from fastapi.responses import FileResponse, RedirectResponse
|
3
|
+
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
4
4
|
|
5
5
|
from goosebit.db.models import Software
|
6
|
+
from goosebit.storage import storage
|
6
7
|
|
7
8
|
router = APIRouter(prefix="/download", tags=["download"])
|
8
9
|
|
@@ -18,5 +19,15 @@ async def download_file(_: Request, file_id: int):
|
|
18
19
|
media_type="application/octet-stream",
|
19
20
|
filename=software.path.name,
|
20
21
|
)
|
21
|
-
|
22
|
-
|
22
|
+
|
23
|
+
try:
|
24
|
+
url = await storage.get_download_url(software.uri)
|
25
|
+
return RedirectResponse(url=url)
|
26
|
+
except ValueError:
|
27
|
+
# Fallback to streaming if redirect fails.
|
28
|
+
file_stream = storage.get_file_stream(software.uri)
|
29
|
+
return StreamingResponse(
|
30
|
+
file_stream,
|
31
|
+
media_type="application/octet-stream",
|
32
|
+
headers={"Content-Disposition": f"attachment; filename={software.path.name}"},
|
33
|
+
)
|
@@ -5,10 +5,13 @@ from tortoise.expressions import Q
|
|
5
5
|
|
6
6
|
from goosebit.api.v1.rollouts import routes
|
7
7
|
from goosebit.auth import validate_user_permissions
|
8
|
+
from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
|
8
9
|
from goosebit.db.models import Rollout
|
9
10
|
from goosebit.ui.bff.common.requests import DataTableRequest
|
10
11
|
from goosebit.ui.bff.common.util import parse_datatables_query
|
11
12
|
|
13
|
+
from ..common.columns import RolloutColumns
|
14
|
+
from ..common.responses import DTColumns
|
12
15
|
from .responses import BFFRolloutsResponse
|
13
16
|
|
14
17
|
router = APIRouter(prefix="/rollouts")
|
@@ -16,7 +19,7 @@ router = APIRouter(prefix="/rollouts")
|
|
16
19
|
|
17
20
|
@router.get(
|
18
21
|
"",
|
19
|
-
dependencies=[Security(validate_user_permissions, scopes=["rollout
|
22
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])],
|
20
23
|
)
|
21
24
|
async def rollouts_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFRolloutsResponse:
|
22
25
|
def search_filter(search_value):
|
@@ -31,7 +34,7 @@ router.add_api_route(
|
|
31
34
|
"",
|
32
35
|
routes.rollouts_put,
|
33
36
|
methods=["POST"],
|
34
|
-
dependencies=[Security(validate_user_permissions, scopes=["rollout
|
37
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])],
|
35
38
|
name="bff_rollouts_post",
|
36
39
|
)
|
37
40
|
|
@@ -40,7 +43,7 @@ router.add_api_route(
|
|
40
43
|
"",
|
41
44
|
routes.rollouts_patch,
|
42
45
|
methods=["PATCH"],
|
43
|
-
dependencies=[Security(validate_user_permissions, scopes=["rollout
|
46
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])],
|
44
47
|
name="bff_rollouts_patch",
|
45
48
|
)
|
46
49
|
|
@@ -49,6 +52,31 @@ router.add_api_route(
|
|
49
52
|
"",
|
50
53
|
routes.rollouts_delete,
|
51
54
|
methods=["DELETE"],
|
52
|
-
dependencies=[Security(validate_user_permissions, scopes=["rollout
|
55
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["delete"]()])],
|
53
56
|
name="bff_rollouts_delete",
|
54
57
|
)
|
58
|
+
|
59
|
+
|
60
|
+
@router.get(
|
61
|
+
"/columns",
|
62
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])],
|
63
|
+
response_model_exclude_none=True,
|
64
|
+
)
|
65
|
+
async def devices_get_columns() -> DTColumns:
|
66
|
+
columns = list(
|
67
|
+
filter(
|
68
|
+
None,
|
69
|
+
[
|
70
|
+
RolloutColumns.id,
|
71
|
+
RolloutColumns.created_at,
|
72
|
+
RolloutColumns.name,
|
73
|
+
RolloutColumns.feed,
|
74
|
+
RolloutColumns.sw_file,
|
75
|
+
RolloutColumns.sw_version,
|
76
|
+
RolloutColumns.paused,
|
77
|
+
RolloutColumns.success_count,
|
78
|
+
RolloutColumns.failure_count,
|
79
|
+
],
|
80
|
+
)
|
81
|
+
)
|
82
|
+
return DTColumns(columns=columns)
|
goosebit/ui/bff/routes.py
CHANGED
@@ -4,10 +4,11 @@ from fastapi import APIRouter, Depends
|
|
4
4
|
|
5
5
|
from goosebit.auth import validate_current_user
|
6
6
|
|
7
|
-
from . import devices, download, rollouts, software
|
7
|
+
from . import devices, download, rollouts, settings, software
|
8
8
|
|
9
9
|
router = APIRouter(prefix="/bff", tags=["bff"], dependencies=[Depends(validate_current_user)])
|
10
10
|
router.include_router(devices.router)
|
11
11
|
router.include_router(software.router)
|
12
12
|
router.include_router(rollouts.router)
|
13
13
|
router.include_router(download.router)
|
14
|
+
router.include_router(settings.router)
|
@@ -0,0 +1 @@
|
|
1
|
+
from .routes import router # noqa: F401
|
@@ -0,0 +1,20 @@
|
|
1
|
+
from fastapi import APIRouter, Security
|
2
|
+
|
3
|
+
from goosebit.api.v1.settings import routes
|
4
|
+
from goosebit.auth import validate_user_permissions
|
5
|
+
from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
|
6
|
+
|
7
|
+
from . import users
|
8
|
+
|
9
|
+
router = APIRouter(prefix="/settings")
|
10
|
+
|
11
|
+
router.include_router(users.router)
|
12
|
+
|
13
|
+
router.add_api_route(
|
14
|
+
"/permissions",
|
15
|
+
routes.settings_permissions_get,
|
16
|
+
methods=["GET"],
|
17
|
+
dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])],
|
18
|
+
name="bff_settings_permissions_get",
|
19
|
+
response_model_exclude_none=True,
|
20
|
+
)
|
@@ -0,0 +1 @@
|
|
1
|
+
from .routes import router # noqa: F401
|
@@ -0,0 +1,33 @@
|
|
1
|
+
from typing import Callable
|
2
|
+
|
3
|
+
from pydantic import BaseModel, Field
|
4
|
+
from tortoise.queryset import QuerySet
|
5
|
+
|
6
|
+
from goosebit.schema.users import UserSchema
|
7
|
+
from goosebit.ui.bff.common.requests import DataTableRequest
|
8
|
+
|
9
|
+
|
10
|
+
class BFFSettingsUsersResponse(BaseModel):
|
11
|
+
data: list[UserSchema]
|
12
|
+
draw: int
|
13
|
+
records_total: int = Field(serialization_alias="recordsTotal")
|
14
|
+
records_filtered: int = Field(serialization_alias="recordsFiltered")
|
15
|
+
|
16
|
+
@classmethod
|
17
|
+
async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable):
|
18
|
+
total_records = await query.count()
|
19
|
+
if dt_query.search.value:
|
20
|
+
query = query.filter(search_filter(dt_query.search.value))
|
21
|
+
|
22
|
+
filtered_records = await query.count()
|
23
|
+
|
24
|
+
if dt_query.order_query:
|
25
|
+
query = query.order_by(dt_query.order_query)
|
26
|
+
|
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()
|
31
|
+
data = [UserSchema.model_validate(r) for r in rollouts]
|
32
|
+
|
33
|
+
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
|