goosebit 0.2.3__py3-none-any.whl → 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. goosebit/__init__.py +32 -3
  2. goosebit/api/v1/devices/device/routes.py +10 -4
  3. goosebit/api/v1/devices/responses.py +0 -7
  4. goosebit/api/v1/devices/routes.py +19 -3
  5. goosebit/api/v1/rollouts/responses.py +2 -7
  6. goosebit/api/v1/rollouts/routes.py +7 -3
  7. goosebit/api/v1/software/responses.py +0 -7
  8. goosebit/api/v1/software/routes.py +24 -11
  9. goosebit/auth/__init__.py +12 -8
  10. goosebit/db/__init__.py +12 -1
  11. goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  12. goosebit/db/models.py +19 -4
  13. goosebit/realtime/logs.py +1 -1
  14. goosebit/schema/devices.py +42 -38
  15. goosebit/schema/rollouts.py +21 -18
  16. goosebit/schema/software.py +24 -19
  17. goosebit/settings/schema.py +2 -0
  18. goosebit/ui/bff/common/__init__.py +0 -0
  19. goosebit/ui/bff/common/requests.py +44 -0
  20. goosebit/ui/bff/common/responses.py +16 -0
  21. goosebit/ui/bff/common/util.py +32 -0
  22. goosebit/ui/bff/devices/responses.py +15 -19
  23. goosebit/ui/bff/devices/routes.py +61 -7
  24. goosebit/ui/bff/rollouts/responses.py +15 -19
  25. goosebit/ui/bff/rollouts/routes.py +8 -6
  26. goosebit/ui/bff/routes.py +4 -2
  27. goosebit/ui/bff/software/responses.py +29 -19
  28. goosebit/ui/bff/software/routes.py +29 -16
  29. goosebit/ui/nav.py +1 -1
  30. goosebit/ui/routes.py +10 -19
  31. goosebit/ui/static/js/devices.js +188 -94
  32. goosebit/ui/static/js/rollouts.js +20 -13
  33. goosebit/ui/static/js/software.js +5 -11
  34. goosebit/ui/static/js/util.js +43 -14
  35. goosebit/ui/templates/devices.html.jinja +77 -49
  36. goosebit/ui/templates/nav.html.jinja +35 -4
  37. goosebit/ui/templates/rollouts.html.jinja +23 -23
  38. goosebit/updater/controller/v1/routes.py +33 -23
  39. goosebit/updater/controller/v1/schema.py +4 -4
  40. goosebit/updater/manager.py +28 -52
  41. goosebit/updater/routes.py +6 -2
  42. goosebit/updates/__init__.py +14 -21
  43. goosebit/updates/swdesc.py +36 -15
  44. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/METADATA +23 -7
  45. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/RECORD +48 -44
  46. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/WHEEL +1 -1
  47. goosebit-0.2.5.dist-info/entry_points.txt +3 -0
  48. goosebit/ui/static/js/index.js +0 -155
  49. goosebit/ui/templates/index.html.jinja +0 -25
  50. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/LICENSE +0 -0
goosebit/__init__.py CHANGED
@@ -1,27 +1,37 @@
1
1
  import importlib.metadata
2
2
  from contextlib import asynccontextmanager
3
+ from logging import getLogger
3
4
  from typing import Annotated
4
5
 
5
- from fastapi import Depends, FastAPI
6
+ from fastapi import Depends, FastAPI, HTTPException
7
+ from fastapi.exception_handlers import http_exception_handler
6
8
  from fastapi.openapi.docs import get_swagger_ui_html
7
9
  from fastapi.requests import Request
8
10
  from fastapi.responses import RedirectResponse
9
11
  from fastapi.security import OAuth2PasswordRequestForm
10
12
  from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
13
+ from starlette.exceptions import HTTPException as StarletteHTTPException
14
+ from tortoise.exceptions import ValidationError
11
15
 
12
16
  from goosebit import api, db, realtime, ui, updater
13
17
  from goosebit.api.telemetry import metrics
14
18
  from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated
19
+ from goosebit.settings import config
15
20
  from goosebit.ui.nav import nav
16
21
  from goosebit.ui.static import static
17
22
  from goosebit.ui.templates import templates
18
23
 
24
+ logger = getLogger(__name__)
25
+
19
26
 
20
27
  @asynccontextmanager
21
28
  async def lifespan(_: FastAPI):
22
- await db.init()
29
+ db_ready = await db.init()
30
+ if not db_ready:
31
+ logger.exception("DB does not exist, try running `poetry run aerich upgrade`.")
23
32
  await metrics.init()
24
- yield
33
+ if db_ready:
34
+ yield
25
35
  await db.close()
26
36
 
27
37
 
@@ -52,6 +62,19 @@ app.mount("/static", static, name="static")
52
62
  Instrumentor.instrument_app(app)
53
63
 
54
64
 
65
+ # Custom exception handler for Tortoise ValidationError
66
+ @app.exception_handler(ValidationError)
67
+ async def tortoise_validation_exception_handler(request: Request, exc: ValidationError):
68
+ raise HTTPException(422, str(exc))
69
+
70
+
71
+ # Extend default handler to do logging
72
+ @app.exception_handler(StarletteHTTPException)
73
+ async def custom_http_exception_handler(request, exc):
74
+ logger.warning(f"HTTPException, request={request.url}, status={exc.status_code}, detail={exc.detail}")
75
+ return await http_exception_handler(request, exc)
76
+
77
+
55
78
  @app.middleware("http")
56
79
  async def attach_user(request: Request, call_next):
57
80
  request.scope["user"] = await get_user_from_request(request)
@@ -64,6 +87,12 @@ async def attach_nav(request: Request, call_next):
64
87
  return await call_next(request)
65
88
 
66
89
 
90
+ @app.middleware("http")
91
+ async def attach_config(request: Request, call_next):
92
+ request.scope["config"] = config
93
+ return await call_next(request)
94
+
95
+
67
96
  @app.get("/", include_in_schema=False)
68
97
  def root_redirect(request: Request):
69
98
  return RedirectResponse(request.url_for("ui_root"))
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from fastapi import APIRouter, Depends, Security
3
+ from fastapi import APIRouter, Depends, HTTPException, Security
4
4
  from fastapi.requests import Request
5
5
 
6
6
  from goosebit.api.v1.devices.device.responses import DeviceLogResponse, DeviceResponse
@@ -12,16 +12,22 @@ router = APIRouter(prefix="/{dev_id}")
12
12
 
13
13
  @router.get(
14
14
  "",
15
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
15
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
16
16
  )
17
17
  async def device_get(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceResponse:
18
- return await DeviceResponse.convert(await updater.get_device())
18
+ device = await updater.get_device()
19
+ if device is None:
20
+ raise HTTPException(404)
21
+ await device.fetch_related("assigned_software", "hardware")
22
+ return DeviceResponse.model_validate(device)
19
23
 
20
24
 
21
25
  @router.get(
22
26
  "/log",
23
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
27
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
24
28
  )
25
29
  async def device_logs(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceLogResponse:
26
30
  device = await updater.get_device()
31
+ if device is None:
32
+ raise HTTPException(404)
27
33
  return DeviceLogResponse(log=device.last_log)
@@ -1,16 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
-
5
3
  from pydantic import BaseModel
6
4
 
7
- from goosebit.db.models import Device
8
5
  from goosebit.schema.devices import DeviceSchema
9
6
 
10
7
 
11
8
  class DevicesResponse(BaseModel):
12
9
  devices: list[DeviceSchema]
13
-
14
- @classmethod
15
- async def convert(cls, devices: list[Device]):
16
- return cls(devices=await asyncio.gather(*[DeviceSchema.convert(d) for d in devices]))
@@ -1,12 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+
3
5
  from fastapi import APIRouter, Security
4
6
  from fastapi.requests import Request
5
7
 
6
8
  from goosebit.api.responses import StatusResponse
7
9
  from goosebit.auth import validate_user_permissions
8
10
  from goosebit.db.models import Device
9
- from goosebit.updater.manager import delete_devices
11
+ from goosebit.schema.devices import DeviceSchema
12
+ from goosebit.schema.software import SoftwareSchema
13
+ from goosebit.updater.manager import delete_devices, get_update_manager
10
14
 
11
15
  from . import device
12
16
  from .requests import DevicesDeleteRequest
@@ -17,10 +21,22 @@ router = APIRouter(prefix="/devices", tags=["devices"])
17
21
 
18
22
  @router.get(
19
23
  "",
20
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
24
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
21
25
  )
22
26
  async def devices_get(_: Request) -> DevicesResponse:
23
- return await DevicesResponse.convert(await Device.all().prefetch_related("assigned_software", "hardware"))
27
+ devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility")
28
+ response = DevicesResponse(devices=devices)
29
+
30
+ async def set_assigned_sw(d: DeviceSchema):
31
+ updater = await get_update_manager(d.uuid)
32
+ _, target = await updater.get_update()
33
+ if target is not None:
34
+ await target.fetch_related("compatibility")
35
+ d.assigned_software = SoftwareSchema.model_validate(target)
36
+ return d
37
+
38
+ response.devices = await asyncio.gather(*[set_assigned_sw(d) for d in response.devices])
39
+ return response
24
40
 
25
41
 
26
42
  @router.delete(
@@ -1,19 +1,14 @@
1
- import asyncio
1
+ from __future__ import annotations
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
5
5
  from goosebit.api.responses import StatusResponse
6
- from goosebit.db.models import Rollout
7
6
  from goosebit.schema.rollouts import RolloutSchema
8
7
 
9
8
 
10
9
  class RolloutsPutResponse(StatusResponse):
11
- id: int
10
+ id: int | None = None
12
11
 
13
12
 
14
13
  class RolloutsResponse(BaseModel):
15
14
  rollouts: list[RolloutSchema]
16
-
17
- @classmethod
18
- async def convert(cls, devices: list[Rollout]):
19
- return cls(rollouts=await asyncio.gather(*[RolloutSchema.convert(d) for d in devices]))
@@ -1,9 +1,9 @@
1
- from fastapi import APIRouter, Security
1
+ from fastapi import APIRouter, HTTPException, Security
2
2
  from fastapi.requests import Request
3
3
 
4
4
  from goosebit.api.responses import StatusResponse
5
5
  from goosebit.auth import validate_user_permissions
6
- from goosebit.db.models import Rollout
6
+ from goosebit.db.models import Rollout, Software
7
7
 
8
8
  from .requests import RolloutsDeleteRequest, RolloutsPatchRequest, RolloutsPutRequest
9
9
  from .responses import RolloutsPutResponse, RolloutsResponse
@@ -16,7 +16,8 @@ router = APIRouter(prefix="/rollouts", tags=["rollouts"])
16
16
  dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
17
17
  )
18
18
  async def rollouts_get(_: Request) -> RolloutsResponse:
19
- return await RolloutsResponse.convert(await Rollout.all().prefetch_related("software"))
19
+ rollouts = await Rollout.all().prefetch_related("software", "software__compatibility")
20
+ return RolloutsResponse(rollouts=rollouts)
20
21
 
21
22
 
22
23
  @router.post(
@@ -24,6 +25,9 @@ async def rollouts_get(_: Request) -> RolloutsResponse:
24
25
  dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
25
26
  )
26
27
  async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse:
28
+ software = await Software.filter(id=rollout.software_id)
29
+ if len(software) == 0:
30
+ raise HTTPException(404, f"No software with ID {rollout.software_id} found")
27
31
  rollout = await Rollout.create(
28
32
  name=rollout.name,
29
33
  feed=rollout.feed,
@@ -1,16 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
-
5
3
  from pydantic import BaseModel
6
4
 
7
- from goosebit.db.models import Software
8
5
  from goosebit.schema.software import SoftwareSchema
9
6
 
10
7
 
11
8
  class SoftwareResponse(BaseModel):
12
9
  software: list[SoftwareSchema]
13
-
14
- @classmethod
15
- async def convert(cls, software: list[Software]):
16
- return cls(software=await asyncio.gather(*[SoftwareSchema.convert(f) for f in software]))
@@ -1,6 +1,9 @@
1
- from pathlib import Path
1
+ from __future__ import annotations
2
2
 
3
- import aiofiles
3
+ import random
4
+ import string
5
+
6
+ from anyio import Path, open_file
4
7
  from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile
5
8
  from fastapi.requests import Request
6
9
 
@@ -21,7 +24,8 @@ router = APIRouter(prefix="/software", tags=["software"])
21
24
  dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
22
25
  )
23
26
  async def software_get(_: Request) -> SoftwareResponse:
24
- return await SoftwareResponse.convert(await Software.all().prefetch_related("compatibility"))
27
+ software = await Software.all().prefetch_related("compatibility")
28
+ return SoftwareResponse(software=software)
25
29
 
26
30
 
27
31
  @router.delete(
@@ -42,8 +46,8 @@ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> Stat
42
46
 
43
47
  if software.local:
44
48
  path = software.path
45
- if path.exists():
46
- path.unlink()
49
+ if await path.exists():
50
+ await path.unlink()
47
51
 
48
52
  await software.delete()
49
53
  success = True
@@ -66,12 +70,21 @@ async def post_update(_: Request, file: UploadFile | None = File(None), url: str
66
70
  raise HTTPException(409, "Software with same URL already exists and is referenced by rollout")
67
71
 
68
72
  software = await create_software_update(url, None)
69
- else:
73
+ elif file is not None:
70
74
  # local file
71
- file_path = config.artifacts_dir.joinpath(file.filename)
72
-
73
- async with aiofiles.tempfile.NamedTemporaryFile("w+b") as f:
74
- await f.write(await file.read())
75
- software = await create_software_update(file_path.absolute().as_uri(), Path(f.name))
75
+ artifacts_dir = Path(config.artifacts_dir)
76
+ file_path = artifacts_dir.joinpath(file.filename)
77
+ tmp_file_path = artifacts_dir.joinpath("tmp", ("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp"))
78
+ await tmp_file_path.parent.mkdir(parents=True, exist_ok=True)
79
+ file_absolute_path = await file_path.absolute()
80
+ tmp_file_absolute_path = await tmp_file_path.absolute()
81
+ try:
82
+ async with await open_file(tmp_file_path, "w+b") as f:
83
+ await f.write(await file.read())
84
+ software = await create_software_update(file_absolute_path.as_uri(), tmp_file_absolute_path)
85
+ finally:
86
+ await tmp_file_path.unlink(missing_ok=True)
87
+ else:
88
+ raise HTTPException(422)
76
89
 
77
90
  return {"id": software.id}
goosebit/auth/__init__.py CHANGED
@@ -16,7 +16,11 @@ from goosebit.settings.schema import User
16
16
  logger = logging.getLogger(__name__)
17
17
 
18
18
 
19
- oauth2_auth = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
19
+ oauth2_bearer = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
20
+
21
+
22
+ async def oauth2_auth(connection: HTTPConnection):
23
+ return await oauth2_bearer(connection)
20
24
 
21
25
 
22
26
  async def session_auth(connection: HTTPConnection) -> str:
@@ -27,15 +31,15 @@ def create_token(username: str) -> str:
27
31
  return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key)
28
32
 
29
33
 
30
- def get_user_from_token(token: str) -> User | None:
34
+ def get_user_from_token(token: str | None) -> User | None:
31
35
  if token is None:
32
- return
36
+ return None
33
37
  try:
34
38
  token_data = jwt.decode(token, config.secret_key)
35
39
  username = token_data.claims["username"]
36
40
  return USERS.get(username)
37
41
  except (BadSignatureError, LookupError, ValueError):
38
- pass
42
+ return None
39
43
 
40
44
 
41
45
  def login_user(username: str, password: str) -> str:
@@ -58,9 +62,9 @@ def login_user(username: str, password: str) -> str:
58
62
 
59
63
 
60
64
  def get_current_user(
61
- session_token: Annotated[str, Depends(session_auth)] = None,
62
- oauth2_token: Annotated[str, Depends(oauth2_auth)] = None,
63
- ) -> User:
65
+ session_token: Annotated[str | None, Depends(session_auth)] = None,
66
+ oauth2_token: Annotated[str | None, Depends(oauth2_auth)] = None,
67
+ ) -> User | None:
64
68
  session_user = get_user_from_token(session_token)
65
69
  oauth2_user = get_user_from_token(oauth2_token)
66
70
  user = session_user or oauth2_user
@@ -68,7 +72,7 @@ def get_current_user(
68
72
 
69
73
 
70
74
  # using | Request because oauth2_auth.__call__ expects is
71
- async def get_user_from_request(connection: HTTPConnection | Request) -> User:
75
+ async def get_user_from_request(connection: HTTPConnection | Request) -> User | None:
72
76
  token = await session_auth(connection) or await oauth2_auth(connection)
73
77
  return get_user_from_token(token)
74
78
 
goosebit/db/__init__.py CHANGED
@@ -1,10 +1,21 @@
1
+ from logging import getLogger
2
+
1
3
  from tortoise import Tortoise
4
+ from tortoise.exceptions import OperationalError
2
5
 
3
6
  from goosebit.db.config import TORTOISE_CONF
7
+ from goosebit.db.models import Device
8
+
9
+ logger = getLogger(__name__)
4
10
 
5
11
 
6
- async def init():
12
+ async def init() -> bool:
7
13
  await Tortoise.init(config=TORTOISE_CONF)
14
+ try:
15
+ await Device.first()
16
+ except OperationalError:
17
+ return False
18
+ return True
8
19
 
9
20
 
10
21
  async def close():
@@ -0,0 +1,11 @@
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ return """
6
+ ALTER TABLE "device" DROP COLUMN "log_complete";"""
7
+
8
+
9
+ async def downgrade(db: BaseDBAsyncClient) -> str:
10
+ return """
11
+ ALTER TABLE "device" ADD "log_complete" INT NOT NULL DEFAULT 0;"""
goosebit/db/models.py CHANGED
@@ -1,11 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  from enum import IntEnum
2
- from pathlib import Path
3
4
  from typing import Self
4
5
  from urllib.parse import unquote, urlparse
5
6
  from urllib.request import url2pathname
6
7
 
7
8
  import semver
9
+ from anyio import Path
10
+ from semver import Version
8
11
  from tortoise import Model, fields
12
+ from tortoise.exceptions import ValidationError
9
13
 
10
14
  from goosebit.api.telemetry.metrics import devices_count
11
15
 
@@ -65,7 +69,6 @@ class Device(Model):
65
69
  update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
66
70
  last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
67
71
  progress = fields.IntField(null=True)
68
- log_complete = fields.BooleanField(default=False)
69
72
  last_log = fields.TextField(null=True)
70
73
  last_seen = fields.BigIntField(null=True)
71
74
  last_ip = fields.CharField(max_length=15, null=True)
@@ -73,6 +76,14 @@ class Device(Model):
73
76
  tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags")
74
77
 
75
78
  async def save(self, *args, **kwargs):
79
+ # Check if the software is compatible with the hardware before saving
80
+ if self.assigned_software and self.hardware:
81
+ # Check if the assigned software is compatible with the hardware
82
+ await self.fetch_related("assigned_software", "hardware")
83
+ is_compatible = await self.assigned_software.compatibility.filter(id=self.hardware.id).exists()
84
+ if not is_compatible:
85
+ raise ValidationError("The assigned software is not compatible with the device's hardware.")
86
+
76
87
  is_new = self._saved_in_db is False
77
88
  await super().save(*args, **kwargs)
78
89
  if is_new:
@@ -127,12 +138,12 @@ class Software(Model):
127
138
  return None
128
139
  return sorted(
129
140
  updates,
130
- key=lambda x: semver.Version.parse(x.version),
141
+ key=lambda x: semver.Version.parse(x.version, optional_minor_and_patch=True),
131
142
  reverse=True,
132
143
  )[0]
133
144
 
134
145
  @property
135
- def path(self):
146
+ def path(self) -> Path:
136
147
  return Path(url2pathname(unquote(urlparse(self.uri).path)))
137
148
 
138
149
  @property
@@ -145,3 +156,7 @@ class Software(Model):
145
156
  return self.path.name
146
157
  else:
147
158
  return self.uri
159
+
160
+ @property
161
+ def parsed_version(self) -> Version:
162
+ return semver.Version.parse(self.version, optional_minor_and_patch=True)
goosebit/realtime/logs.py CHANGED
@@ -19,7 +19,7 @@ class RealtimeLogModel(BaseModel):
19
19
 
20
20
  @router.websocket(
21
21
  "/{dev_id}",
22
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
22
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
23
23
  )
24
24
  async def device_logs(websocket: WebSocket, dev_id: str):
25
25
  await websocket.accept()
@@ -4,10 +4,11 @@ import time
4
4
  from enum import Enum, IntEnum, StrEnum
5
5
  from typing import Annotated
6
6
 
7
- from pydantic import BaseModel, BeforeValidator, computed_field
7
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, computed_field
8
8
 
9
- from goosebit.db.models import Device, UpdateModeEnum, UpdateStateEnum
10
- from goosebit.updater.manager import get_update_manager
9
+ from goosebit.db.models import UpdateModeEnum, UpdateStateEnum
10
+ from goosebit.schema.software import HardwareSchema, SoftwareSchema
11
+ from goosebit.updater.manager import DeviceUpdateManager
11
12
 
12
13
 
13
14
  class ConvertableEnum(StrEnum):
@@ -26,48 +27,51 @@ UpdateModeSchema = enum_factory("UpdateModeSchema", UpdateModeEnum)
26
27
 
27
28
 
28
29
  class DeviceSchema(BaseModel):
30
+ model_config = ConfigDict(from_attributes=True)
31
+
29
32
  uuid: str
30
33
  name: str | None
31
34
  sw_version: str | None
32
- sw_target_version: str | None
33
- sw_assigned: int | None
34
- hw_model: str
35
- hw_revision: str
35
+
36
+ assigned_software: SoftwareSchema | None = Field(exclude=True)
37
+ hardware: HardwareSchema | None = Field(exclude=True)
38
+
36
39
  feed: str
37
40
  progress: int | None
38
- last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)]
39
- update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)]
41
+ last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)] # type: ignore[valid-type]
42
+ update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)] # type: ignore[valid-type]
40
43
  force_update: bool
41
44
  last_ip: str | None
42
- last_seen: int | None
43
- poll_seconds: int
45
+ last_seen: Annotated[
46
+ int | None, BeforeValidator(lambda last_seen: round(time.time() - last_seen) if last_seen is not None else None)
47
+ ]
44
48
 
45
- @computed_field
49
+ @computed_field # type: ignore[misc]
50
+ @property
46
51
  def online(self) -> bool | None:
47
- return self.last_seen < self.poll_seconds if self.last_seen is not None else None
52
+ return self.last_seen < (self.poll_seconds + 10) if self.last_seen is not None else None
48
53
 
49
- @classmethod
50
- async def convert(cls, device: Device):
51
- manager = await get_update_manager(device.uuid)
52
- _, target_software = await manager.get_update()
53
- last_seen = device.last_seen
54
- if last_seen is not None:
55
- last_seen = round(time.time() - device.last_seen)
56
-
57
- return cls(
58
- uuid=device.uuid,
59
- name=device.name,
60
- sw_version=device.sw_version,
61
- sw_target_version=(target_software.version if target_software is not None else None),
62
- sw_assigned=(device.assigned_software.id if device.assigned_software is not None else None),
63
- hw_model=device.hardware.model,
64
- hw_revision=device.hardware.revision,
65
- feed=device.feed,
66
- progress=device.progress,
67
- last_state=device.last_state,
68
- update_mode=device.update_mode,
69
- force_update=device.force_update,
70
- last_ip=device.last_ip,
71
- last_seen=last_seen,
72
- poll_seconds=manager.poll_seconds,
73
- )
54
+ @computed_field # type: ignore[misc]
55
+ @property
56
+ def sw_target_version(self) -> str | None:
57
+ return self.assigned_software.version if self.assigned_software is not None else None
58
+
59
+ @computed_field # type: ignore[misc]
60
+ @property
61
+ def sw_assigned(self) -> int | None:
62
+ return self.assigned_software.id if self.assigned_software is not None else None
63
+
64
+ @computed_field # type: ignore[misc]
65
+ @property
66
+ def hw_model(self) -> str | None:
67
+ return self.hardware.model if self.hardware is not None else None
68
+
69
+ @computed_field # type: ignore[misc]
70
+ @property
71
+ def hw_revision(self) -> str | None:
72
+ return self.hardware.revision if self.hardware is not None else None
73
+
74
+ @computed_field # type: ignore[misc]
75
+ @property
76
+ def poll_seconds(self) -> int:
77
+ return DeviceUpdateManager(self.uuid).poll_seconds
@@ -1,31 +1,34 @@
1
1
  from __future__ import annotations
2
2
 
3
- from pydantic import BaseModel
3
+ from datetime import datetime
4
4
 
5
- from goosebit.db.models import Rollout
5
+ from pydantic import BaseModel, ConfigDict, Field, computed_field, field_serializer
6
+
7
+ from goosebit.schema.software import SoftwareSchema
6
8
 
7
9
 
8
10
  class RolloutSchema(BaseModel):
11
+ model_config = ConfigDict(from_attributes=True)
12
+
9
13
  id: int
10
- created_at: int
14
+ created_at: datetime
11
15
  name: str | None
12
16
  feed: str
13
- sw_file: str
14
- sw_version: str
17
+ software: SoftwareSchema = Field(exclude=True)
15
18
  paused: bool
16
19
  success_count: int
17
20
  failure_count: int
18
21
 
19
- @classmethod
20
- async def convert(cls, rollout: Rollout):
21
- return cls(
22
- id=rollout.id,
23
- created_at=int(rollout.created_at.timestamp() * 1000),
24
- name=rollout.name,
25
- feed=rollout.feed,
26
- sw_file=rollout.software.path.name,
27
- sw_version=rollout.software.version,
28
- paused=rollout.paused,
29
- success_count=rollout.success_count,
30
- failure_count=rollout.failure_count,
31
- )
22
+ @computed_field # type: ignore[misc]
23
+ @property
24
+ def sw_version(self) -> str:
25
+ return self.software.version
26
+
27
+ @computed_field # type: ignore[misc]
28
+ @property
29
+ def sw_file(self) -> str:
30
+ return self.software.path.name
31
+
32
+ @field_serializer("created_at")
33
+ def serialize_created_at(self, created_at: datetime, _info):
34
+ return int(created_at.timestamp() * 1000)