import logging import uuid from collections.abc import Mapping from datetime import datetime, timedelta from typing import Optional from sqlalchemy import text from sqlalchemy.exc import IntegrityError from app.dao import BaseDAO logger = logging.getLogger("app.dao.binding") class BindingDAO(BaseDAO): def _upsert_parent_child_relation(self, user_id: int, child_id: int) -> None: updated = self.db.execute( text( """ UPDATE parent_child_relations SET status = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = :user_id AND child_id = :child_id """ ), {"user_id": user_id, "child_id": child_id}, ) if updated.rowcount and updated.rowcount > 0: return try: self.db.execute( text( """ INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status) VALUES (:user_id, :child_id, 9, 0, 1) """ ), {"user_id": user_id, "child_id": child_id}, ) except IntegrityError: # Handle race: another transaction inserted the same (user_id, child_id) row. self.db.execute( text( """ UPDATE parent_child_relations SET status = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = :user_id AND child_id = :child_id """ ), {"user_id": user_id, "child_id": child_id}, ) def _insert_bind_history(self, device_id: str, child_id: Optional[int], user_id: int, bind_source: int) -> None: self.db.execute( text( """ INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, bind_source, bound_at) VALUES (:device_id, :child_id, :user_id, :bind_source, CURRENT_TIMESTAMP) """ ), { "device_id": device_id, "child_id": child_id, "user_id": user_id, "bind_source": bind_source, }, ) def _bind_device(self, device_id: str, user_id: int, child_id: Optional[int]) -> None: existing_by_device = ( self.db.execute( text("SELECT id FROM device_bindings WHERE device_id = :device_id"), {"device_id": device_id}, ) .mappings() .first() ) if child_id is None: if existing_by_device: self.db.execute( text( """ UPDATE device_bindings SET owner_user_id = :owner_user_id, child_id = NULL, status = 1, unbound_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE device_id = :device_id """ ), {"owner_user_id": user_id, "device_id": device_id}, ) else: self.db.execute( text( """ INSERT INTO device_bindings (device_id, owner_user_id, child_id, status, bound_at) VALUES (:device_id, :owner_user_id, NULL, 1, CURRENT_TIMESTAMP) """ ), {"device_id": device_id, "owner_user_id": user_id}, ) return existing_by_child = ( self.db.execute( text("SELECT id FROM device_bindings WHERE child_id = :child_id"), {"child_id": child_id}, ) .mappings() .first() ) if existing_by_device: device_row_id = int(existing_by_device["id"]) if existing_by_child and int(existing_by_child["id"]) != device_row_id: # Release child ownership from a different row before assigning to this device. self.db.execute( text( """ UPDATE device_bindings SET child_id = NULL, status = 0, unbound_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = :id """ ), {"id": existing_by_child["id"]}, ) self.db.execute( text( """ UPDATE device_bindings SET owner_user_id = :owner_user_id, child_id = :child_id, status = 1, unbound_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE device_id = :device_id """ ), {"owner_user_id": user_id, "child_id": child_id, "device_id": device_id}, ) return if existing_by_child: self.db.execute( text( """ UPDATE device_bindings SET device_id = :device_id, owner_user_id = :owner_user_id, status = 1, unbound_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = :id """ ), {"device_id": device_id, "owner_user_id": user_id, "id": existing_by_child["id"]}, ) return self.db.execute( text( """ INSERT INTO device_bindings (device_id, owner_user_id, child_id, status, bound_at) VALUES (:device_id, :owner_user_id, :child_id, 1, CURRENT_TIMESTAMP) """ ), {"device_id": device_id, "owner_user_id": user_id, "child_id": child_id}, ) def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> str: bind_token = str(uuid.uuid4()) expires_at = datetime.utcnow() + timedelta(minutes=10) self.db.execute( text( """ INSERT INTO device_bind_sessions (bind_token, device_id, initiator_user_id, target_child_id, expires_at, status) VALUES (:bind_token, :device_id, :initiator_user_id, :target_child_id, :expires_at, 1) """ ), { "bind_token": bind_token, "device_id": device_id, "initiator_user_id": user_id, "target_child_id": child_id, "expires_at": expires_at, }, ) self.commit() return bind_token def get_session(self, bind_token: str, user_id: int) -> Optional[Mapping]: return ( self.db.execute( text( "SELECT * FROM device_bind_sessions WHERE bind_token = :bind_token AND initiator_user_id = :user_id" ), {"bind_token": bind_token, "user_id": user_id}, ) .mappings() .first() ) def confirm_bind(self, session_id: int, device_id: str, child_id: Optional[int], user_id: int) -> None: if child_id is not None: self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) self.db.execute( text("UPDATE device_bind_sessions SET status = 2, confirmed_at = CURRENT_TIMESTAMP WHERE id = :id"), {"id": session_id}, ) self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=1) self.commit() def direct_bind(self, device_id: str, child_id: Optional[int], user_id: int) -> None: if child_id is not None: self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=2) self.commit() def get_current_by_user(self, user_id: int) -> Optional[Mapping]: return ( self.db.execute( text( """ SELECT * FROM device_bindings WHERE owner_user_id = :user_id AND status = 1 ORDER BY bound_at DESC LIMIT 1 """ ), {"user_id": user_id}, ) .mappings() .first() ) def list_by_user(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]: params = {"user_id": user_id, "limit": limit + 1} where = "db.owner_user_id = :user_id AND db.status = 1" if cursor is not None: where += " AND db.id < :cursor" params["cursor"] = cursor rows = ( self.db.execute( text( f""" SELECT db.id, db.device_id, db.child_id, c.child_name, db.status, db.bound_at FROM device_bindings AS db LEFT JOIN children AS c ON c.child_id = db.child_id AND c.status = 1 WHERE {where} ORDER BY db.id DESC LIMIT :limit """ ), params, ) .mappings() .all() ) return rows def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]: return ( self.db.execute( text( """ SELECT * FROM device_bindings WHERE device_id = :device_id AND owner_user_id = :user_id AND status = 1 """ ), {"device_id": device_id, "user_id": user_id}, ) .mappings() .first() ) def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> bool: row = self.get_by_device(device_id=device_id, user_id=user_id) if not row: return False self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=3) self.commit() return True def unbind(self, device_id: str, user_id: int) -> bool: row = self.get_by_device(device_id, user_id) if not row: return False self.db.execute( text("UPDATE device_bindings SET status = 0, unbound_at = CURRENT_TIMESTAMP WHERE id = :id"), {"id": row["id"]}, ) self.db.execute( text( "INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, unbound_by_user_id, bind_source, bound_at, unbound_at, unbind_reason) SELECT device_id, child_id, bound_by_user_id, :user_id, bind_source, bound_at, CURRENT_TIMESTAMP, 'user_unbind' FROM device_bind_history WHERE device_id = :device_id AND unbound_at IS NULL" ), {"device_id": device_id, "user_id": user_id}, ) self.commit() return True def list_history(self, device_id: str, limit: int = 20, cursor: datetime = None) -> list[Mapping]: params = {"device_id": device_id, "limit": limit + 1} where = "device_id = :device_id" if cursor: where += " AND bound_at < :cursor" params["cursor"] = cursor rows = ( self.db.execute( text( f""" SELECT * FROM device_bind_history WHERE {where} ORDER BY bound_at DESC LIMIT :limit """ ), params, ) .mappings() .all() ) return rows