Files
romm/backend/handler/database/device_save_sync_handler.py
nendo a6ecfb738c feat(devices): add duplicate device registration handling
Add fingerprint-based detection for duplicate device registration with
configurable behavior via new body params:
- allow_existing: return existing device if fingerprint matches
- allow_duplicate: skip fingerprint check, always create new device
- reset_syncs: clear tracked saves when reclaiming existing device

Fingerprint matching uses mac_address (primary) or hostname+platform
(fallback). Returns 409 Conflict with device_id when duplicate detected
without flags, 200 OK for existing device, 201 Created for new.
2026-02-03 20:03:50 +09:00

130 lines
3.8 KiB
Python

from collections.abc import Sequence
from datetime import datetime, timezone
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from decorators.database import begin_session
from models.device_save_sync import DeviceSaveSync
from .base_handler import DBBaseHandler
class DBDeviceSaveSyncHandler(DBBaseHandler):
@begin_session
def get_sync(
self,
device_id: str,
save_id: int,
session: Session = None, # type: ignore
) -> DeviceSaveSync | None:
return session.scalar(
select(DeviceSaveSync)
.filter_by(device_id=device_id, save_id=save_id)
.limit(1)
)
@begin_session
def get_syncs_for_device_and_saves(
self,
device_id: str,
save_ids: list[int],
session: Session = None, # type: ignore
) -> Sequence[DeviceSaveSync]:
if not save_ids:
return []
return session.scalars(
select(DeviceSaveSync).filter(
DeviceSaveSync.device_id == device_id,
DeviceSaveSync.save_id.in_(save_ids),
)
).all()
@begin_session
def upsert_sync(
self,
device_id: str,
save_id: int,
synced_at: datetime | None = None,
session: Session = None, # type: ignore
) -> DeviceSaveSync:
now = synced_at or datetime.now(timezone.utc)
existing = session.scalar(
select(DeviceSaveSync)
.filter_by(device_id=device_id, save_id=save_id)
.limit(1)
)
if existing:
session.execute(
update(DeviceSaveSync)
.where(
DeviceSaveSync.device_id == device_id,
DeviceSaveSync.save_id == save_id,
)
.values(last_synced_at=now, is_untracked=False)
.execution_options(synchronize_session="evaluate")
)
existing.last_synced_at = now
existing.is_untracked = False
return existing
else:
sync = DeviceSaveSync(
device_id=device_id,
save_id=save_id,
last_synced_at=now,
is_untracked=False,
)
session.add(sync)
session.flush()
return sync
@begin_session
def set_untracked(
self,
device_id: str,
save_id: int,
untracked: bool,
session: Session = None, # type: ignore
) -> DeviceSaveSync | None:
existing = session.scalar(
select(DeviceSaveSync)
.filter_by(device_id=device_id, save_id=save_id)
.limit(1)
)
if existing:
session.execute(
update(DeviceSaveSync)
.where(
DeviceSaveSync.device_id == device_id,
DeviceSaveSync.save_id == save_id,
)
.values(is_untracked=untracked)
.execution_options(synchronize_session="evaluate")
)
existing.is_untracked = untracked
return existing
elif untracked:
now = datetime.now(timezone.utc)
sync = DeviceSaveSync(
device_id=device_id,
save_id=save_id,
last_synced_at=now,
is_untracked=True,
)
session.add(sync)
session.flush()
return sync
return None
@begin_session
def delete_syncs_for_device(
self,
device_id: str,
session: Session = None, # type: ignore
) -> None:
session.execute(
delete(DeviceSaveSync)
.where(DeviceSaveSync.device_id == device_id)
.execution_options(synchronize_session="evaluate")
)