goosebit 0.1.2__py3-none-any.whl → 0.2.1__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 (105) hide show
  1. goosebit/__init__.py +50 -19
  2. goosebit/__main__.py +7 -0
  3. goosebit/api/responses.py +5 -0
  4. goosebit/api/routes.py +5 -15
  5. goosebit/api/telemetry/__init__.py +1 -0
  6. goosebit/{telemetry/__init__.py → api/telemetry/metrics.py} +9 -3
  7. goosebit/api/telemetry/prometheus/__init__.py +2 -0
  8. goosebit/api/telemetry/prometheus/readers.py +3 -0
  9. goosebit/api/telemetry/prometheus/routes.py +18 -0
  10. goosebit/api/telemetry/routes.py +9 -0
  11. goosebit/api/v1/__init__.py +1 -0
  12. goosebit/api/v1/devices/__init__.py +1 -0
  13. goosebit/api/v1/devices/device/__init__.py +1 -0
  14. goosebit/api/v1/devices/device/responses.py +13 -0
  15. goosebit/api/v1/devices/device/routes.py +27 -0
  16. goosebit/api/v1/devices/requests.py +7 -0
  17. goosebit/api/v1/devices/responses.py +16 -0
  18. goosebit/api/v1/devices/routes.py +35 -0
  19. goosebit/api/v1/download/__init__.py +1 -0
  20. goosebit/api/v1/download/routes.py +22 -0
  21. goosebit/api/v1/rollouts/__init__.py +1 -0
  22. goosebit/api/v1/rollouts/requests.py +16 -0
  23. goosebit/api/v1/rollouts/responses.py +19 -0
  24. goosebit/api/v1/rollouts/routes.py +50 -0
  25. goosebit/api/v1/routes.py +9 -0
  26. goosebit/api/v1/software/__init__.py +1 -0
  27. goosebit/api/v1/software/requests.py +5 -0
  28. goosebit/api/v1/software/responses.py +16 -0
  29. goosebit/api/v1/software/routes.py +77 -0
  30. goosebit/auth/__init__.py +101 -101
  31. goosebit/db/__init__.py +11 -0
  32. goosebit/db/config.py +10 -0
  33. goosebit/db/migrations/models/0_20240830054046_init.py +136 -0
  34. goosebit/{models.py → db/models.py} +17 -10
  35. goosebit/realtime/logs.py +4 -3
  36. goosebit/realtime/routes.py +2 -2
  37. goosebit/schema/__init__.py +0 -0
  38. goosebit/schema/devices.py +73 -0
  39. goosebit/schema/rollouts.py +31 -0
  40. goosebit/schema/software.py +37 -0
  41. goosebit/settings/__init__.py +17 -0
  42. goosebit/settings/const.py +21 -0
  43. goosebit/settings/schema.py +86 -0
  44. goosebit/ui/bff/__init__.py +1 -0
  45. goosebit/ui/bff/devices/__init__.py +1 -0
  46. goosebit/ui/bff/devices/requests.py +12 -0
  47. goosebit/ui/bff/devices/responses.py +39 -0
  48. goosebit/ui/bff/devices/routes.py +72 -0
  49. goosebit/ui/bff/download/__init__.py +1 -0
  50. goosebit/ui/bff/download/routes.py +22 -0
  51. goosebit/ui/bff/rollouts/__init__.py +1 -0
  52. goosebit/ui/bff/rollouts/responses.py +37 -0
  53. goosebit/ui/bff/rollouts/routes.py +52 -0
  54. goosebit/ui/bff/routes.py +11 -0
  55. goosebit/ui/bff/software/__init__.py +1 -0
  56. goosebit/ui/bff/software/responses.py +37 -0
  57. goosebit/ui/bff/software/routes.py +83 -0
  58. goosebit/ui/nav.py +16 -0
  59. goosebit/ui/routes.py +29 -66
  60. goosebit/ui/static/favicon.ico +0 -0
  61. goosebit/ui/static/favicon.svg +1 -1
  62. goosebit/ui/static/js/devices.js +47 -71
  63. goosebit/ui/static/js/index.js +4 -9
  64. goosebit/ui/static/js/login.js +23 -0
  65. goosebit/ui/static/js/logs.js +1 -1
  66. goosebit/ui/static/js/rollouts.js +33 -19
  67. goosebit/ui/static/js/{firmware.js → software.js} +87 -86
  68. goosebit/ui/static/js/util.js +60 -6
  69. goosebit/ui/static/svg/goosebit-logo.svg +1 -1
  70. goosebit/ui/templates/__init__.py +9 -1
  71. goosebit/ui/templates/devices.html.jinja +75 -0
  72. goosebit/ui/templates/index.html.jinja +25 -0
  73. goosebit/ui/templates/login.html.jinja +57 -0
  74. goosebit/ui/templates/logs.html.jinja +31 -0
  75. goosebit/ui/templates/nav.html.jinja +84 -0
  76. goosebit/ui/templates/rollouts.html.jinja +93 -0
  77. goosebit/ui/templates/software.html.jinja +139 -0
  78. goosebit/updater/controller/v1/routes.py +101 -96
  79. goosebit/updater/controller/v1/schema.py +56 -0
  80. goosebit/updater/manager.py +65 -65
  81. goosebit/updater/routes.py +3 -11
  82. goosebit/updates/__init__.py +91 -32
  83. goosebit/updates/swdesc.py +2 -7
  84. goosebit-0.2.1.dist-info/METADATA +173 -0
  85. goosebit-0.2.1.dist-info/RECORD +95 -0
  86. goosebit/api/devices.py +0 -136
  87. goosebit/api/download.py +0 -34
  88. goosebit/api/firmware.py +0 -57
  89. goosebit/api/helper.py +0 -30
  90. goosebit/api/rollouts.py +0 -87
  91. goosebit/db.py +0 -37
  92. goosebit/permissions.py +0 -75
  93. goosebit/settings.py +0 -64
  94. goosebit/telemetry/prometheus.py +0 -10
  95. goosebit/ui/templates/devices.html +0 -115
  96. goosebit/ui/templates/firmware.html +0 -163
  97. goosebit/ui/templates/index.html +0 -23
  98. goosebit/ui/templates/login.html +0 -65
  99. goosebit/ui/templates/logs.html +0 -36
  100. goosebit/ui/templates/nav.html +0 -117
  101. goosebit/ui/templates/rollouts.html +0 -76
  102. goosebit-0.1.2.dist-info/METADATA +0 -123
  103. goosebit-0.1.2.dist-info/RECORD +0 -51
  104. {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/LICENSE +0 -0
  105. {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,21 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from argon2 import PasswordHasher
5
+
6
+ GOOSEBIT_ROOT_DIR = Path(__file__).resolve().parent.parent.parent
7
+ CURRENT_DIR = Path(os.getcwd())
8
+
9
+ PWD_CXT = PasswordHasher()
10
+
11
+ LOGGING_DEFAULT = {
12
+ "version": 1,
13
+ "formatters": {"simple": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}},
14
+ "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "simple", "level": "DEBUG"}},
15
+ "loggers": {
16
+ "tortoise": {"handlers": ["console"], "level": "WARNING", "propagate": True},
17
+ "aiosqlite": {"handlers": ["console"], "level": "WARNING", "propagate": True},
18
+ "multipart": {"handlers": ["console"], "level": "INFO", "propagate": True},
19
+ },
20
+ "root": {"level": "INFO", "handlers": ["console"]},
21
+ }
@@ -0,0 +1,86 @@
1
+ import os
2
+ import secrets
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ from joserfc.rfc7518.oct_key import OctKey
7
+ from pydantic import BaseModel, BeforeValidator, Field
8
+ from pydantic_settings import (
9
+ BaseSettings,
10
+ PydanticBaseSettingsSource,
11
+ SettingsConfigDict,
12
+ YamlConfigSettingsSource,
13
+ )
14
+
15
+ from .const import CURRENT_DIR, GOOSEBIT_ROOT_DIR, LOGGING_DEFAULT, PWD_CXT
16
+
17
+
18
+ class User(BaseModel):
19
+ username: str
20
+ hashed_pwd: Annotated[str, BeforeValidator(PWD_CXT.hash)] = Field(validation_alias="password")
21
+ permissions: set[str]
22
+
23
+ def get_json_permissions(self):
24
+ return [str(p) for p in self.permissions]
25
+
26
+
27
+ class PrometheusSettings(BaseModel):
28
+ enable: bool = False
29
+
30
+
31
+ class MetricsSettings(BaseModel):
32
+ prometheus: PrometheusSettings = PrometheusSettings()
33
+
34
+
35
+ class GooseBitSettings(BaseSettings):
36
+ model_config = SettingsConfigDict(env_prefix="GOOSEBIT_")
37
+
38
+ port: int = 60053 # GOOSE
39
+
40
+ poll_time_default: str = "00:01:00"
41
+ poll_time_updating: str = "00:00:05"
42
+ poll_time_registration: str = "00:00:10"
43
+
44
+ secret_key: Annotated[OctKey, BeforeValidator(OctKey.import_key)] = secrets.token_hex(16)
45
+
46
+ users: list[User] = []
47
+
48
+ db_uri: str = f"sqlite:///{GOOSEBIT_ROOT_DIR.joinpath('db.sqlite3')}"
49
+ artifacts_dir: Path = GOOSEBIT_ROOT_DIR.joinpath("artifacts")
50
+
51
+ metrics: MetricsSettings = MetricsSettings()
52
+
53
+ logging: dict = LOGGING_DEFAULT
54
+
55
+ @classmethod
56
+ def settings_customise_sources(
57
+ cls,
58
+ settings_cls: type[BaseSettings],
59
+ init_settings: PydanticBaseSettingsSource,
60
+ env_settings: PydanticBaseSettingsSource,
61
+ dotenv_settings: PydanticBaseSettingsSource,
62
+ file_secret_settings: PydanticBaseSettingsSource,
63
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
64
+ settings_sources = [env_settings]
65
+ config_files = []
66
+
67
+ if (path := os.getenv("GOOSEBIT_SETTINGS")) is not None:
68
+ config_files.append(Path(path))
69
+
70
+ config_files.extend(
71
+ [
72
+ CURRENT_DIR.joinpath("goosebit.yaml"),
73
+ GOOSEBIT_ROOT_DIR.joinpath("goosebit.yaml"),
74
+ Path("/etc/goosebit.yaml"),
75
+ ]
76
+ )
77
+
78
+ cls.config_file = None
79
+ for config_file in config_files:
80
+ if config_file.exists():
81
+ settings_sources.append(
82
+ YamlConfigSettingsSource(settings_cls, config_file),
83
+ )
84
+ cls.config_file = config_file
85
+ break
86
+ return tuple(settings_sources)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class DevicesPatchRequest(BaseModel):
7
+ devices: list[str]
8
+ software: str | None = None
9
+ name: str | None = None
10
+ pinned: bool | None = None
11
+ feed: str | None = None
12
+ force_update: bool | None = None
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from fastapi.requests import Request
6
+ from pydantic import BaseModel, Field
7
+
8
+ from goosebit.schema.devices import DeviceSchema
9
+
10
+
11
+ class BFFDeviceResponse(BaseModel):
12
+ data: list[DeviceSchema]
13
+ draw: int
14
+ records_total: int = Field(serialization_alias="recordsTotal")
15
+ records_filtered: int = Field(serialization_alias="recordsFiltered")
16
+
17
+ @classmethod
18
+ async def convert(cls, request: Request, query, search_filter, total_records):
19
+ params = request.query_params
20
+
21
+ draw = int(params.get("draw", 1))
22
+ start = int(params.get("start", 0))
23
+ length = int(params.get("length", 10))
24
+ search_value = params.get("search[value]", None)
25
+ order_column_index = params.get("order[0][column]", None)
26
+ order_column = params.get(f"columns[{order_column_index}][data]", None)
27
+ order_dir = params.get("order[0][dir]", None)
28
+
29
+ if search_value:
30
+ query = query.filter(search_filter(search_value))
31
+
32
+ if order_column:
33
+ query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
34
+
35
+ filtered_records = await query.count()
36
+ devices = await query.offset(start).limit(length).all()
37
+ data = await asyncio.gather(*[DeviceSchema.convert(d) for d in devices])
38
+
39
+ return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Security
4
+ from fastapi.requests import Request
5
+ from tortoise.expressions import Q
6
+
7
+ from goosebit.api.responses import StatusResponse
8
+ from goosebit.api.v1.devices import routes
9
+ from goosebit.auth import validate_user_permissions
10
+ from goosebit.db.models import Device, Software, UpdateModeEnum, UpdateStateEnum
11
+ from goosebit.updater.manager import get_update_manager
12
+
13
+ from .requests import DevicesPatchRequest
14
+ from .responses import BFFDeviceResponse
15
+
16
+ router = APIRouter(prefix="/devices")
17
+
18
+
19
+ @router.get(
20
+ "",
21
+ dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
22
+ )
23
+ async def devices_get(request: Request) -> BFFDeviceResponse:
24
+ def search_filter(search_value):
25
+ return (
26
+ Q(uuid__icontains=search_value)
27
+ | Q(name__icontains=search_value)
28
+ | Q(feed__icontains=search_value)
29
+ | Q(sw_version__icontains=search_value)
30
+ | Q(update_mode=int(UpdateModeEnum.from_str(search_value)))
31
+ | Q(last_state=int(UpdateStateEnum.from_str(search_value)))
32
+ )
33
+
34
+ query = Device.all().prefetch_related("assigned_software", "hardware")
35
+ total_records = await Device.all().count()
36
+
37
+ return await BFFDeviceResponse.convert(request, query, search_filter, total_records)
38
+
39
+
40
+ @router.patch(
41
+ "",
42
+ dependencies=[Security(validate_user_permissions, scopes=["device.write"])],
43
+ )
44
+ async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse:
45
+ for uuid in config.devices:
46
+ updater = await get_update_manager(uuid)
47
+ if config.software is not None:
48
+ if config.software == "rollout":
49
+ await updater.update_update(UpdateModeEnum.ROLLOUT, None)
50
+ elif config.software == "latest":
51
+ await updater.update_update(UpdateModeEnum.LATEST, None)
52
+ else:
53
+ software = await Software.get_or_none(id=config.software)
54
+ await updater.update_update(UpdateModeEnum.ASSIGNED, software)
55
+ if config.pinned is not None:
56
+ await updater.update_update(UpdateModeEnum.PINNED, None)
57
+ if config.name is not None:
58
+ await updater.update_name(config.name)
59
+ if config.feed is not None:
60
+ await updater.update_feed(config.feed)
61
+ if config.force_update is not None:
62
+ await updater.update_force_update(config.force_update)
63
+ return StatusResponse(success=True)
64
+
65
+
66
+ router.add_api_route(
67
+ "",
68
+ routes.devices_delete,
69
+ methods=["DELETE"],
70
+ dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
71
+ name="bff_devices_delete",
72
+ )
@@ -0,0 +1 @@
1
+ from .routes import router # noqa : F401
@@ -0,0 +1,22 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from fastapi.requests import Request
3
+ from fastapi.responses import FileResponse, RedirectResponse
4
+
5
+ from goosebit.db.models import Software
6
+
7
+ router = APIRouter(prefix="/download", tags=["download"])
8
+
9
+
10
+ @router.get("/{file_id}")
11
+ async def download_file(_: Request, file_id: int):
12
+ software = await Software.get_or_none(id=file_id)
13
+ if software is None:
14
+ raise HTTPException(404)
15
+ if software.local:
16
+ return FileResponse(
17
+ software.path,
18
+ media_type="application/octet-stream",
19
+ filename=software.path.name,
20
+ )
21
+ else:
22
+ return RedirectResponse(url=software.uri)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1,37 @@
1
+ import asyncio
2
+
3
+ from fastapi.requests import Request
4
+ from pydantic import BaseModel, Field
5
+
6
+ from goosebit.schema.rollouts import RolloutSchema
7
+
8
+
9
+ class BFFRolloutsResponse(BaseModel):
10
+ data: list[RolloutSchema]
11
+ draw: int
12
+ records_total: int = Field(serialization_alias="recordsTotal")
13
+ records_filtered: int = Field(serialization_alias="recordsFiltered")
14
+
15
+ @classmethod
16
+ async def convert(cls, request: Request, query, search_filter, total_records):
17
+ params = request.query_params
18
+
19
+ draw = int(params.get("draw", 1))
20
+ start = int(params.get("start", 0))
21
+ length = int(params.get("length", 10))
22
+ search_value = params.get("search[value]", None)
23
+ order_column_index = params.get("order[0][column]", None)
24
+ order_column = params.get(f"columns[{order_column_index}][data]", None)
25
+ order_dir = params.get("order[0][dir]", None)
26
+
27
+ if search_value:
28
+ query = query.filter(search_filter(search_value))
29
+
30
+ if order_column:
31
+ query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
32
+
33
+ filtered_records = await query.count()
34
+ rollouts = await query.offset(start).limit(length).all()
35
+ data = await asyncio.gather(*[RolloutSchema.convert(r) for r in rollouts])
36
+
37
+ return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
@@ -0,0 +1,52 @@
1
+ from fastapi import APIRouter, Security
2
+ from fastapi.requests import Request
3
+ from tortoise.expressions import Q
4
+
5
+ from goosebit.api.v1.rollouts import routes
6
+ from goosebit.auth import validate_user_permissions
7
+ from goosebit.db.models import Rollout
8
+
9
+ from .responses import BFFRolloutsResponse
10
+
11
+ router = APIRouter(prefix="/rollouts")
12
+
13
+
14
+ @router.get(
15
+ "",
16
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
17
+ )
18
+ async def rollouts_get(request: Request) -> BFFRolloutsResponse:
19
+ def search_filter(search_value):
20
+ return Q(name__icontains=search_value) | Q(feed__icontains=search_value)
21
+
22
+ query = Rollout.all().prefetch_related("software")
23
+ total_records = await Rollout.all().count()
24
+
25
+ return await BFFRolloutsResponse.convert(request, query, search_filter, total_records)
26
+
27
+
28
+ router.add_api_route(
29
+ "",
30
+ routes.rollouts_put,
31
+ methods=["POST"],
32
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
33
+ name="bff_rollouts_post",
34
+ )
35
+
36
+
37
+ router.add_api_route(
38
+ "",
39
+ routes.rollouts_patch,
40
+ methods=["PATCH"],
41
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
42
+ name="bff_rollouts_patch",
43
+ )
44
+
45
+
46
+ router.add_api_route(
47
+ "",
48
+ routes.rollouts_delete,
49
+ methods=["DELETE"],
50
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.delete"])],
51
+ name="bff_rollouts_delete",
52
+ )
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from . import devices, download, rollouts, software
6
+
7
+ router = APIRouter(prefix="/bff", tags=["bff"])
8
+ router.include_router(devices.router)
9
+ router.include_router(software.router)
10
+ router.include_router(rollouts.router)
11
+ router.include_router(download.router)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1,37 @@
1
+ import asyncio
2
+
3
+ from fastapi.requests import Request
4
+ from pydantic import BaseModel, Field
5
+
6
+ from goosebit.schema.software import SoftwareSchema
7
+
8
+
9
+ class BFFSoftwareResponse(BaseModel):
10
+ data: list[SoftwareSchema]
11
+ draw: int
12
+ records_total: int = Field(serialization_alias="recordsTotal")
13
+ records_filtered: int = Field(serialization_alias="recordsFiltered")
14
+
15
+ @classmethod
16
+ async def convert(cls, request: Request, query, search_filter, total_records):
17
+ params = request.query_params
18
+
19
+ draw = int(params.get("draw", 1))
20
+ start = int(params.get("start", 0))
21
+ length = int(params.get("length", 10))
22
+ search_value = params.get("search[value]", None)
23
+ order_column_index = params.get("order[0][column]", None)
24
+ order_column = params.get(f"columns[{order_column_index}][data]", None)
25
+ order_dir = params.get("order[0][dir]", None)
26
+
27
+ if search_value:
28
+ query = query.filter(search_filter(search_value))
29
+
30
+ if order_column:
31
+ query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
32
+
33
+ filtered_records = await query.count()
34
+ devices = await query.offset(start).limit(length).all()
35
+ data = await asyncio.gather(*[SoftwareSchema.convert(d) for d in devices])
36
+
37
+ return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import aiofiles
4
+ from fastapi import APIRouter, Form, HTTPException, Security, UploadFile
5
+ from fastapi.requests import Request
6
+ from tortoise.expressions import Q
7
+
8
+ from goosebit.api.v1.software import routes
9
+ from goosebit.auth import validate_user_permissions
10
+ from goosebit.db.models import Rollout, Software
11
+ from goosebit.settings import config
12
+ from goosebit.updates import create_software_update
13
+
14
+ from .responses import BFFSoftwareResponse
15
+
16
+ router = APIRouter(prefix="/software")
17
+
18
+
19
+ @router.get(
20
+ "",
21
+ dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
22
+ )
23
+ async def software_get(request: Request) -> BFFSoftwareResponse:
24
+ def search_filter(search_value):
25
+ return Q(uri__icontains=search_value) | Q(version__icontains=search_value)
26
+
27
+ query = Software.all().prefetch_related("compatibility")
28
+ total_records = await Software.all().count()
29
+
30
+ return await BFFSoftwareResponse.convert(request, query, search_filter, total_records)
31
+
32
+
33
+ router.add_api_route(
34
+ "",
35
+ routes.software_delete,
36
+ methods=["DELETE"],
37
+ dependencies=[Security(validate_user_permissions, scopes=["software.delete"])],
38
+ name="bff_software_delete",
39
+ )
40
+
41
+
42
+ @router.post(
43
+ "",
44
+ dependencies=[Security(validate_user_permissions, scopes=["software.write"])],
45
+ )
46
+ async def post_update(
47
+ request: Request,
48
+ url: str = Form(default=None),
49
+ chunk: UploadFile = Form(default=None),
50
+ init: bool = Form(default=None),
51
+ done: bool = Form(default=None),
52
+ filename: str = Form(default=None),
53
+ ):
54
+ if url is not None:
55
+ # remote file
56
+ software = await Software.get_or_none(uri=url)
57
+ if software is not None:
58
+ rollout_count = await Rollout.filter(software=software).count()
59
+ if rollout_count == 0:
60
+ await software.delete()
61
+ else:
62
+ raise HTTPException(409, "Software with same URL already exists and is referenced by rollout")
63
+
64
+ await create_software_update(url, None)
65
+ else:
66
+ # local file
67
+ file = config.artifacts_dir.joinpath(filename)
68
+ config.artifacts_dir.mkdir(parents=True, exist_ok=True)
69
+
70
+ temp_file = file.with_suffix(".tmp")
71
+ if init:
72
+ temp_file.unlink(missing_ok=True)
73
+
74
+ contents = await chunk.read()
75
+
76
+ async with aiofiles.open(temp_file, mode="ab") as f:
77
+ await f.write(contents)
78
+
79
+ if done:
80
+ try:
81
+ await create_software_update(file.absolute().as_uri(), temp_file)
82
+ finally:
83
+ temp_file.unlink(missing_ok=True)
goosebit/ui/nav.py ADDED
@@ -0,0 +1,16 @@
1
+ class Navigation:
2
+ def __init__(self):
3
+ self.items = []
4
+
5
+ def route(self, text: str, permissions: str = None):
6
+ def decorator(func):
7
+ self.items.append({"function": func.__name__, "text": text, "permissions": permissions})
8
+ return func
9
+
10
+ return decorator
11
+
12
+ def get(self):
13
+ return self.items
14
+
15
+
16
+ nav = Navigation()
goosebit/ui/routes.py CHANGED
@@ -1,101 +1,64 @@
1
- import aiofiles
2
- from fastapi import APIRouter, Depends, Form, Security, UploadFile
1
+ from fastapi import APIRouter, Depends, Security
3
2
  from fastapi.requests import Request
4
3
  from fastapi.responses import RedirectResponse
5
4
  from fastapi.security import OAuth2PasswordBearer
6
5
 
7
- from goosebit.auth import authenticate_session, validate_user_permissions
8
- from goosebit.models import Firmware
9
- from goosebit.permissions import Permissions
10
- from goosebit.settings import UPDATES_DIR
11
- from goosebit.ui.templates import templates
12
- from goosebit.updates import create_firmware_update
6
+ from goosebit.auth import redirect_if_unauthenticated, validate_user_permissions
7
+ from goosebit.ui.nav import nav
8
+
9
+ from . import bff
10
+ from .templates import templates
13
11
 
14
12
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
15
13
 
16
- router = APIRouter(prefix="/ui", dependencies=[Depends(authenticate_session)], include_in_schema=False)
14
+ router = APIRouter(prefix="/ui", dependencies=[Depends(redirect_if_unauthenticated)], include_in_schema=False)
15
+ router.include_router(bff.router)
17
16
 
18
17
 
19
- @router.get("/")
18
+ @router.get("")
20
19
  async def ui_root(request: Request):
21
20
  return RedirectResponse(request.url_for("home_ui"))
22
21
 
23
22
 
24
- @router.get(
25
- "/firmware",
26
- dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])],
27
- )
28
- async def firmware_ui(request: Request):
29
- return templates.TemplateResponse(request, "firmware.html", context={"title": "Firmware"})
30
-
31
-
32
- @router.post(
33
- "/upload/local",
34
- dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.WRITE])],
35
- )
36
- async def upload_update_local(
37
- request: Request,
38
- chunk: UploadFile = Form(...),
39
- init: bool = Form(...),
40
- done: bool = Form(...),
41
- filename: str = Form(...),
42
- ):
43
- file = UPDATES_DIR.joinpath(filename)
44
- firmware = await Firmware.get_or_none(uri=file.absolute().as_uri())
45
- if firmware is not None:
46
- await firmware.delete()
47
-
48
- tmpfile = file.with_suffix(".tmp")
49
- contents = await chunk.read()
50
- if init:
51
- file.unlink(missing_ok=True)
52
-
53
- async with aiofiles.open(tmpfile, mode="ab") as f:
54
- await f.write(contents)
55
- if done:
56
- tmpfile.replace(file)
57
- await create_firmware_update(file.absolute().as_uri())
58
-
59
-
60
- @router.post(
61
- "/upload/remote",
62
- dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.WRITE])],
63
- )
64
- async def upload_update_remote(request: Request, url: str = Form(...)):
65
- firmware = await Firmware.get_or_none(uri=url)
66
- if firmware is not None:
67
- await firmware.delete()
68
-
69
- await create_firmware_update(url)
70
-
71
-
72
23
  @router.get(
73
24
  "/home",
74
- dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])],
25
+ dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
75
26
  )
27
+ @nav.route("Home", permissions=["home.read"])
76
28
  async def home_ui(request: Request):
77
- return templates.TemplateResponse(request, "index.html", context={"title": "Home"})
29
+ return templates.TemplateResponse(request, "index.html.jinja", context={"title": "Home"})
78
30
 
79
31
 
80
32
  @router.get(
81
33
  "/devices",
82
- dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])],
34
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
83
35
  )
36
+ @nav.route("Devices", permissions=["device.read"])
84
37
  async def devices_ui(request: Request):
85
- return templates.TemplateResponse(request, "devices.html", context={"title": "Devices"})
38
+ return templates.TemplateResponse(request, "devices.html.jinja", context={"title": "Devices"})
39
+
40
+
41
+ @router.get(
42
+ "/software",
43
+ dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
44
+ )
45
+ @nav.route("Software", permissions=["software.read"])
46
+ async def software_ui(request: Request):
47
+ return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"})
86
48
 
87
49
 
88
50
  @router.get(
89
51
  "/rollouts",
90
- dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])],
52
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
91
53
  )
54
+ @nav.route("Rollouts", permissions=["rollout.read"])
92
55
  async def rollouts_ui(request: Request):
93
- return templates.TemplateResponse(request, "rollouts.html", context={"title": "Rollouts"})
56
+ return templates.TemplateResponse(request, "rollouts.html.jinja", context={"title": "Rollouts"})
94
57
 
95
58
 
96
59
  @router.get(
97
60
  "/logs/{dev_id}",
98
- dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])],
61
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
99
62
  )
100
63
  async def logs_ui(request: Request, dev_id: str):
101
- return templates.TemplateResponse(request, "logs.html", context={"title": "Log", "device": dev_id})
64
+ return templates.TemplateResponse(request, "logs.html.jinja", context={"title": "Log", "device": dev_id})
Binary file