goosebit 0.2.3__py3-none-any.whl → 0.2.4__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 (40) hide show
  1. goosebit/__init__.py +16 -3
  2. goosebit/api/v1/devices/device/routes.py +8 -2
  3. goosebit/api/v1/devices/responses.py +0 -7
  4. goosebit/api/v1/devices/routes.py +2 -1
  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 +7 -7
  10. goosebit/db/__init__.py +12 -1
  11. goosebit/db/models.py +13 -2
  12. goosebit/schema/devices.py +41 -37
  13. goosebit/schema/rollouts.py +21 -18
  14. goosebit/schema/software.py +24 -19
  15. goosebit/ui/bff/common/__init__.py +0 -0
  16. goosebit/ui/bff/common/requests.py +56 -0
  17. goosebit/ui/bff/common/util.py +32 -0
  18. goosebit/ui/bff/devices/responses.py +12 -20
  19. goosebit/ui/bff/devices/routes.py +9 -6
  20. goosebit/ui/bff/rollouts/responses.py +12 -20
  21. goosebit/ui/bff/rollouts/routes.py +8 -6
  22. goosebit/ui/bff/software/responses.py +19 -19
  23. goosebit/ui/bff/software/routes.py +29 -16
  24. goosebit/ui/nav.py +1 -1
  25. goosebit/ui/routes.py +4 -4
  26. goosebit/ui/static/js/devices.js +135 -25
  27. goosebit/ui/static/js/rollouts.js +4 -0
  28. goosebit/ui/static/js/util.js +23 -14
  29. goosebit/ui/templates/devices.html.jinja +77 -29
  30. goosebit/ui/templates/nav.html.jinja +22 -2
  31. goosebit/ui/templates/rollouts.html.jinja +23 -23
  32. goosebit/updater/controller/v1/routes.py +7 -3
  33. goosebit/updater/controller/v1/schema.py +4 -4
  34. goosebit/updater/manager.py +16 -8
  35. goosebit/updates/__init__.py +14 -21
  36. goosebit/updates/swdesc.py +35 -14
  37. {goosebit-0.2.3.dist-info → goosebit-0.2.4.dist-info}/METADATA +11 -3
  38. {goosebit-0.2.3.dist-info → goosebit-0.2.4.dist-info}/RECORD +40 -37
  39. {goosebit-0.2.3.dist-info → goosebit-0.2.4.dist-info}/LICENSE +0 -0
  40. {goosebit-0.2.3.dist-info → goosebit-0.2.4.dist-info}/WHEEL +0 -0
goosebit/__init__.py CHANGED
@@ -1,13 +1,15 @@
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
6
7
  from fastapi.openapi.docs import get_swagger_ui_html
7
8
  from fastapi.requests import Request
8
9
  from fastapi.responses import RedirectResponse
9
10
  from fastapi.security import OAuth2PasswordRequestForm
10
11
  from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
12
+ from tortoise.exceptions import ValidationError
11
13
 
12
14
  from goosebit import api, db, realtime, ui, updater
13
15
  from goosebit.api.telemetry import metrics
@@ -16,12 +18,17 @@ from goosebit.ui.nav import nav
16
18
  from goosebit.ui.static import static
17
19
  from goosebit.ui.templates import templates
18
20
 
21
+ logger = getLogger(__name__)
22
+
19
23
 
20
24
  @asynccontextmanager
21
25
  async def lifespan(_: FastAPI):
22
- await db.init()
26
+ db_ready = await db.init()
27
+ if not db_ready:
28
+ logger.exception("DB does not exist, try running `poetry run aerich upgrade`.")
23
29
  await metrics.init()
24
- yield
30
+ if db_ready:
31
+ yield
25
32
  await db.close()
26
33
 
27
34
 
@@ -52,6 +59,12 @@ app.mount("/static", static, name="static")
52
59
  Instrumentor.instrument_app(app)
53
60
 
54
61
 
62
+ # Custom exception handler for Tortoise ValidationError
63
+ @app.exception_handler(ValidationError)
64
+ async def tortoise_validation_exception_handler(request: Request, exc: ValidationError):
65
+ raise HTTPException(422, str(exc))
66
+
67
+
55
68
  @app.middleware("http")
56
69
  async def attach_user(request: Request, call_next):
57
70
  request.scope["user"] = await get_user_from_request(request)
@@ -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
@@ -15,7 +15,11 @@ router = APIRouter(prefix="/{dev_id}")
15
15
  dependencies=[Security(validate_user_permissions, scopes=["home.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(
@@ -24,4 +28,6 @@ async def device_get(_: Request, updater: UpdateManager = Depends(get_update_man
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]))
@@ -20,7 +20,8 @@ router = APIRouter(prefix="/devices", tags=["devices"])
20
20
  dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
21
21
  )
22
22
  async def devices_get(_: Request) -> DevicesResponse:
23
- return await DevicesResponse.convert(await Device.all().prefetch_related("assigned_software", "hardware"))
23
+ devices = await Device.all().prefetch_related("assigned_software", "hardware")
24
+ return DevicesResponse(devices=devices)
24
25
 
25
26
 
26
27
  @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
@@ -27,15 +27,15 @@ def create_token(username: str) -> str:
27
27
  return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key)
28
28
 
29
29
 
30
- def get_user_from_token(token: str) -> User | None:
30
+ def get_user_from_token(token: str | None) -> User | None:
31
31
  if token is None:
32
- return
32
+ return None
33
33
  try:
34
34
  token_data = jwt.decode(token, config.secret_key)
35
35
  username = token_data.claims["username"]
36
36
  return USERS.get(username)
37
37
  except (BadSignatureError, LookupError, ValueError):
38
- pass
38
+ return None
39
39
 
40
40
 
41
41
  def login_user(username: str, password: str) -> str:
@@ -58,9 +58,9 @@ def login_user(username: str, password: str) -> str:
58
58
 
59
59
 
60
60
  def get_current_user(
61
- session_token: Annotated[str, Depends(session_auth)] = None,
62
- oauth2_token: Annotated[str, Depends(oauth2_auth)] = None,
63
- ) -> User:
61
+ session_token: Annotated[str | None, Depends(session_auth)] = None,
62
+ oauth2_token: Annotated[str | None, Depends(oauth2_auth)] = None,
63
+ ) -> User | None:
64
64
  session_user = get_user_from_token(session_token)
65
65
  oauth2_user = get_user_from_token(oauth2_token)
66
66
  user = session_user or oauth2_user
@@ -68,7 +68,7 @@ def get_current_user(
68
68
 
69
69
 
70
70
  # using | Request because oauth2_auth.__call__ expects is
71
- async def get_user_from_request(connection: HTTPConnection | Request) -> User:
71
+ async def get_user_from_request(connection: HTTPConnection | Request) -> User | None:
72
72
  token = await session_auth(connection) or await oauth2_auth(connection)
73
73
  return get_user_from_token(token)
74
74
 
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():
goosebit/db/models.py CHANGED
@@ -1,11 +1,14 @@
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
8
10
  from tortoise import Model, fields
11
+ from tortoise.exceptions import ValidationError
9
12
 
10
13
  from goosebit.api.telemetry.metrics import devices_count
11
14
 
@@ -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:
@@ -132,7 +143,7 @@ class Software(Model):
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
@@ -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
52
  return self.last_seen < self.poll_seconds 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)
@@ -1,37 +1,42 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
3
+ from urllib.parse import unquote, urlparse
4
+ from urllib.request import url2pathname
4
5
 
5
- from pydantic import BaseModel
6
-
7
- from goosebit.db.models import Hardware, Software
6
+ from anyio import Path
7
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
8
8
 
9
9
 
10
10
  class HardwareSchema(BaseModel):
11
+ model_config = ConfigDict(from_attributes=True)
12
+
11
13
  id: int
12
14
  model: str
13
15
  revision: str
14
16
 
15
- @classmethod
16
- async def convert(cls, hardware: Hardware):
17
- return cls(id=hardware.id, model=hardware.model, revision=hardware.revision)
18
-
19
17
 
20
18
  class SoftwareSchema(BaseModel):
19
+ model_config = ConfigDict(from_attributes=True)
20
+
21
21
  id: int
22
- name: str
22
+ uri: str = Field(exclude=True)
23
23
  size: int
24
24
  hash: str
25
25
  version: str
26
26
  compatibility: list[HardwareSchema]
27
27
 
28
- @classmethod
29
- async def convert(cls, software: Software):
30
- return cls(
31
- id=software.id,
32
- name=software.path_user,
33
- size=software.size,
34
- hash=software.hash,
35
- version=software.version,
36
- compatibility=await asyncio.gather(*[HardwareSchema.convert(h) for h in software.compatibility]),
37
- )
28
+ @property
29
+ def path(self) -> Path:
30
+ return Path(url2pathname(unquote(urlparse(self.uri).path)))
31
+
32
+ @property
33
+ def local(self) -> bool:
34
+ return urlparse(self.uri).scheme == "file"
35
+
36
+ @computed_field # type: ignore[misc]
37
+ @property
38
+ def name(self) -> str:
39
+ if self.local:
40
+ return self.path.name
41
+ else:
42
+ return self.uri
File without changes
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel, computed_field
6
+
7
+
8
+ class DataTableSearchSchema(BaseModel):
9
+ value: str | None = None
10
+ regex: bool | None = False
11
+
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
+ class DataTableOrderDirection(StrEnum):
22
+ ASCENDING = "asc"
23
+ DESCENDING = "desc"
24
+
25
+
26
+ class DataTableOrderSchema(BaseModel):
27
+ column: int | None = None
28
+ dir: DataTableOrderDirection | None = None
29
+ name: str | None = None
30
+
31
+ @computed_field # type: ignore[misc]
32
+ @property
33
+ def direction(self) -> str:
34
+ return "-" if self.dir == DataTableOrderDirection.DESCENDING else ""
35
+
36
+
37
+ class DataTableRequest(BaseModel):
38
+ draw: int = 1
39
+ columns: list[DataTableColumnSchema] = list()
40
+ order: list[DataTableOrderSchema] = list()
41
+ start: int = 0
42
+ length: int = 0
43
+ search: DataTableSearchSchema = DataTableSearchSchema()
44
+
45
+ @computed_field # type: ignore[misc]
46
+ @property
47
+ def order_query(self) -> str | None:
48
+ try:
49
+ column = self.order[0].column
50
+ if column is None:
51
+ return None
52
+ if self.columns[column].name is None:
53
+ return None
54
+ return f"{self.order[0].direction}{self.columns[column].data}"
55
+ except LookupError:
56
+ return None
@@ -0,0 +1,32 @@
1
+ from fastapi.requests import Request
2
+
3
+ from goosebit.ui.bff.common.requests import DataTableRequest
4
+
5
+
6
+ def parse_datatables_query(request: Request):
7
+ # parsing adapted from https://github.com/ziiiio/datatable_ajax_request_parser
8
+
9
+ result = {}
10
+ for key, value in request.query_params.items():
11
+ key_list = key.replace("][", ";").replace("[", ";").replace("]", "").split(";")
12
+
13
+ if len(key_list) == 0:
14
+ continue
15
+
16
+ if len(key_list) == 1:
17
+ result[key] = value[0] if len(value) == 1 else value
18
+ continue
19
+
20
+ temp_dict = result
21
+ for inner_key in key_list[:-1]:
22
+ if inner_key not in temp_dict:
23
+ temp_dict.update({inner_key: {}})
24
+ temp_dict = temp_dict[inner_key]
25
+ temp_dict[key_list[-1]] = value[0] if len(value) == 1 else value
26
+
27
+ if result.get("columns"):
28
+ result["columns"] = [result["columns"][str(idx)] for idx, _ in enumerate(result["columns"])]
29
+ if result.get("order"):
30
+ result["order"] = [result["order"][str(idx)] for idx, _ in enumerate(result["order"])]
31
+
32
+ return DataTableRequest.model_validate(result)