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.
Files changed (88) hide show
  1. goosebit/__init__.py +41 -7
  2. goosebit/api/telemetry/metrics.py +1 -5
  3. goosebit/api/v1/devices/device/responses.py +1 -0
  4. goosebit/api/v1/devices/device/routes.py +8 -8
  5. goosebit/api/v1/devices/requests.py +20 -0
  6. goosebit/api/v1/devices/routes.py +68 -8
  7. goosebit/api/v1/download/routes.py +14 -3
  8. goosebit/api/v1/rollouts/routes.py +5 -4
  9. goosebit/api/v1/routes.py +2 -1
  10. goosebit/api/v1/settings/routes.py +14 -0
  11. goosebit/api/v1/settings/users/__init__.py +1 -0
  12. goosebit/api/v1/settings/users/requests.py +16 -0
  13. goosebit/api/v1/settings/users/responses.py +7 -0
  14. goosebit/api/v1/settings/users/routes.py +56 -0
  15. goosebit/api/v1/software/routes.py +18 -14
  16. goosebit/auth/__init__.py +49 -13
  17. goosebit/auth/permissions.py +80 -0
  18. goosebit/db/config.py +57 -1
  19. goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
  20. goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
  21. goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
  22. goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
  23. goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
  24. goosebit/db/models.py +19 -8
  25. goosebit/db/pg_ssl_context.py +51 -0
  26. goosebit/device_manager.py +262 -0
  27. goosebit/plugins/__init__.py +32 -0
  28. goosebit/schema/devices.py +8 -5
  29. goosebit/schema/plugins.py +67 -0
  30. goosebit/schema/updates.py +15 -0
  31. goosebit/schema/users.py +9 -0
  32. goosebit/settings/__init__.py +0 -3
  33. goosebit/settings/schema.py +60 -14
  34. goosebit/storage/__init__.py +62 -0
  35. goosebit/storage/base.py +14 -0
  36. goosebit/storage/filesystem.py +111 -0
  37. goosebit/storage/s3.py +104 -0
  38. goosebit/ui/bff/common/columns.py +50 -0
  39. goosebit/ui/bff/common/responses.py +1 -0
  40. goosebit/ui/bff/devices/device/__init__.py +1 -0
  41. goosebit/ui/bff/devices/device/routes.py +17 -0
  42. goosebit/ui/bff/devices/requests.py +1 -0
  43. goosebit/ui/bff/devices/routes.py +49 -46
  44. goosebit/ui/bff/download/routes.py +14 -3
  45. goosebit/ui/bff/rollouts/routes.py +32 -4
  46. goosebit/ui/bff/routes.py +2 -1
  47. goosebit/ui/bff/settings/__init__.py +1 -0
  48. goosebit/ui/bff/settings/routes.py +20 -0
  49. goosebit/ui/bff/settings/users/__init__.py +1 -0
  50. goosebit/ui/bff/settings/users/responses.py +33 -0
  51. goosebit/ui/bff/settings/users/routes.py +80 -0
  52. goosebit/ui/bff/software/routes.py +40 -12
  53. goosebit/ui/nav.py +12 -2
  54. goosebit/ui/routes.py +66 -13
  55. goosebit/ui/static/js/devices.js +32 -24
  56. goosebit/ui/static/js/login.js +21 -5
  57. goosebit/ui/static/js/logs.js +7 -22
  58. goosebit/ui/static/js/rollouts.js +31 -30
  59. goosebit/ui/static/js/settings.js +322 -0
  60. goosebit/ui/static/js/setup.js +28 -0
  61. goosebit/ui/static/js/software.js +127 -121
  62. goosebit/ui/static/js/util.js +25 -4
  63. goosebit/ui/templates/__init__.py +10 -1
  64. goosebit/ui/templates/login.html.jinja +5 -0
  65. goosebit/ui/templates/nav.html.jinja +13 -5
  66. goosebit/ui/templates/rollouts.html.jinja +4 -22
  67. goosebit/ui/templates/settings.html.jinja +88 -0
  68. goosebit/ui/templates/setup.html.jinja +71 -0
  69. goosebit/ui/templates/software.html.jinja +0 -11
  70. goosebit/updater/controller/v1/routes.py +119 -77
  71. goosebit/updater/routes.py +83 -8
  72. goosebit/updates/__init__.py +24 -31
  73. goosebit/updates/swdesc.py +15 -8
  74. goosebit/users/__init__.py +63 -0
  75. goosebit/util/__init__.py +0 -0
  76. goosebit/util/path.py +42 -0
  77. goosebit/util/version.py +92 -0
  78. goosebit-0.2.6.dist-info/METADATA +280 -0
  79. goosebit-0.2.6.dist-info/RECORD +133 -0
  80. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
  81. goosebit/realtime/logs.py +0 -42
  82. goosebit/realtime/routes.py +0 -13
  83. goosebit/updater/manager.py +0 -325
  84. goosebit-0.2.5.dist-info/METADATA +0 -189
  85. goosebit-0.2.5.dist-info/RECORD +0 -99
  86. /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
  87. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/LICENSE +0 -0
  88. {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")
@@ -10,6 +10,7 @@ class DTColumnDescription(BaseModel):
10
10
 
11
11
  searchable: bool | None = None
12
12
  orderable: bool | None = None
13
+ visible: bool | None = None
13
14
 
14
15
 
15
16
  class DTColumns(BaseModel):
@@ -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
+ )
@@ -10,3 +10,4 @@ class DevicesPatchRequest(BaseModel):
10
10
  pinned: bool | None = None
11
11
  feed: str | None = None
12
12
  force_update: bool | None = None
13
+ auth_token: str | None = None
@@ -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.responses import DTColumnDescription, DTColumns
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.read"])],
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(uuid__icontains=search_value)
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
- updater = await get_update_manager(d.uuid)
49
- _, target = await updater.get_update()
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.write"])],
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 uuid in config.devices:
65
- updater = await get_update_manager(uuid)
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 updater.update_update(UpdateModeEnum.ROLLOUT, None)
75
+ await DeviceManager.update_update(device, UpdateModeEnum.ROLLOUT, None)
69
76
  elif config.software == "latest":
70
- await updater.update_update(UpdateModeEnum.LATEST, None)
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 updater.update_update(UpdateModeEnum.ASSIGNED, software)
80
+ await DeviceManager.update_update(device, UpdateModeEnum.ASSIGNED, software)
74
81
  if config.pinned is not None:
75
- await updater.update_update(UpdateModeEnum.PINNED, None)
82
+ await DeviceManager.update_update(device, UpdateModeEnum.PINNED, None)
76
83
  if config.name is not None:
77
- await updater.update_name(config.name)
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 updater.update_force_update(config.force_update)
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.delete"])],
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.read"])],
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
- columns = []
101
- columns.append(DTColumnDescription(title="Online", data="online"))
102
- columns.append(DTColumnDescription(title="UUID", data="uuid", name="uuid", searchable=True, orderable=True))
103
- columns.append(DTColumnDescription(title="Name", data="name", name="name", searchable=True, orderable=True))
104
- columns.append(DTColumnDescription(title="Model", data="hw_model"))
105
- columns.append(DTColumnDescription(title="Revision", data="hw_revision"))
106
- columns.append(DTColumnDescription(title="Feed", data="feed", name="feed", searchable=True, orderable=True))
107
- columns.append(
108
- DTColumnDescription(
109
- title="Installed Software", data="sw_version", name="sw_version", searchable=True, orderable=True
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
- else:
22
- return RedirectResponse(url=software.uri)
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.read"])],
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.write"])],
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.write"])],
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.delete"])],
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)