384 lines
13 KiB
Python
384 lines
13 KiB
Python
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 get_device_auth(self, device_id: str) -> Optional[Mapping]:
|
|
return (
|
|
self.db.execute(
|
|
text(
|
|
"""
|
|
SELECT device_id, serial_number, is_active
|
|
FROM device_auth
|
|
WHERE device_id = :device_id
|
|
LIMIT 1
|
|
"""
|
|
),
|
|
{"device_id": device_id},
|
|
)
|
|
.mappings()
|
|
.first()
|
|
)
|
|
|
|
def _clear_child_from_binding(self, binding_row: Mapping[str, object]) -> None:
|
|
row_id = int(binding_row["id"])
|
|
status = int(binding_row["status"])
|
|
|
|
if status == 1:
|
|
self.db.execute(
|
|
text(
|
|
"""
|
|
UPDATE device_bindings
|
|
SET child_id = NULL,
|
|
status = 1,
|
|
unbound_at = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = :id
|
|
"""
|
|
),
|
|
{"id": row_id},
|
|
)
|
|
return
|
|
|
|
self.db.execute(
|
|
text(
|
|
"""
|
|
UPDATE device_bindings
|
|
SET child_id = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = :id
|
|
"""
|
|
),
|
|
{"id": row_id},
|
|
)
|
|
|
|
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, status 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, status 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:
|
|
# Keep the old device row visible; only release its child assignment.
|
|
self._clear_child_from_binding(existing_by_child)
|
|
|
|
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:
|
|
# Do not overwrite the previous device row; keep it as an unbound device.
|
|
self._clear_child_from_binding(existing_by_child)
|
|
|
|
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
|