Files
romm/backend/endpoints/device.py
nendo 34f48e58a1 feat(devices): default to returning existing device on duplicate registration
Change allow_existing default to True so duplicate fingerprint matches
return the existing device (200) instead of 409 Conflict. Add model
validator to force allow_existing=False when allow_duplicate is set.
2026-02-04 11:10:40 +09:00

180 lines
5.6 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 logger.logger import log
from models.device import Device
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
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
@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,
last_seen=now,
)
db_device = db_device_handler.add_device(device)
log.info(f"Registered device {device_id} for user {request.user.username}")
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}")