goosebit 0.2.4__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 (96) hide show
  1. goosebit/__init__.py +56 -6
  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 +83 -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 +54 -14
  17. goosebit/auth/permissions.py +80 -0
  18. goosebit/db/config.py +57 -1
  19. goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  20. goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
  21. goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
  22. goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
  23. goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
  24. goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
  25. goosebit/db/models.py +22 -7
  26. goosebit/db/pg_ssl_context.py +51 -0
  27. goosebit/device_manager.py +262 -0
  28. goosebit/plugins/__init__.py +32 -0
  29. goosebit/schema/devices.py +9 -6
  30. goosebit/schema/plugins.py +67 -0
  31. goosebit/schema/updates.py +15 -0
  32. goosebit/schema/users.py +9 -0
  33. goosebit/settings/__init__.py +0 -3
  34. goosebit/settings/schema.py +62 -14
  35. goosebit/storage/__init__.py +62 -0
  36. goosebit/storage/base.py +14 -0
  37. goosebit/storage/filesystem.py +111 -0
  38. goosebit/storage/s3.py +104 -0
  39. goosebit/ui/bff/common/columns.py +50 -0
  40. goosebit/ui/bff/common/requests.py +3 -15
  41. goosebit/ui/bff/common/responses.py +17 -0
  42. goosebit/ui/bff/devices/device/__init__.py +1 -0
  43. goosebit/ui/bff/devices/device/routes.py +17 -0
  44. goosebit/ui/bff/devices/requests.py +1 -0
  45. goosebit/ui/bff/devices/responses.py +6 -2
  46. goosebit/ui/bff/devices/routes.py +71 -17
  47. goosebit/ui/bff/download/routes.py +14 -3
  48. goosebit/ui/bff/rollouts/responses.py +6 -2
  49. goosebit/ui/bff/rollouts/routes.py +32 -4
  50. goosebit/ui/bff/routes.py +6 -3
  51. goosebit/ui/bff/settings/__init__.py +1 -0
  52. goosebit/ui/bff/settings/routes.py +20 -0
  53. goosebit/ui/bff/settings/users/__init__.py +1 -0
  54. goosebit/ui/bff/settings/users/responses.py +33 -0
  55. goosebit/ui/bff/settings/users/routes.py +80 -0
  56. goosebit/ui/bff/software/responses.py +19 -9
  57. goosebit/ui/bff/software/routes.py +40 -12
  58. goosebit/ui/nav.py +12 -2
  59. goosebit/ui/routes.py +70 -26
  60. goosebit/ui/static/js/devices.js +72 -80
  61. goosebit/ui/static/js/login.js +21 -5
  62. goosebit/ui/static/js/logs.js +7 -22
  63. goosebit/ui/static/js/rollouts.js +39 -35
  64. goosebit/ui/static/js/settings.js +322 -0
  65. goosebit/ui/static/js/setup.js +28 -0
  66. goosebit/ui/static/js/software.js +127 -127
  67. goosebit/ui/static/js/util.js +45 -4
  68. goosebit/ui/templates/__init__.py +10 -1
  69. goosebit/ui/templates/devices.html.jinja +0 -20
  70. goosebit/ui/templates/login.html.jinja +5 -0
  71. goosebit/ui/templates/nav.html.jinja +26 -7
  72. goosebit/ui/templates/rollouts.html.jinja +4 -22
  73. goosebit/ui/templates/settings.html.jinja +88 -0
  74. goosebit/ui/templates/setup.html.jinja +71 -0
  75. goosebit/ui/templates/software.html.jinja +0 -11
  76. goosebit/updater/controller/v1/routes.py +120 -72
  77. goosebit/updater/routes.py +86 -7
  78. goosebit/updates/__init__.py +24 -31
  79. goosebit/updates/swdesc.py +15 -8
  80. goosebit/users/__init__.py +63 -0
  81. goosebit/util/__init__.py +0 -0
  82. goosebit/util/path.py +42 -0
  83. goosebit/util/version.py +92 -0
  84. goosebit-0.2.6.dist-info/METADATA +280 -0
  85. goosebit-0.2.6.dist-info/RECORD +133 -0
  86. {goosebit-0.2.4.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
  87. goosebit-0.2.6.dist-info/entry_points.txt +3 -0
  88. goosebit/realtime/logs.py +0 -42
  89. goosebit/realtime/routes.py +0 -13
  90. goosebit/ui/static/js/index.js +0 -155
  91. goosebit/ui/templates/index.html.jinja +0 -25
  92. goosebit/updater/manager.py +0 -357
  93. goosebit-0.2.4.dist-info/METADATA +0 -181
  94. goosebit-0.2.4.dist-info/RECORD +0 -98
  95. /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
  96. {goosebit-0.2.4.dist-info → goosebit-0.2.6.dist-info}/LICENSE +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,14 +10,6 @@ class DataTableSearchSchema(BaseModel):
10
10
  regex: bool | None = False
11
11
 
12
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
13
  class DataTableOrderDirection(StrEnum):
22
14
  ASCENDING = "asc"
23
15
  DESCENDING = "desc"
@@ -36,21 +28,17 @@ class DataTableOrderSchema(BaseModel):
36
28
 
37
29
  class DataTableRequest(BaseModel):
38
30
  draw: int = 1
39
- columns: list[DataTableColumnSchema] = list()
40
31
  order: list[DataTableOrderSchema] = list()
41
32
  start: int = 0
42
- length: int = 0
33
+ length: int | None = None
43
34
  search: DataTableSearchSchema = DataTableSearchSchema()
44
35
 
45
36
  @computed_field # type: ignore[misc]
46
37
  @property
47
38
  def order_query(self) -> str | None:
48
39
  try:
49
- column = self.order[0].column
50
- if column is None:
51
- return None
52
- if self.columns[column].name is None:
40
+ if len(self.order) == 0 or self.order[0].direction is None or self.order[0].name is None:
53
41
  return None
54
- return f"{self.order[0].direction}{self.columns[column].data}"
42
+ return f"{self.order[0].direction}{self.order[0].name}"
55
43
  except LookupError:
56
44
  return None
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class DTColumnDescription(BaseModel):
7
+ title: str
8
+ data: str
9
+ name: str | None = None
10
+
11
+ searchable: bool | None = None
12
+ orderable: bool | None = None
13
+ visible: bool | None = None
14
+
15
+
16
+ class DTColumns(BaseModel):
17
+ columns: list[DTColumnDescription]
@@ -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
@@ -21,11 +21,15 @@ class BFFDeviceResponse(BaseModel):
21
21
  if dt_query.search.value:
22
22
  query = query.filter(search_filter(dt_query.search.value))
23
23
 
24
+ filtered_records = await query.count()
25
+
24
26
  if dt_query.order_query:
25
27
  query = query.order_by(dt_query.order_query)
26
28
 
27
- filtered_records = await query.count()
28
- devices = await query.offset(dt_query.start).limit(dt_query.length).all()
29
+ if dt_query.length is not None:
30
+ query = query.limit(dt_query.length)
31
+
32
+ devices = await query.offset(dt_query.start).all()
29
33
  data = [DeviceSchema.model_validate(d) for d in devices]
30
34
 
31
35
  return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
@@ -1,33 +1,41 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  from typing import Annotated
4
5
 
5
- from fastapi import APIRouter, Depends, Security
6
+ from fastapi import APIRouter, Depends, HTTPException, Security
6
7
  from fastapi.requests import Request
7
8
  from tortoise.expressions import Q
8
9
 
9
10
  from goosebit.api.responses import StatusResponse
10
11
  from goosebit.api.v1.devices import routes
11
12
  from goosebit.auth import validate_user_permissions
13
+ from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
12
14
  from goosebit.db.models import Device, Software, UpdateModeEnum, UpdateStateEnum
15
+ from goosebit.device_manager import DeviceManager, get_device
16
+ from goosebit.schema.devices import DeviceSchema
17
+ from goosebit.schema.software import SoftwareSchema
13
18
  from goosebit.ui.bff.common.requests import DataTableRequest
14
19
  from goosebit.ui.bff.common.util import parse_datatables_query
15
- from goosebit.updater.manager import get_update_manager
16
20
 
21
+ from ..common.columns import DeviceColumns
22
+ from ..common.responses import DTColumns
23
+ from . import device
17
24
  from .requests import DevicesPatchRequest
18
25
  from .responses import BFFDeviceResponse
19
26
 
20
27
  router = APIRouter(prefix="/devices")
28
+ router.include_router(device.router)
21
29
 
22
30
 
23
31
  @router.get(
24
32
  "",
25
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
33
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
26
34
  )
27
35
  async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse:
28
36
  def search_filter(search_value: str):
29
37
  return (
30
- Q(uuid__icontains=search_value)
38
+ Q(id__icontains=search_value)
31
39
  | Q(name__icontains=search_value)
32
40
  | Q(feed__icontains=search_value)
33
41
  | Q(sw_version__icontains=search_value)
@@ -37,32 +45,47 @@ async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datata
37
45
 
38
46
  query = Device.all().prefetch_related("assigned_software", "hardware", "assigned_software__compatibility")
39
47
 
40
- return await BFFDeviceResponse.convert(dt_query, query, search_filter)
48
+ response = await BFFDeviceResponse.convert(dt_query, query, search_filter)
49
+
50
+ async def set_assigned_sw(d: DeviceSchema):
51
+ device = await get_device(d.id)
52
+ _, target = await DeviceManager.get_update(device)
53
+ if target is not None:
54
+ await target.fetch_related("compatibility")
55
+ d.assigned_software = SoftwareSchema.model_validate(target)
56
+ return d
57
+
58
+ response.data = await asyncio.gather(*[set_assigned_sw(d) for d in response.data])
59
+ return response
41
60
 
42
61
 
43
62
  @router.patch(
44
63
  "",
45
- dependencies=[Security(validate_user_permissions, scopes=["device.write"])],
64
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])],
46
65
  )
47
66
  async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse:
48
- for uuid in config.devices:
49
- 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)
50
73
  if config.software is not None:
51
74
  if config.software == "rollout":
52
- await updater.update_update(UpdateModeEnum.ROLLOUT, None)
75
+ await DeviceManager.update_update(device, UpdateModeEnum.ROLLOUT, None)
53
76
  elif config.software == "latest":
54
- await updater.update_update(UpdateModeEnum.LATEST, None)
77
+ await DeviceManager.update_update(device, UpdateModeEnum.LATEST, None)
55
78
  else:
56
79
  software = await Software.get_or_none(id=config.software)
57
- await updater.update_update(UpdateModeEnum.ASSIGNED, software)
80
+ await DeviceManager.update_update(device, UpdateModeEnum.ASSIGNED, software)
58
81
  if config.pinned is not None:
59
- await updater.update_update(UpdateModeEnum.PINNED, None)
82
+ await DeviceManager.update_update(device, UpdateModeEnum.PINNED, None)
60
83
  if config.name is not None:
61
- await updater.update_name(config.name)
62
- if config.feed is not None:
63
- await updater.update_feed(config.feed)
84
+ await DeviceManager.update_name(device, config.name)
64
85
  if config.force_update is not None:
65
- 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)
66
89
  return StatusResponse(success=True)
67
90
 
68
91
 
@@ -70,6 +93,37 @@ router.add_api_route(
70
93
  "",
71
94
  routes.devices_delete,
72
95
  methods=["DELETE"],
73
- dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
96
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["delete"]()])],
74
97
  name="bff_devices_delete",
75
98
  )
99
+
100
+
101
+ @router.get(
102
+ "/columns",
103
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
104
+ response_model_exclude_none=True,
105
+ )
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
+ ],
127
+ )
128
+ )
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
+ )
@@ -19,11 +19,15 @@ class BFFRolloutsResponse(BaseModel):
19
19
  if dt_query.search.value:
20
20
  query = query.filter(search_filter(dt_query.search.value))
21
21
 
22
+ filtered_records = await query.count()
23
+
22
24
  if dt_query.order_query:
23
25
  query = query.order_by(dt_query.order_query)
24
26
 
25
- filtered_records = await query.count()
26
- rollouts = await query.offset(dt_query.start).limit(dt_query.length).all()
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()
27
31
  data = [RolloutSchema.model_validate(r) for r in rollouts]
28
32
 
29
33
  return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
@@ -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
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- from fastapi import APIRouter
3
+ from fastapi import APIRouter, Depends
4
4
 
5
- from . import devices, download, rollouts, software
5
+ from goosebit.auth import validate_current_user
6
6
 
7
- router = APIRouter(prefix="/bff", tags=["bff"])
7
+ from . import devices, download, rollouts, settings, software
8
+
9
+ router = APIRouter(prefix="/bff", tags=["bff"], dependencies=[Depends(validate_current_user)])
8
10
  router.include_router(devices.router)
9
11
  router.include_router(software.router)
10
12
  router.include_router(rollouts.router)
11
13
  router.include_router(download.router)
14
+ router.include_router(settings.router)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401