Skip to content

Commit

Permalink
Merge pull request #129 from UpstreamDataInc/dev_from_orm
Browse files Browse the repository at this point in the history
Use `model_validate` built into pydantic to parse DB models
  • Loading branch information
b-rowan authored Sep 20, 2024
2 parents 18abbb3 + 1fb6368 commit b3654a8
Show file tree
Hide file tree
Showing 16 changed files with 108 additions and 111 deletions.
3 changes: 2 additions & 1 deletion goosebit/api/v1/devices/device/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ async def device_get(_: Request, updater: UpdateManager = Depends(get_update_man
device = await updater.get_device()
if device is None:
raise HTTPException(404)
return await DeviceResponse.convert(device)
await device.fetch_related("assigned_software", "hardware")
return DeviceResponse.model_validate(device)


@router.get(
Expand Down
7 changes: 0 additions & 7 deletions goosebit/api/v1/devices/responses.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
from __future__ import annotations

import asyncio

from pydantic import BaseModel

from goosebit.db.models import Device
from goosebit.schema.devices import DeviceSchema


class DevicesResponse(BaseModel):
devices: list[DeviceSchema]

@classmethod
async def convert(cls, devices: list[Device]):
return cls(devices=await asyncio.gather(*[DeviceSchema.convert(d) for d in devices]))
3 changes: 2 additions & 1 deletion goosebit/api/v1/devices/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
)
async def devices_get(_: Request) -> DevicesResponse:
return await DevicesResponse.convert(await Device.all().prefetch_related("assigned_software", "hardware"))
devices = await Device.all().prefetch_related("assigned_software", "hardware")
return DevicesResponse(devices=devices)


@router.delete(
Expand Down
9 changes: 2 additions & 7 deletions goosebit/api/v1/rollouts/responses.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import asyncio
from __future__ import annotations

from pydantic import BaseModel

from goosebit.api.responses import StatusResponse
from goosebit.db.models import Rollout
from goosebit.schema.rollouts import RolloutSchema


class RolloutsPutResponse(StatusResponse):
id: int
id: int | None = None


class RolloutsResponse(BaseModel):
rollouts: list[RolloutSchema]

@classmethod
async def convert(cls, devices: list[Rollout]):
return cls(rollouts=await asyncio.gather(*[RolloutSchema.convert(d) for d in devices]))
10 changes: 7 additions & 3 deletions goosebit/api/v1/rollouts/routes.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Security
from fastapi import APIRouter, HTTPException, Security
from fastapi.requests import Request

from goosebit.api.responses import StatusResponse
from goosebit.auth import validate_user_permissions
from goosebit.db.models import Rollout
from goosebit.db.models import Rollout, Software

from .requests import RolloutsDeleteRequest, RolloutsPatchRequest, RolloutsPutRequest
from .responses import RolloutsPutResponse, RolloutsResponse
Expand All @@ -16,14 +16,18 @@
dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
)
async def rollouts_get(_: Request) -> RolloutsResponse:
return await RolloutsResponse.convert(await Rollout.all().prefetch_related("software"))
rollouts = await Rollout.all().prefetch_related("software", "software__compatibility")
return RolloutsResponse(rollouts=rollouts)


@router.post(
"",
dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
)
async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse:
software = await Software.filter(id=rollout.software_id)
if len(software) == 0:
raise HTTPException(404, f"No software with ID {rollout.software_id} found")
rollout = await Rollout.create(
name=rollout.name,
feed=rollout.feed,
Expand Down
7 changes: 0 additions & 7 deletions goosebit/api/v1/software/responses.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
from __future__ import annotations

import asyncio

from pydantic import BaseModel

from goosebit.db.models import Software
from goosebit.schema.software import SoftwareSchema


class SoftwareResponse(BaseModel):
software: list[SoftwareSchema]

@classmethod
async def convert(cls, software: list[Software]):
return cls(software=await asyncio.gather(*[SoftwareSchema.convert(f) for f in software]))
5 changes: 4 additions & 1 deletion goosebit/api/v1/software/routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import random
import string

Expand All @@ -22,7 +24,8 @@
dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
)
async def software_get(_: Request) -> SoftwareResponse:
return await SoftwareResponse.convert(await Software.all().prefetch_related("compatibility"))
software = await Software.all().prefetch_related("compatibility")
return SoftwareResponse(software=software)


@router.delete(
Expand Down
2 changes: 2 additions & 0 deletions goosebit/db/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from enum import IntEnum
from typing import Self
from urllib.parse import unquote, urlparse
Expand Down
74 changes: 39 additions & 35 deletions goosebit/schema/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
from enum import Enum, IntEnum, StrEnum
from typing import Annotated

from pydantic import BaseModel, BeforeValidator, computed_field
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, computed_field

from goosebit.db.models import Device, UpdateModeEnum, UpdateStateEnum
from goosebit.updater.manager import get_update_manager
from goosebit.db.models import UpdateModeEnum, UpdateStateEnum
from goosebit.schema.software import HardwareSchema, SoftwareSchema
from goosebit.updater.manager import DeviceUpdateManager


class ConvertableEnum(StrEnum):
Expand All @@ -26,48 +27,51 @@ def enum_factory(name: str, base: type[Enum]) -> type[ConvertableEnum]:


class DeviceSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

uuid: str
name: str | None
sw_version: str | None
sw_target_version: str | None
sw_assigned: int | None
hw_model: str
hw_revision: str

assigned_software: SoftwareSchema | None = Field(exclude=True)
hardware: HardwareSchema | None = Field(exclude=True)

feed: str
progress: int | None
last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)] # type: ignore[valid-type]
update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)] # type: ignore[valid-type]
force_update: bool
last_ip: str | None
last_seen: int | None
poll_seconds: int
last_seen: Annotated[
int | None, BeforeValidator(lambda last_seen: round(time.time() - last_seen) if last_seen is not None else None)
]

@computed_field
@computed_field # type: ignore[misc]
@property
def online(self) -> bool | None:
return self.last_seen < self.poll_seconds if self.last_seen is not None else None

@classmethod
async def convert(cls, device: Device):
manager = await get_update_manager(device.uuid)
_, target_software = await manager.get_update()
last_seen = device.last_seen
if last_seen is not None:
last_seen = round(time.time() - device.last_seen)

return cls(
uuid=device.uuid,
name=device.name,
sw_version=device.sw_version,
sw_target_version=(target_software.version if target_software is not None else None),
sw_assigned=(device.assigned_software.id if device.assigned_software is not None else None),
hw_model=device.hardware.model,
hw_revision=device.hardware.revision,
feed=device.feed,
progress=device.progress,
last_state=device.last_state,
update_mode=device.update_mode,
force_update=device.force_update,
last_ip=device.last_ip,
last_seen=last_seen,
poll_seconds=manager.poll_seconds,
)
@computed_field # type: ignore[misc]
@property
def sw_target_version(self) -> str | None:
return self.assigned_software.version if self.assigned_software is not None else None

@computed_field # type: ignore[misc]
@property
def sw_assigned(self) -> int | None:
return self.assigned_software.id if self.assigned_software is not None else None

@computed_field # type: ignore[misc]
@property
def hw_model(self) -> str | None:
return self.hardware.model if self.hardware is not None else None

@computed_field # type: ignore[misc]
@property
def hw_revision(self) -> str | None:
return self.hardware.revision if self.hardware is not None else None

@computed_field # type: ignore[misc]
@property
def poll_seconds(self) -> int:
return DeviceUpdateManager(self.uuid).poll_seconds
39 changes: 21 additions & 18 deletions goosebit/schema/rollouts.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
from __future__ import annotations

from pydantic import BaseModel
from datetime import datetime

from goosebit.db.models import Rollout
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_serializer

from goosebit.schema.software import SoftwareSchema


class RolloutSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
created_at: int
created_at: datetime
name: str | None
feed: str
sw_file: str
sw_version: str
software: SoftwareSchema = Field(exclude=True)
paused: bool
success_count: int
failure_count: int

@classmethod
async def convert(cls, rollout: Rollout):
return cls(
id=rollout.id,
created_at=int(rollout.created_at.timestamp() * 1000),
name=rollout.name,
feed=rollout.feed,
sw_file=rollout.software.path.name,
sw_version=rollout.software.version,
paused=rollout.paused,
success_count=rollout.success_count,
failure_count=rollout.failure_count,
)
@computed_field # type: ignore[misc]
@property
def sw_version(self) -> str:
return self.software.version

@computed_field # type: ignore[misc]
@property
def sw_file(self) -> str:
return self.software.path.name

@field_serializer("created_at")
def serialize_created_at(self, created_at: datetime, _info):
return int(created_at.timestamp() * 1000)
43 changes: 24 additions & 19 deletions goosebit/schema/software.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,42 @@
from __future__ import annotations

import asyncio
from urllib.parse import unquote, urlparse
from urllib.request import url2pathname

from pydantic import BaseModel

from goosebit.db.models import Hardware, Software
from anyio import Path
from pydantic import BaseModel, ConfigDict, Field, computed_field


class HardwareSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
model: str
revision: str

@classmethod
async def convert(cls, hardware: Hardware):
return cls(id=hardware.id, model=hardware.model, revision=hardware.revision)


class SoftwareSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
name: str
uri: str = Field(exclude=True)
size: int
hash: str
version: str
compatibility: list[HardwareSchema]

@classmethod
async def convert(cls, software: Software):
return cls(
id=software.id,
name=software.path_user,
size=software.size,
hash=software.hash,
version=software.version,
compatibility=await asyncio.gather(*[HardwareSchema.convert(h) for h in software.compatibility]),
)
@property
def path(self) -> Path:
return Path(url2pathname(unquote(urlparse(self.uri).path)))

@property
def local(self) -> bool:
return urlparse(self.uri).scheme == "file"

@computed_field # type: ignore[misc]
@property
def name(self) -> str:
if self.local:
return self.path.name
else:
return self.uri
6 changes: 2 additions & 4 deletions goosebit/ui/bff/devices/responses.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from __future__ import annotations

import asyncio

from fastapi.requests import Request
from pydantic import BaseModel, Field

Expand Down Expand Up @@ -33,7 +31,7 @@ async def convert(cls, request: Request, query, search_filter, total_records):
query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")

filtered_records = await query.count()
devices = await query.offset(start).limit(length).all()
data = await asyncio.gather(*[DeviceSchema.convert(d) for d in devices])
devices = await query.offset(start).limit(length).all().prefetch_related("assigned_software", "hardware")
data = [DeviceSchema.model_validate(d) for d in devices]

return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
4 changes: 1 addition & 3 deletions goosebit/ui/bff/rollouts/responses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import asyncio

from fastapi.requests import Request
from pydantic import BaseModel, Field

Expand Down Expand Up @@ -32,6 +30,6 @@ async def convert(cls, request: Request, query, search_filter, total_records):

filtered_records = await query.count()
rollouts = await query.offset(start).limit(length).all()
data = await asyncio.gather(*[RolloutSchema.convert(r) for r in rollouts])
data = [RolloutSchema.model_validate(r) for r in rollouts]

return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
2 changes: 1 addition & 1 deletion goosebit/ui/bff/rollouts/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ async def rollouts_get(request: Request) -> BFFRolloutsResponse:
def search_filter(search_value):
return Q(name__icontains=search_value) | Q(feed__icontains=search_value)

query = Rollout.all().prefetch_related("software")
query = Rollout.all().prefetch_related("software", "software__compatibility")
total_records = await Rollout.all().count()

return await BFFRolloutsResponse.convert(request, query, search_filter, total_records)
Expand Down
4 changes: 1 addition & 3 deletions goosebit/ui/bff/software/responses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import asyncio

from fastapi.requests import Request
from pydantic import BaseModel, Field

Expand Down Expand Up @@ -32,6 +30,6 @@ async def convert(cls, request: Request, query, search_filter, total_records):

filtered_records = await query.count()
devices = await query.offset(start).limit(length).all()
data = await asyncio.gather(*[SoftwareSchema.convert(d) for d in devices])
data = [SoftwareSchema.model_validate(d) for d in devices]

return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
Loading

0 comments on commit b3654a8

Please sign in to comment.