feat: 支持先绑定设备后关联儿童档案

- 绑定模型新增 owner_user_id,支持 child_id 为空\n- 新增绑定后设置儿童接口 /bindings/{device_id}/child\n- 前端绑定页改为先绑设备后补充儿童\n- 补充数据库迁移脚本与 DAO 测试用例
This commit is contained in:
ChengCan
2026-04-14 12:47:52 +08:00
parent a22fcafea9
commit 1b4c47f77a
11 changed files with 699 additions and 246 deletions

View File

@@ -1,8 +1,8 @@
import { View, Text, Button, Image, Input } from '@tarojs/components' import { View, Text, Image, Input } from '@tarojs/components'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { getToken } from '@/services/api' import { getToken } from '@/services/api'
import { getCurrentBinding, directBind } from '@/services/binding' import { getCurrentBinding, directBind, setBindingChild } from '@/services/binding'
import { getChildren, createChild } from '@/services/child' import { getChildren, createChild } from '@/services/child'
import './index.scss' import './index.scss'
@@ -17,11 +17,23 @@ export default function Bind() {
const [children, setChildren] = useState<Child[]>([]) const [children, setChildren] = useState<Child[]>([])
const [newChildName, setNewChildName] = useState('') const [newChildName, setNewChildName] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
checkAuth() checkAuth()
}, []) }, [])
const goDevicePage = () => {
setTimeout(() => {
Taro.reLaunch({ url: '/pages/device/index' })
}, 500)
}
const completeChildLink = async (deviceId: string, childId: number) => {
await setBindingChild(deviceId, { child_id: childId })
setPendingDeviceId(null)
}
const checkAuth = async () => { const checkAuth = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
@@ -31,31 +43,65 @@ export default function Bind() {
try { try {
const childRes = await getChildren() const childRes = await getChildren()
setChildren(childRes.items || []) const currentChildren = childRes.items || []
setChildren(currentChildren)
if (childRes.items?.length === 0) {
setLoading(false)
return
}
} catch (err) {
console.error('Get children failed:', err)
}
try {
const binding = await getCurrentBinding() const binding = await getCurrentBinding()
if (binding) { if (binding) {
Taro.showToast({ title: '已有设备绑定', icon: 'none' }) if (binding.child_id) {
setTimeout(() => { Taro.showToast({ title: 'Already bound', icon: 'none' })
Taro.reLaunch({ url: '/pages/device/index' }) goDevicePage()
}, 1000)
return return
} }
} catch (err) {
setPendingDeviceId(binding.device_id)
if (currentChildren.length > 0) {
await completeChildLink(binding.device_id, currentChildren[0].child_id)
Taro.showToast({ title: 'Binding completed', icon: 'success' })
goDevicePage()
return
} }
}
} catch (err) {
console.error('Load bind page data failed:', err)
} finally {
setLoading(false) setLoading(false)
} }
}
const doBind = async (deviceId: string) => {
Taro.showLoading({ title: 'Binding...' })
try {
await directBind({ device_id: deviceId })
if (children.length > 0) {
await completeChildLink(deviceId, children[0].child_id)
Taro.hideLoading()
Taro.showToast({
title: 'Bind success',
icon: 'success',
duration: 1200,
})
goDevicePage()
return
}
setPendingDeviceId(deviceId)
Taro.hideLoading()
Taro.showToast({ title: 'Device bound, add child next', icon: 'none' })
} catch (err: any) {
Taro.hideLoading()
Taro.showToast({ title: err.message || 'Bind failed', icon: 'none' })
}
}
const handleScanCode = () => { const handleScanCode = () => {
if (pendingDeviceId) {
Taro.showToast({ title: 'Complete child setup first', icon: 'none' })
return
}
setIsScanning(true) setIsScanning(true)
Taro.scanCode({ Taro.scanCode({
@@ -66,7 +112,7 @@ export default function Bind() {
const deviceId = res.result const deviceId = res.result
if (!deviceId) { if (!deviceId) {
Taro.showToast({ title: '无效的设备码', icon: 'none' }) Taro.showToast({ title: 'Invalid device code', icon: 'none' })
return return
} }
@@ -77,98 +123,79 @@ export default function Bind() {
if (err.errMsg && err.errMsg.includes('cancel')) { if (err.errMsg && err.errMsg.includes('cancel')) {
return return
} }
Taro.showToast({ title: '扫码失败', icon: 'none' }) Taro.showToast({ title: 'Scan failed', icon: 'none' })
} },
}) })
} }
const doBind = async (deviceId: string) => {
if (children.length === 0) {
Taro.showToast({ title: '请先添加儿童', icon: 'none' })
return
}
Taro.showLoading({ title: '绑定中...' })
try {
await directBind({
device_id: deviceId,
child_id: children[0].child_id
})
Taro.hideLoading()
Taro.showToast({
title: '设备绑定成功',
icon: 'success',
duration: 1500,
complete: () => {
setTimeout(() => {
Taro.reLaunch({ url: '/pages/device/index' })
}, 500)
}
})
} catch (err: any) {
Taro.hideLoading()
Taro.showToast({ title: err.message || '绑定失败', icon: 'none' })
}
}
const handleManualInput = () => { const handleManualInput = () => {
if (pendingDeviceId) {
Taro.showToast({ title: 'Complete child setup first', icon: 'none' })
return
}
Taro.showModal({ Taro.showModal({
title: '手动输入设备码', title: 'Manual input',
editable: true, editable: true,
placeholderText: '请输入设备底部的设备码', placeholderText: 'Enter device code',
success: (res) => { success: (res) => {
if (res.confirm && res.content) { if (res.confirm && res.content) {
doBind(res.content) doBind(res.content)
} }
} },
}) })
} }
const handleAddChild = async () => { const handleAddChild = async () => {
if (!newChildName.trim()) { if (!newChildName.trim()) {
Taro.showToast({ title: '请输入儿童姓名', icon: 'none' }) Taro.showToast({ title: 'Please enter child name', icon: 'none' })
return return
} }
setSaving(true) setSaving(true)
try { try {
await createChild({ child_name: newChildName.trim() }) const child = await createChild({ child_name: newChildName.trim() })
Taro.showToast({ title: '添加成功', icon: 'success' }) setNewChildName('')
if (pendingDeviceId) {
await completeChildLink(pendingDeviceId, child.child_id)
Taro.showToast({ title: 'Child linked', icon: 'success' })
goDevicePage()
return
}
const childRes = await getChildren() const childRes = await getChildren()
setChildren(childRes.items || []) setChildren(childRes.items || [])
Taro.showToast({ title: 'Child created', icon: 'success' })
} catch (err) { } catch (err) {
Taro.showToast({ title: '添加失败', icon: 'none' }) Taro.showToast({ title: 'Create child failed', icon: 'none' })
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const handleLogout = () => {
Taro.showModal({
title: 'Logout',
content: 'Clear login info and go to login page?',
confirmColor: '#FF8C42',
success: (res) => {
if (!res.confirm) return
Taro.removeStorageSync('token')
Taro.removeStorageSync('user_id')
Taro.removeStorageSync('expires_in')
Taro.removeStorageSync('userInfo')
Taro.removeStorageSync('hasDevice')
Taro.removeStorageSync('deviceInfo')
Taro.reLaunch({ url: '/pages/login/index' })
},
})
}
if (loading) { if (loading) {
return ( return (
<View className='bind-page'> <View className='bind-page'>
<View className='loading'>...</View> <View className='loading'>Loading...</View>
</View>
)
}
if (children.length === 0) {
return (
<View className='bind-page'>
<View className='empty-tip'>
<Text className='empty-title'></Text>
<Text className='empty-desc'></Text>
<View className='empty-input-wrap'>
<Input
className='empty-input'
placeholder='请输入儿童姓名'
value={newChildName}
onInput={(e) => setNewChildName(e.detail.value)}
/>
</View>
<View className='empty-btn' onClick={handleAddChild}>
<Text className='empty-btn-text'>{saving ? '保存中...' : '确认添加'}</Text>
</View>
</View>
</View> </View>
) )
} }
@@ -176,8 +203,8 @@ export default function Bind() {
return ( return (
<View className='bind-page'> <View className='bind-page'>
<View className='bind-header'> <View className='bind-header'>
<Text className='title'></Text> <Text className='title'>Bind Device</Text>
<Text className='subtitle'></Text> <Text className='subtitle'>Scan QR/device code to bind first, then set child profile.</Text>
</View> </View>
<View className='scan-area'> <View className='scan-area'>
@@ -189,8 +216,8 @@ export default function Bind() {
mode='aspectFit' mode='aspectFit'
/> />
</View> </View>
<Text className='scan-text'>{isScanning ? '正在扫码...' : '点击扫码'}</Text> <Text className='scan-text'>{isScanning ? 'Scanning...' : 'Tap to scan'}</Text>
<Text className='scan-hint'></Text> <Text className='scan-hint'>Place QR/barcode in frame</Text>
</View> </View>
<View className='scan-corners'> <View className='scan-corners'>
@@ -203,25 +230,49 @@ export default function Bind() {
<View className='bind-options'> <View className='bind-options'>
<View className='option-item' onClick={handleManualInput}> <View className='option-item' onClick={handleManualInput}>
<Text className='option-icon'></Text> <Text className='option-icon'>#</Text>
<Text className='option-text'></Text> <Text className='option-text'>Manual device code input</Text>
<Text className='option-arrow'></Text> <Text className='option-arrow'>{'>'}</Text>
</View> </View>
</View> </View>
{pendingDeviceId && (
<View className='empty-tip'>
<Text className='empty-title'>Device bound: {pendingDeviceId}</Text>
<Text className='empty-desc'>Add child profile to complete setup</Text>
<View className='empty-input-wrap'>
<Input
className='empty-input'
placeholder='Enter child name'
value={newChildName}
onInput={(e) => setNewChildName(e.detail.value)}
/>
</View>
<View className='empty-btn' onClick={handleAddChild}>
<Text className='empty-btn-text'>{saving ? 'Saving...' : 'Create and link child'}</Text>
</View>
</View>
)}
<View className='bind-tips'> <View className='bind-tips'>
<Text className='tips-title'></Text> <Text className='tips-title'>Binding Tips</Text>
<View className='tip-item'> <View className='tip-item'>
<Text className='tip-number'>1</Text> <Text className='tip-number'>1</Text>
<Text className='tip-text'></Text> <Text className='tip-text'>Find QR code or device code on device.</Text>
</View> </View>
<View className='tip-item'> <View className='tip-item'>
<Text className='tip-number'>2</Text> <Text className='tip-number'>2</Text>
<Text className='tip-text'></Text> <Text className='tip-text'>Bind device first by scan/manual input.</Text>
</View> </View>
<View className='tip-item'> <View className='tip-item'>
<Text className='tip-number'>3</Text> <Text className='tip-number'>3</Text>
<Text className='tip-text'></Text> <Text className='tip-text'>Create or select child profile to finish setup.</Text>
</View>
</View>
<View className='bind-footer'>
<View className='logout-btn' onClick={handleLogout}>
<Text className='logout-btn-text'>Logout</Text>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -40,6 +40,15 @@ export default function Device() {
console.log('[Device] Got binding:', binding) console.log('[Device] Got binding:', binding)
if (binding) { if (binding) {
if (!binding.child_id) {
setHasDevice(false)
Taro.showToast({ title: 'Please link child profile first', icon: 'none' })
setTimeout(() => {
Taro.redirectTo({ url: '/pages/bind/index' })
}, 600)
return
}
setHasDevice(true) setHasDevice(true)
Taro.setStorageSync('hasDevice', '1') Taro.setStorageSync('hasDevice', '1')
Taro.setStorageSync('deviceInfo', { Taro.setStorageSync('deviceInfo', {

View File

@@ -15,7 +15,7 @@ interface Child {
interface Binding { interface Binding {
device_id: string device_id: string
child_id: number child_id: number | null
status: number status: number
} }

View File

@@ -2,7 +2,7 @@ import { request } from './api'
export interface Binding { export interface Binding {
device_id: string device_id: string
child_id: number child_id: number | null
status: number status: number
bound_at: string bound_at: string
} }
@@ -14,7 +14,7 @@ export interface BindStartResponse {
export interface BindHistoryItem { export interface BindHistoryItem {
device_id: string device_id: string
child_id: number child_id: number | null
bound_at: string bound_at: string
unbound_at?: string unbound_at?: string
} }
@@ -40,7 +40,7 @@ export async function getCurrentBinding(): Promise<Binding | null> {
// 开始绑定设备 // 开始绑定设备
export async function startBind(data: { export async function startBind(data: {
device_id: string device_id: string
child_id: number child_id?: number
}): Promise<BindStartResponse> { }): Promise<BindStartResponse> {
return request<BindStartResponse>('/bindings/start', { return request<BindStartResponse>('/bindings/start', {
method: 'POST', method: 'POST',
@@ -52,7 +52,7 @@ export async function startBind(data: {
export async function confirmBind(data: { export async function confirmBind(data: {
bind_token: string bind_token: string
challenge_code: string challenge_code: string
}): Promise<{ device_id: string; child_id: number }> { }): Promise<{ device_id: string; child_id: number | null }> {
return request('/bindings/confirm', { return request('/bindings/confirm', {
method: 'POST', method: 'POST',
data, data,
@@ -83,14 +83,25 @@ export async function unbindDevice(deviceId: string): Promise<void> {
// 直接绑定设备(手动输入设备码) // 直接绑定设备(手动输入设备码)
export async function directBind(data: { export async function directBind(data: {
device_id: string device_id: string
child_id: number child_id?: number
}): Promise<{ device_id: string; child_id: number }> { }): Promise<{ device_id: string; child_id: number | null }> {
return request('/bindings/direct', { return request('/bindings/direct', {
method: 'POST', method: 'POST',
data, data,
}) })
} }
// Bind child profile after device has been bound to parent account.
export async function setBindingChild(
deviceId: string,
data: { child_id: number }
): Promise<{ device_id: string; child_id: number | null }> {
return request(`/bindings/${deviceId}/child`, {
method: 'PATCH',
data,
})
}
// 获取绑定历史 // 获取绑定历史
export async function getBindHistory( export async function getBindHistory(
deviceId: string, deviceId: string,

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.dao import BaseDAO from app.dao import BaseDAO
@@ -12,7 +13,172 @@ logger = logging.getLogger("app.dao.binding")
class BindingDAO(BaseDAO): 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()) bind_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(minutes=10) expires_at = datetime.utcnow() + timedelta(minutes=10)
@@ -46,106 +212,25 @@ class BindingDAO(BaseDAO):
.first() .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( self.db.execute(
text("UPDATE device_bind_sessions SET status = 2, confirmed_at = CURRENT_TIMESTAMP WHERE id = :id"), text("UPDATE device_bind_sessions SET status = 2, confirmed_at = CURRENT_TIMESTAMP WHERE id = :id"),
{"id": session_id}, {"id": session_id},
) )
existing = self.db.execute( self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
text("SELECT id FROM device_bindings WHERE device_id = :device_id OR child_id = :child_id"), self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=1)
{"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() self.commit()
def direct_bind(self, device_id: str, child_id: int, user_id: int) -> None: def direct_bind(self, device_id: str, child_id: Optional[int], user_id: int) -> None:
# Check if binding exists for this device_id if child_id is not None:
existing_by_device = ( self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
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 self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
existing_by_child = ( self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=2)
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() self.commit()
def get_current_by_user(self, user_id: int) -> Optional[Mapping]: def get_current_by_user(self, user_id: int) -> Optional[Mapping]:
@@ -153,10 +238,11 @@ class BindingDAO(BaseDAO):
self.db.execute( self.db.execute(
text( text(
""" """
SELECT b.* FROM device_bindings b SELECT *
JOIN children c ON b.child_id = c.child_id FROM device_bindings
WHERE c.parent_user_id = :user_id AND b.status = 1 WHERE owner_user_id = :user_id
ORDER BY b.bound_at DESC AND status = 1
ORDER BY bound_at DESC
LIMIT 1 LIMIT 1
""" """
), ),
@@ -171,9 +257,11 @@ class BindingDAO(BaseDAO):
self.db.execute( self.db.execute(
text( text(
""" """
SELECT b.* FROM device_bindings b SELECT *
JOIN children c ON b.child_id = c.child_id FROM device_bindings
WHERE b.device_id = :device_id AND c.parent_user_id = :user_id AND b.status = 1 WHERE device_id = :device_id
AND owner_user_id = :user_id
AND status = 1
""" """
), ),
{"device_id": device_id, "user_id": user_id}, {"device_id": device_id, "user_id": user_id},
@@ -182,6 +270,17 @@ class BindingDAO(BaseDAO):
.first() .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: def unbind(self, device_id: str, user_id: int) -> bool:
row = self.get_by_device(device_id, user_id) row = self.get_by_device(device_id, user_id)
if not row: if not row:

View File

@@ -15,6 +15,7 @@ from sqlalchemy import (
Text, Text,
Time, Time,
UniqueConstraint, UniqueConstraint,
text,
) )
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -31,24 +32,20 @@ class Parent(Base):
avatar_url: Mapped[Optional[str]] = mapped_column(String(255)) avatar_url: Mapped[Optional[str]] = mapped_column(String(255))
phone: Mapped[Optional[str]] = mapped_column(String(20)) phone: Mapped[Optional[str]] = mapped_column(String(20))
status: Mapped[int] = mapped_column(Integer, server_default="1") status: Mapped[int] = mapped_column(Integer, server_default="1")
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"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP") updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class Child(Base): class Child(Base):
__tablename__ = "children" __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) 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_name: Mapped[str] = mapped_column(String(32), nullable=False)
child_gender: Mapped[int] = mapped_column(Integer, server_default="2") child_gender: Mapped[int] = mapped_column(Integer, server_default="2")
child_birthday: Mapped[Optional[date]] = mapped_column(Date) child_birthday: Mapped[Optional[date]] = mapped_column(Date)
status: Mapped[int] = mapped_column(Integer, server_default="1") status: Mapped[int] = mapped_column(Integer, server_default="1")
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"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP") updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class ParentChildRelation(Base): class ParentChildRelation(Base):
@@ -65,24 +62,27 @@ class ParentChildRelation(Base):
relation_type: Mapped[int] = mapped_column(Integer, server_default="9") relation_type: Mapped[int] = mapped_column(Integer, server_default="9")
is_primary: Mapped[bool] = mapped_column(Boolean, server_default="0") is_primary: Mapped[bool] = mapped_column(Boolean, server_default="0")
status: Mapped[int] = mapped_column(Integer, server_default="1") status: Mapped[int] = mapped_column(Integer, server_default="1")
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"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP") updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class DeviceBinding(Base): class DeviceBinding(Base):
__tablename__ = "device_bindings" __tablename__ = "device_bindings"
__table_args__ = ( __table_args__ = (
UniqueConstraint("device_id", name="uq_device_binding_device"), 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) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=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") status: Mapped[int] = mapped_column(Integer, server_default="1")
bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime) unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
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"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP") updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class DeviceBindSession(Base): class DeviceBindSession(Base):
@@ -101,8 +101,8 @@ class DeviceBindSession(Base):
status: Mapped[int] = mapped_column(Integer, server_default="1") status: Mapped[int] = mapped_column(Integer, server_default="1")
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime) confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
consumed_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") created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP") updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class DeviceBindHistory(Base): class DeviceBindHistory(Base):
@@ -110,14 +110,14 @@ class DeviceBindHistory(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False) 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) bound_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
unbound_by_user_id: Mapped[Optional[int]] = mapped_column(Integer) unbound_by_user_id: Mapped[Optional[int]] = mapped_column(Integer)
bind_source: Mapped[int] = mapped_column(Integer, server_default="1") bind_source: Mapped[int] = mapped_column(Integer, server_default="1")
bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime) unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
unbind_reason: Mapped[Optional[str]] = mapped_column(String(191)) 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): class Card(Base):
@@ -129,8 +129,8 @@ class Card(Base):
card_name: Mapped[Optional[str]] = mapped_column(String(64)) card_name: Mapped[Optional[str]] = mapped_column(String(64))
status: Mapped[int] = mapped_column(Integer, server_default="0") status: Mapped[int] = mapped_column(Integer, server_default="0")
total_swaps: 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") created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP") updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class DeviceSetting(Base): class DeviceSetting(Base):
@@ -141,14 +141,15 @@ class DeviceSetting(Base):
sleep_mode: Mapped[int] = mapped_column(Integer, server_default="0") sleep_mode: Mapped[int] = mapped_column(Integer, server_default="0")
disable_time_start: Mapped[Optional[time]] = mapped_column(Time) disable_time_start: Mapped[Optional[time]] = mapped_column(Time)
disable_time_end: 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) volume: Mapped[Optional[int]] = mapped_column(Integer)
brightness: Mapped[Optional[int]] = mapped_column(Integer) brightness: Mapped[Optional[int]] = mapped_column(Integer)
disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32)) disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32))
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"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP") updated_at: Mapped[Optional[datetime]] = mapped_column(
updated_at: Mapped[datetime] = mapped_column( DateTime,
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow server_default=text("CURRENT_TIMESTAMP"),
onupdate=datetime.utcnow,
) )

View File

@@ -1,4 +1,5 @@
import logging import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel from pydantic import BaseModel
@@ -19,7 +20,7 @@ logger = logging.getLogger("app.bindings")
class BindStartRequest(BaseModel): class BindStartRequest(BaseModel):
device_id: str device_id: str
child_id: int child_id: int | None = None
class BindStartResponse(BaseModel): class BindStartResponse(BaseModel):
@@ -34,21 +35,21 @@ class BindConfirmRequest(BaseModel):
class BindConfirmResponse(BaseModel): class BindConfirmResponse(BaseModel):
device_id: str device_id: str
child_id: int child_id: int | None
class BindingGetResponse(BaseModel): class BindingGetResponse(BaseModel):
device_id: str device_id: str
child_id: int child_id: int | None
status: int status: int
bound_at: str bound_at: datetime
class BindHistoryItem(BaseModel): class BindHistoryItem(BaseModel):
device_id: str device_id: str
child_id: int child_id: int | None
bound_at: str bound_at: datetime
unbound_at: str | None unbound_at: datetime | None
class BindHistoryResponse(BaseModel): class BindHistoryResponse(BaseModel):
@@ -86,11 +87,15 @@ def confirm_bind(
class DirectBindRequest(BaseModel): class DirectBindRequest(BaseModel):
device_id: str device_id: str
child_id: int child_id: int | None = None
class DirectBindResponse(BaseModel): class DirectBindResponse(BaseModel):
device_id: str device_id: str
child_id: int | None
class BindSetChildRequest(BaseModel):
child_id: int child_id: int
@@ -106,6 +111,22 @@ def direct_bind(
return DirectBindResponse(**result) 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) @router.get("/current", response_model=BindingGetResponse)
def get_current_binding( def get_current_binding(
request: Request, request: Request,
@@ -154,8 +175,6 @@ def get_bind_history(
current_user_id: int = Depends(get_current_user_id), current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session), db=Depends(get_db_session),
) -> BindHistoryResponse: ) -> BindHistoryResponse:
from datetime import datetime
cursor_dt = datetime.fromisoformat(cursor) if cursor else None cursor_dt = datetime.fromisoformat(cursor) if cursor else None
service = BindingService(db) service = BindingService(db)
rows, has_more = service.list_history(device_id, limit, cursor_dt) rows, has_more = service.list_history(device_id, limit, cursor_dt)

View File

@@ -9,7 +9,7 @@ class BindingService:
def __init__(self, db): def __init__(self, db):
self.dao = BindingDAO(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) bind_token = self.dao.start_bind(user_id, device_id, child_id)
return bind_token, datetime.utcnow() return bind_token, datetime.utcnow()
@@ -31,10 +31,16 @@ class BindingService:
def get_current_binding(self, user_id: int) -> Optional[Mapping]: def get_current_binding(self, user_id: int) -> Optional[Mapping]:
return self.dao.get_current_by_user(user_id) 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) self.dao.direct_bind(device_id, child_id, user_id)
return {"device_id": device_id, "child_id": child_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: def unbind(self, device_id: str, user_id: int) -> bool:
return self.dao.unbind(device_id, user_id) return self.dao.unbind(device_id, user_id)

View File

@@ -48,7 +48,8 @@ CREATE TABLE IF NOT EXISTS parent_child_relations (
CREATE TABLE IF NOT EXISTS device_bindings ( CREATE TABLE IF NOT EXISTS device_bindings (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL,
child_id BIGINT NOT NULL, owner_user_id BIGINT NOT NULL,
child_id BIGINT,
status TINYINT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 1,
bound_at DATETIME NOT NULL, bound_at DATETIME NOT NULL,
unbound_at DATETIME, unbound_at DATETIME,
@@ -58,6 +59,8 @@ CREATE TABLE IF NOT EXISTS device_bindings (
UNIQUE (child_id) UNIQUE (child_id)
); );
CREATE INDEX IF NOT EXISTS idx_device_bindings_owner_user_id ON device_bindings(owner_user_id);
-- device_bind_sessions table -- device_bind_sessions table
CREATE TABLE IF NOT EXISTS device_bind_sessions ( CREATE TABLE IF NOT EXISTS device_bind_sessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
@@ -81,7 +84,7 @@ CREATE TABLE IF NOT EXISTS device_bind_sessions (
CREATE TABLE IF NOT EXISTS device_bind_history ( CREATE TABLE IF NOT EXISTS device_bind_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL,
child_id BIGINT NOT NULL, child_id BIGINT,
bound_by_user_id BIGINT NOT NULL, bound_by_user_id BIGINT NOT NULL,
unbound_by_user_id BIGINT, unbound_by_user_id BIGINT,
bind_source TINYINT NOT NULL DEFAULT 1, bind_source TINYINT NOT NULL DEFAULT 1,

View File

@@ -0,0 +1,46 @@
-- Migration: support "bind device first, then set child profile"
-- Target: MySQL 8.x
-- 1) Add owner_user_id on device_bindings.
ALTER TABLE device_bindings
ADD COLUMN owner_user_id BIGINT NULL AFTER device_id;
-- 2) Backfill owner_user_id from active parent-child relation (prefer primary relation).
UPDATE device_bindings b
SET owner_user_id = (
SELECT r.user_id
FROM parent_child_relations r
WHERE r.child_id = b.child_id
AND r.status = 1
ORDER BY r.is_primary DESC, r.id ASC
LIMIT 1
)
WHERE b.owner_user_id IS NULL;
-- 3) Fallback backfill from latest bind history.
UPDATE device_bindings b
SET owner_user_id = (
SELECT h.bound_by_user_id
FROM device_bind_history h
WHERE h.device_id = b.device_id
ORDER BY h.bound_at DESC, h.id DESC
LIMIT 1
)
WHERE b.owner_user_id IS NULL;
-- 4) Ensure no NULL owner before setting NOT NULL.
-- If this query returns rows, handle manually before continuing:
-- SELECT id, device_id, child_id FROM device_bindings WHERE owner_user_id IS NULL;
-- 5) Make schema changes for new flow.
ALTER TABLE device_bindings
MODIFY COLUMN owner_user_id BIGINT NOT NULL;
ALTER TABLE device_bindings
MODIFY COLUMN child_id BIGINT NULL;
ALTER TABLE device_bindings
ADD INDEX idx_device_bindings_owner_user_id (owner_user_id);
ALTER TABLE device_bind_history
MODIFY COLUMN child_id BIGINT NULL;

View File

@@ -77,9 +77,217 @@ def test_binding_dao():
assert len(token) == 36 assert len(token) == 36
def test_confirm_bind_upserts_parent_child_relation():
"""Confirm bind should create parent-child relation when missing."""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dao.binding import BindingDAO
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_bind_confirm', 1)"))
db.execute(text("INSERT INTO children (child_name, child_gender, status) VALUES ('Kid Confirm', 2, 1)"))
db.commit()
user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_bind_confirm'")).scalar_one())
child_id = int(
db.execute(text("SELECT child_id FROM children WHERE child_name = 'Kid Confirm'")).scalar_one()
)
dao = BindingDAO(db)
token = dao.start_bind(user_id, "device_confirm_001", child_id)
session = dao.get_session(token, user_id)
assert session is not None
dao.confirm_bind(int(session["id"]), "device_confirm_001", child_id, user_id)
relation = db.execute(
text(
"""
SELECT status
FROM parent_child_relations
WHERE user_id = :user_id AND child_id = :child_id
"""
),
{"user_id": user_id, "child_id": child_id},
).scalar_one_or_none()
assert relation == 1
def test_direct_bind_reactivates_parent_child_relation():
"""Direct bind should reactivate an existing disabled parent-child relation."""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dao.binding import BindingDAO
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_bind_direct', 1)"))
db.execute(text("INSERT INTO children (child_name, child_gender, status) VALUES ('Kid Direct', 2, 1)"))
db.commit()
user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_bind_direct'")).scalar_one())
child_id = int(db.execute(text("SELECT child_id FROM children WHERE child_name = 'Kid Direct'")).scalar_one())
db.execute(
text(
"""
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
VALUES (:user_id, :child_id, 9, 0, 0)
"""
),
{"user_id": user_id, "child_id": child_id},
)
db.commit()
dao = BindingDAO(db)
dao.direct_bind("device_direct_001", child_id, user_id)
relation = db.execute(
text(
"""
SELECT status
FROM parent_child_relations
WHERE user_id = :user_id AND child_id = :child_id
"""
),
{"user_id": user_id, "child_id": child_id},
).scalar_one()
assert relation == 1
def test_direct_bind_without_child_then_set_child():
"""Direct bind should support empty child first and assign child later."""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dao.binding import BindingDAO
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_bind_later', 1)"))
db.execute(text("INSERT INTO children (child_name, child_gender, status) VALUES ('Kid Later', 2, 1)"))
db.commit()
user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_bind_later'")).scalar_one())
child_id = int(db.execute(text("SELECT child_id FROM children WHERE child_name = 'Kid Later'")).scalar_one())
dao = BindingDAO(db)
dao.direct_bind("device_later_001", None, user_id)
current = dao.get_current_by_user(user_id)
assert current is not None
assert current["device_id"] == "device_later_001"
assert current["child_id"] is None
changed = dao.set_binding_child("device_later_001", child_id, user_id)
assert changed is True
current = dao.get_current_by_user(user_id)
assert current is not None
assert int(current["child_id"]) == child_id
relation = db.execute(
text(
"""
SELECT status
FROM parent_child_relations
WHERE user_id = :user_id AND child_id = :child_id
"""
),
{"user_id": user_id, "child_id": child_id},
).scalar_one()
assert relation == 1
def test_update_my_relation_switches_primary():
"""Relation update should switch primary parent on the same child."""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dao.relation import RelationDAO
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_relation_1', 1)"))
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_relation_2', 1)"))
db.execute(text("INSERT INTO children (child_name, child_gender, status) VALUES ('Kid Relation', 2, 1)"))
db.commit()
user_id_1 = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_relation_1'")).scalar_one())
user_id_2 = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_relation_2'")).scalar_one())
child_id = int(db.execute(text("SELECT child_id FROM children WHERE child_name = 'Kid Relation'")).scalar_one())
db.execute(
text(
"""
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
VALUES (:user_id, :child_id, :relation_type, :is_primary, 1)
"""
),
{"user_id": user_id_1, "child_id": child_id, "relation_type": 9, "is_primary": 0},
)
db.execute(
text(
"""
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
VALUES (:user_id, :child_id, :relation_type, :is_primary, 1)
"""
),
{"user_id": user_id_2, "child_id": child_id, "relation_type": 9, "is_primary": 1},
)
db.commit()
dao = RelationDAO(db)
updated = dao.update_my_relation(
child_id=child_id,
user_id=user_id_1,
relation_type=2,
is_primary=True,
)
assert updated is not None
assert int(updated["relation_type"]) == 2
assert int(updated["is_primary"]) == 1
assert int(updated["status"]) == 1
other_primary = int(
db.execute(
text(
"""
SELECT is_primary
FROM parent_child_relations
WHERE child_id = :child_id
AND user_id = :user_id
"""
),
{"child_id": child_id, "user_id": user_id_2},
).scalar_one()
)
assert other_primary == 0
if __name__ == "__main__": if __name__ == "__main__":
test_sqlite_connection() test_sqlite_connection()
test_parent_dao() test_parent_dao()
test_child_dao() test_child_dao()
test_binding_dao() test_binding_dao()
test_confirm_bind_upserts_parent_child_relation()
test_direct_bind_reactivates_parent_child_relation()
test_direct_bind_without_child_then_set_child()
test_update_my_relation_switches_primary()
print("All DAO tests passed!") print("All DAO tests passed!")