import logging import uuid from collections.abc import Mapping from datetime import datetime, timedelta from typing import Optional from sqlalchemy import text from app.dao import BaseDAO logger = logging.getLogger("app.dao.binding") class BindingDAO(BaseDAO): def start_bind(self, user_id: int, device_id: str, child_id: 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: int, user_id: int) -> None: self.db.execute( text("UPDATE device_bind_sessions SET status = 2, confirmed_at = CURRENT_TIMESTAMP WHERE id = :id"), {"id": session_id}, ) existing = self.db.execute( text("SELECT id FROM device_bindings WHERE device_id = :device_id OR child_id = :child_id"), {"device_id": device_id, "child_id": child_id}, ).scalar_one_or_none() if existing: self.db.execute( text( "UPDATE device_bindings SET child_id = :child_id, status = 1, unbound_at = NULL WHERE device_id = :device_id" ), {"device_id": device_id, "child_id": child_id}, ) else: self.db.execute( text( "INSERT INTO device_bindings (device_id, child_id, status, bound_at) VALUES (:device_id, :child_id, 1, CURRENT_TIMESTAMP)" ), {"device_id": device_id, "child_id": child_id}, ) 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, 1, CURRENT_TIMESTAMP)" ), {"device_id": device_id, "child_id": child_id, "user_id": user_id}, ) self.commit() def direct_bind(self, device_id: str, child_id: int, user_id: int) -> None: # Check if binding exists for this device_id existing_by_device = ( self.db.execute( text("SELECT id FROM device_bindings WHERE device_id = :device_id"), {"device_id": device_id}, ).fetchone() ) # Check if this child is already bound to a different device existing_by_child = ( self.db.execute( text("SELECT id FROM device_bindings WHERE child_id = :child_id AND status = 1"), {"child_id": child_id}, ).fetchone() ) if existing_by_device: # Update existing binding for this device self.db.execute( text( "UPDATE device_bindings SET child_id = :child_id, status = 1, unbound_at = NULL WHERE device_id = :device_id" ), {"device_id": device_id, "child_id": child_id}, ) 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, 2, CURRENT_TIMESTAMP)" ), {"device_id": device_id, "child_id": child_id, "user_id": user_id}, ) self.commit() return elif existing_by_child: # Child already bound to another device - update that binding self.db.execute( text( "UPDATE device_bindings SET device_id = :device_id, status = 1, unbound_at = NULL WHERE child_id = :child_id AND status = 1" ), {"device_id": device_id, "child_id": child_id}, ) # Skip INSERT since we updated 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, 2, CURRENT_TIMESTAMP)" ), {"device_id": device_id, "child_id": child_id, "user_id": user_id}, ) self.commit() return else: # Insert new binding self.db.execute( text( "INSERT INTO device_bindings (device_id, child_id, status, bound_at) VALUES (:device_id, :child_id, 1, CURRENT_TIMESTAMP)" ), {"device_id": device_id, "child_id": child_id}, ) # Add history 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, 2, CURRENT_TIMESTAMP)" ), {"device_id": device_id, "child_id": child_id, "user_id": user_id}, ) self.commit() def get_current_by_user(self, user_id: int) -> Optional[Mapping]: return ( self.db.execute( text( """ SELECT b.* FROM device_bindings b JOIN children c ON b.child_id = c.child_id WHERE c.parent_user_id = :user_id AND b.status = 1 ORDER BY b.bound_at DESC LIMIT 1 """ ), {"user_id": user_id}, ) .mappings() .first() ) def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]: return ( self.db.execute( text( """ SELECT b.* FROM device_bindings b JOIN children c ON b.child_id = c.child_id WHERE b.device_id = :device_id AND c.parent_user_id = :user_id AND b.status = 1 """ ), {"device_id": device_id, "user_id": user_id}, ) .mappings() .first() ) 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