feat: 支持先绑定设备后关联儿童档案
- 绑定模型新增 owner_user_id,支持 child_id 为空\n- 新增绑定后设置儿童接口 /bindings/{device_id}/child\n- 前端绑定页改为先绑设备后补充儿童\n- 补充数据库迁移脚本与 DAO 测试用例
This commit is contained in:
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.dao import BaseDAO
|
||||
|
||||
@@ -12,7 +13,172 @@ logger = logging.getLogger("app.dao.binding")
|
||||
|
||||
|
||||
class BindingDAO(BaseDAO):
|
||||
def start_bind(self, user_id: int, device_id: str, child_id: int) -> str:
|
||||
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)
|
||||
|
||||
@@ -46,106 +212,25 @@ class BindingDAO(BaseDAO):
|
||||
.first()
|
||||
)
|
||||
|
||||
def confirm_bind(self, session_id: int, device_id: str, child_id: int, user_id: int) -> None:
|
||||
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},
|
||||
)
|
||||
|
||||
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._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: 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()
|
||||
)
|
||||
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)
|
||||
|
||||
# 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._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]:
|
||||
@@ -153,10 +238,11 @@ class BindingDAO(BaseDAO):
|
||||
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
|
||||
SELECT *
|
||||
FROM device_bindings
|
||||
WHERE owner_user_id = :user_id
|
||||
AND status = 1
|
||||
ORDER BY bound_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
@@ -171,9 +257,11 @@ class BindingDAO(BaseDAO):
|
||||
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
|
||||
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},
|
||||
@@ -182,6 +270,17 @@ class BindingDAO(BaseDAO):
|
||||
.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:
|
||||
@@ -223,4 +322,4 @@ class BindingDAO(BaseDAO):
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return rows
|
||||
return rows
|
||||
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy import (
|
||||
Text,
|
||||
Time,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -31,24 +32,20 @@ class Parent(Base):
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
status: Mapped[int] = mapped_column(Integer, server_default="1")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class Child(Base):
|
||||
__tablename__ = "children"
|
||||
__table_args__ = (
|
||||
Index("idx_child_parent_user_id", "parent_user_id"),
|
||||
)
|
||||
|
||||
child_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
parent_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
child_name: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
child_gender: Mapped[int] = mapped_column(Integer, server_default="2")
|
||||
child_birthday: Mapped[Optional[date]] = mapped_column(Date)
|
||||
status: Mapped[int] = mapped_column(Integer, server_default="1")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class ParentChildRelation(Base):
|
||||
@@ -65,24 +62,27 @@ class ParentChildRelation(Base):
|
||||
relation_type: Mapped[int] = mapped_column(Integer, server_default="9")
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, server_default="0")
|
||||
status: Mapped[int] = mapped_column(Integer, server_default="1")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class DeviceBinding(Base):
|
||||
__tablename__ = "device_bindings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("device_id", name="uq_device_binding_device"),
|
||||
UniqueConstraint("child_id", name="uq_device_binding_child"),
|
||||
Index("idx_device_bindings_owner_user_id", "owner_user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
child_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
owner_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
child_id: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
status: Mapped[int] = mapped_column(Integer, server_default="1")
|
||||
bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class DeviceBindSession(Base):
|
||||
@@ -101,8 +101,8 @@ class DeviceBindSession(Base):
|
||||
status: Mapped[int] = mapped_column(Integer, server_default="1")
|
||||
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
consumed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class DeviceBindHistory(Base):
|
||||
@@ -110,14 +110,14 @@ class DeviceBindHistory(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
child_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
child_id: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
bound_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
unbound_by_user_id: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
bind_source: Mapped[int] = mapped_column(Integer, server_default="1")
|
||||
bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
unbind_reason: Mapped[Optional[str]] = mapped_column(String(191))
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class Card(Base):
|
||||
@@ -129,8 +129,8 @@ class Card(Base):
|
||||
card_name: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
status: Mapped[int] = mapped_column(Integer, server_default="0")
|
||||
total_swaps: Mapped[int] = mapped_column(Integer, server_default="0")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class DeviceSetting(Base):
|
||||
@@ -141,14 +141,15 @@ class DeviceSetting(Base):
|
||||
sleep_mode: Mapped[int] = mapped_column(Integer, server_default="0")
|
||||
disable_time_start: Mapped[Optional[time]] = mapped_column(Time)
|
||||
disable_time_end: Mapped[Optional[time]] = mapped_column(Time)
|
||||
timezone: Mapped[str] = mapped_column(String(32), server_default="'Asia/Shanghai'")
|
||||
timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'"))
|
||||
volume: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
brightness: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
onupdate=datetime.utcnow,
|
||||
)
|
||||
|
||||
|
||||
@@ -255,4 +256,4 @@ class ChildLocationHistory(Base):
|
||||
battery_pct: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
device_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
server_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
@@ -19,7 +20,7 @@ logger = logging.getLogger("app.bindings")
|
||||
|
||||
class BindStartRequest(BaseModel):
|
||||
device_id: str
|
||||
child_id: int
|
||||
child_id: int | None = None
|
||||
|
||||
|
||||
class BindStartResponse(BaseModel):
|
||||
@@ -34,21 +35,21 @@ class BindConfirmRequest(BaseModel):
|
||||
|
||||
class BindConfirmResponse(BaseModel):
|
||||
device_id: str
|
||||
child_id: int
|
||||
child_id: int | None
|
||||
|
||||
|
||||
class BindingGetResponse(BaseModel):
|
||||
device_id: str
|
||||
child_id: int
|
||||
child_id: int | None
|
||||
status: int
|
||||
bound_at: str
|
||||
bound_at: datetime
|
||||
|
||||
|
||||
class BindHistoryItem(BaseModel):
|
||||
device_id: str
|
||||
child_id: int
|
||||
bound_at: str
|
||||
unbound_at: str | None
|
||||
child_id: int | None
|
||||
bound_at: datetime
|
||||
unbound_at: datetime | None
|
||||
|
||||
|
||||
class BindHistoryResponse(BaseModel):
|
||||
@@ -86,11 +87,15 @@ def confirm_bind(
|
||||
|
||||
class DirectBindRequest(BaseModel):
|
||||
device_id: str
|
||||
child_id: int
|
||||
child_id: int | None = None
|
||||
|
||||
|
||||
class DirectBindResponse(BaseModel):
|
||||
device_id: str
|
||||
child_id: int | None
|
||||
|
||||
|
||||
class BindSetChildRequest(BaseModel):
|
||||
child_id: int
|
||||
|
||||
|
||||
@@ -106,6 +111,22 @@ def direct_bind(
|
||||
return DirectBindResponse(**result)
|
||||
|
||||
|
||||
@router.patch("/{device_id}/child", response_model=DirectBindResponse)
|
||||
def set_binding_child(
|
||||
device_id: str,
|
||||
payload: BindSetChildRequest,
|
||||
request: Request,
|
||||
current_user_id: int = Depends(get_current_user_id),
|
||||
db=Depends(get_db_session),
|
||||
) -> DirectBindResponse:
|
||||
service = BindingService(db)
|
||||
try:
|
||||
result = service.set_binding_child(device_id=device_id, child_id=payload.child_id, user_id=current_user_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return DirectBindResponse(**result)
|
||||
|
||||
|
||||
@router.get("/current", response_model=BindingGetResponse)
|
||||
def get_current_binding(
|
||||
request: Request,
|
||||
@@ -154,11 +175,9 @@ def get_bind_history(
|
||||
current_user_id: int = Depends(get_current_user_id),
|
||||
db=Depends(get_db_session),
|
||||
) -> BindHistoryResponse:
|
||||
from datetime import datetime
|
||||
|
||||
cursor_dt = datetime.fromisoformat(cursor) if cursor else None
|
||||
service = BindingService(db)
|
||||
rows, has_more = service.list_history(device_id, limit, cursor_dt)
|
||||
next_cursor = rows[-1]["bound_at"].isoformat() if has_more and rows else None
|
||||
items = [BindHistoryItem(**row) for row in rows]
|
||||
return BindHistoryResponse(items=items, total=len(items), next_cursor=next_cursor)
|
||||
return BindHistoryResponse(items=items, total=len(items), next_cursor=next_cursor)
|
||||
|
||||
@@ -9,7 +9,7 @@ class BindingService:
|
||||
def __init__(self, db):
|
||||
self.dao = BindingDAO(db)
|
||||
|
||||
def start_bind(self, user_id: int, device_id: str, child_id: int) -> tuple[str, datetime]:
|
||||
def start_bind(self, user_id: int, device_id: str, child_id: int | None = None) -> tuple[str, datetime]:
|
||||
bind_token = self.dao.start_bind(user_id, device_id, child_id)
|
||||
return bind_token, datetime.utcnow()
|
||||
|
||||
@@ -31,10 +31,16 @@ class BindingService:
|
||||
def get_current_binding(self, user_id: int) -> Optional[Mapping]:
|
||||
return self.dao.get_current_by_user(user_id)
|
||||
|
||||
def direct_bind(self, device_id: str, child_id: int, user_id: int) -> Mapping:
|
||||
def direct_bind(self, device_id: str, child_id: int | None, user_id: int) -> Mapping:
|
||||
self.dao.direct_bind(device_id, child_id, user_id)
|
||||
return {"device_id": device_id, "child_id": child_id}
|
||||
|
||||
def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> Mapping:
|
||||
ok = self.dao.set_binding_child(device_id=device_id, child_id=child_id, user_id=user_id)
|
||||
if not ok:
|
||||
raise ValueError("binding not found")
|
||||
return {"device_id": device_id, "child_id": child_id}
|
||||
|
||||
def unbind(self, device_id: str, user_id: int) -> bool:
|
||||
return self.dao.unbind(device_id, user_id)
|
||||
|
||||
@@ -42,4 +48,4 @@ class BindingService:
|
||||
rows = self.dao.list_history(device_id, limit, cursor)
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
return rows, has_more
|
||||
return rows, has_more
|
||||
|
||||
Reference in New Issue
Block a user