Files
romm/backend/endpoints/device.py
Claude 26bdc11e13 refactor(filesystem): lazy-init launchbox + sync handlers, drop tolerate_missing_base
Apply the same lazy-factory pattern to FSLaunchboxHandler and FSSyncHandler
that ssh_sync_handler now uses. With both opt-in features deferred to
first-use, the tolerate_missing_base escape hatch on FSHandler is no longer
needed — every handler now fails loudly on mkdir failure, which is the
right behavior for the always-on core paths (assets, library, resources).

Touched call sites:
  - resources_handler._resolve_local_file_uri (launchbox)
  - sync_watcher.py, endpoints/device.py, tasks/manual/sync_folder_scan.py
    (fs_sync)

Net effect:
  - Default installs never poke /romm/launchbox or /romm/sync at startup.
  - Misconfigured opt-in users get a clear, actionable PermissionError at
    the call site instead of a silent warning followed by mystery failures.
  - tolerate_missing_base, its tests, and one stale log import are gone.
2026-05-24 14:59:03 +00:00

194 lines
6.2 KiB
Python

import uuid
from datetime import datetime, timezone
from fastapi import HTTPException, Request, Response, status
from pydantic import BaseModel, model_validator
from decorators.auth import protected_route
from endpoints.responses.device import DeviceCreateResponse, DeviceSchema
from handler.auth.constants import Scope
from handler.database import db_device_handler, db_device_save_sync_handler
from handler.filesystem import get_fs_sync_handler
from logger.logger import log
from models.device import Device, SyncMode
from utils.router import APIRouter
router = APIRouter(
prefix="/devices",
tags=["devices"],
)
class DeviceCreatePayload(BaseModel):
name: str | None = None
platform: str | None = None
client: str | None = None
client_version: str | None = None
ip_address: str | None = None
mac_address: str | None = None
hostname: str | None = None
sync_mode: SyncMode | None = None
sync_config: dict | None = None
allow_existing: bool = True
allow_duplicate: bool = False
reset_syncs: bool = False
@model_validator(mode="after")
def _duplicate_disables_existing(self) -> "DeviceCreatePayload":
if self.allow_duplicate:
self.allow_existing = False
return self
class DeviceUpdatePayload(BaseModel):
name: str | None = None
platform: str | None = None
client: str | None = None
client_version: str | None = None
ip_address: str | None = None
mac_address: str | None = None
hostname: str | None = None
sync_enabled: bool | None = None
sync_mode: SyncMode | None = None
sync_config: dict | None = None
@protected_route(router.post, "", [Scope.DEVICES_WRITE])
def register_device(
request: Request,
response: Response,
payload: DeviceCreatePayload,
) -> DeviceCreateResponse:
existing_device = None
if not payload.allow_duplicate:
existing_device = db_device_handler.get_device_by_fingerprint(
user_id=request.user.id,
mac_address=payload.mac_address,
hostname=payload.hostname,
platform=payload.platform,
)
if existing_device:
if not payload.allow_existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error": "device_exists",
"message": "A device with this fingerprint already exists",
"device_id": existing_device.id,
},
)
if payload.reset_syncs:
db_device_save_sync_handler.delete_syncs_for_device(
device_id=existing_device.id
)
db_device_handler.update_last_seen(
device_id=existing_device.id, user_id=request.user.id
)
log.info(
f"Returned existing device {existing_device.id} for user {request.user.username}"
)
response.status_code = status.HTTP_200_OK
return DeviceCreateResponse(
device_id=existing_device.id,
name=existing_device.name,
created_at=existing_device.created_at,
)
response.status_code = status.HTTP_201_CREATED
device_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
device = Device(
id=device_id,
user_id=request.user.id,
name=payload.name,
platform=payload.platform,
client=payload.client,
client_version=payload.client_version,
ip_address=payload.ip_address,
mac_address=payload.mac_address,
hostname=payload.hostname,
sync_mode=payload.sync_mode,
sync_config=payload.sync_config,
last_seen=now,
)
db_device = db_device_handler.add_device(device)
log.info(f"Registered device {device_id} for user {request.user.username}")
# Auto-create sync folders for file_transfer devices
if payload.sync_mode == SyncMode.FILE_TRANSFER:
try:
get_fs_sync_handler().ensure_device_directories(device_id)
except Exception:
log.warning(f"Failed to create sync directories for device {device_id}")
return DeviceCreateResponse(
device_id=db_device.id,
name=db_device.name,
created_at=db_device.created_at,
)
@protected_route(router.get, "", [Scope.DEVICES_READ])
def get_devices(request: Request) -> list[DeviceSchema]:
devices = db_device_handler.get_devices(user_id=request.user.id)
return [DeviceSchema.model_validate(device) for device in devices]
@protected_route(router.get, "/{device_id}", [Scope.DEVICES_READ])
def get_device(request: Request, device_id: str) -> DeviceSchema:
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device with ID {device_id} not found",
)
return DeviceSchema.model_validate(device)
@protected_route(router.put, "/{device_id}", [Scope.DEVICES_WRITE])
def update_device(
request: Request,
device_id: str,
payload: DeviceUpdatePayload,
) -> DeviceSchema:
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device with ID {device_id} not found",
)
update_data = payload.model_dump(exclude_unset=True)
if update_data:
device = db_device_handler.update_device(
device_id=device_id,
user_id=request.user.id,
data=update_data,
)
return DeviceSchema.model_validate(device)
@protected_route(
router.delete,
"/{device_id}",
[Scope.DEVICES_WRITE],
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_device(request: Request, device_id: str) -> None:
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device with ID {device_id} not found",
)
db_device_handler.delete_device(device_id=device_id, user_id=request.user.id)
log.info(f"Deleted device {device_id} for user {request.user.username}")