feat: 支持先绑定设备后关联儿童档案
- 绑定模型新增 owner_user_id,支持 child_id 为空\n- 新增绑定后设置儿童接口 /bindings/{device_id}/child\n- 前端绑定页改为先绑设备后补充儿童\n- 补充数据库迁移脚本与 DAO 测试用例
This commit is contained in:
@@ -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
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
|
console.error('Load bind page data failed:', err)
|
||||||
|
} finally {
|
||||||
|
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' })
|
||||||
}
|
}
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
const handleManualInput = () => {
|
||||||
if (children.length === 0) {
|
if (pendingDeviceId) {
|
||||||
Taro.showToast({ title: '请先添加儿童', icon: 'none' })
|
Taro.showToast({ title: 'Complete child setup first', icon: 'none' })
|
||||||
return
|
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 = () => {
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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!")
|
||||||
Reference in New Issue
Block a user