Files
banban/talkingq-url/banban/dao/family.py
2026-06-08 11:46:14 +08:00

582 lines
20 KiB
Python

from collections.abc import Mapping
from datetime import datetime, timedelta
import uuid
from sqlalchemy import text
from banban.dao import BaseDAO
FAMILY_ROLE_OWNER = 1
FAMILY_ROLE_MEMBER = 2
FAMILY_STATUS_ACTIVE = 1
INVITE_STATUS_PENDING = 1
INVITE_STATUS_ACCEPTED = 2
INVITE_STATUS_EXPIRED = 3
INVITE_STATUS_CANCELLED = 4
MAX_FAMILY_MEMBERS = 4
class FamilyDAO(BaseDAO):
async def ensure_owner_member(self, *, device_id: str) -> None:
await self.execute(
text(
"""
INSERT INTO device_family_members (
device_id,
user_id,
role,
status,
invited_by_user_id,
joined_at,
created_at,
updated_at
)
SELECT
db.device_id,
db.owner_user_id,
:owner_role,
:active_status,
NULL,
COALESCE(db.bound_at, CURRENT_TIMESTAMP),
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM device_bindings AS db
LEFT JOIN device_family_members AS dfm
ON dfm.device_id = db.device_id
AND dfm.user_id = db.owner_user_id
WHERE db.device_id = :device_id
AND db.status = 1
AND dfm.id IS NULL
"""
),
{
"device_id": device_id,
"owner_role": FAMILY_ROLE_OWNER,
"active_status": FAMILY_STATUS_ACTIVE,
},
)
async def get_binding_for_owner(self, *, device_id: str, user_id: int) -> Mapping | None:
return (
await self.execute(
text(
"""
SELECT *
FROM device_bindings
WHERE device_id = :device_id
AND owner_user_id = :user_id
AND status = 1
LIMIT 1
"""
),
{"device_id": device_id, "user_id": user_id},
)
).mappings().first()
async def get_binding_for_access(self, *, device_id: str, user_id: int) -> Mapping | None:
await self.ensure_owner_member(device_id=device_id)
return (
await self.execute(
text(
"""
SELECT
db.*,
CASE
WHEN db.owner_user_id = :user_id THEN :owner_role
ELSE COALESCE(dfm.role, :member_role)
END AS family_role
FROM device_bindings AS db
LEFT JOIN device_family_members AS dfm
ON dfm.device_id = db.device_id
AND dfm.user_id = :user_id
AND dfm.status = :active_status
WHERE db.device_id = :device_id
AND db.status = 1
AND (
db.owner_user_id = :user_id
OR dfm.id IS NOT NULL
)
LIMIT 1
"""
),
{
"device_id": device_id,
"user_id": user_id,
"owner_role": FAMILY_ROLE_OWNER,
"member_role": FAMILY_ROLE_MEMBER,
"active_status": FAMILY_STATUS_ACTIVE,
},
)
).mappings().first()
async def get_binding_by_child_for_access(self, *, child_id: int, user_id: int) -> Mapping | None:
return (
await self.execute(
text(
"""
SELECT
db.*,
CASE
WHEN db.owner_user_id = :user_id THEN :owner_role
ELSE COALESCE(dfm.role, :member_role)
END AS family_role
FROM device_bindings AS db
LEFT JOIN device_family_members AS dfm
ON dfm.device_id = db.device_id
AND dfm.user_id = :user_id
AND dfm.status = :active_status
WHERE db.child_id = :child_id
AND db.status = 1
AND (
db.owner_user_id = :user_id
OR dfm.id IS NOT NULL
)
LIMIT 1
"""
),
{
"child_id": child_id,
"user_id": user_id,
"owner_role": FAMILY_ROLE_OWNER,
"member_role": FAMILY_ROLE_MEMBER,
"active_status": FAMILY_STATUS_ACTIVE,
},
)
).mappings().first()
async def has_child_access(self, *, child_id: int, user_id: int) -> bool:
result = await self.execute(
text(
"""
SELECT 1
FROM children AS c
LEFT JOIN parent_child_relations AS pcr
ON pcr.child_id = c.child_id
AND pcr.user_id = :user_id
AND pcr.status = 1
LEFT JOIN device_bindings AS db
ON db.child_id = c.child_id
AND db.status = 1
LEFT JOIN device_family_members AS dfm
ON dfm.device_id = db.device_id
AND dfm.user_id = :user_id
AND dfm.status = :active_status
WHERE c.child_id = :child_id
AND c.status = 1
AND (
pcr.id IS NOT NULL
OR db.owner_user_id = :user_id
OR dfm.id IS NOT NULL
)
LIMIT 1
"""
),
{
"child_id": child_id,
"user_id": user_id,
"active_status": FAMILY_STATUS_ACTIVE,
},
)
return result.scalar_one_or_none() is not None
async def get_child_for_access(self, *, child_id: int, user_id: int) -> Mapping | None:
return (
await self.execute(
text(
"""
SELECT c.*
FROM children AS c
LEFT JOIN parent_child_relations AS pcr
ON pcr.child_id = c.child_id
AND pcr.user_id = :user_id
AND pcr.status = 1
LEFT JOIN device_bindings AS db
ON db.child_id = c.child_id
AND db.status = 1
LEFT JOIN device_family_members AS dfm
ON dfm.device_id = db.device_id
AND dfm.user_id = :user_id
AND dfm.status = :active_status
WHERE c.child_id = :child_id
AND c.status = 1
AND (
pcr.id IS NOT NULL
OR db.owner_user_id = :user_id
OR dfm.id IS NOT NULL
)
LIMIT 1
"""
),
{
"child_id": child_id,
"user_id": user_id,
"active_status": FAMILY_STATUS_ACTIVE,
},
)
).mappings().first()
async def list_children_for_access(self, *, user_id: int, limit: int, cursor: int | None) -> list[Mapping]:
params = {
"user_id": user_id,
"limit": limit + 1,
"active_status": FAMILY_STATUS_ACTIVE,
}
cursor_where = ""
if cursor is not None:
cursor_where = "AND child_id < :cursor"
params["cursor"] = cursor
rows = (
await self.execute(
text(
f"""
SELECT *
FROM (
SELECT DISTINCT
c.child_id,
c.child_name,
c.child_gender,
c.child_birthday,
c.status,
c.created_at,
c.updated_at
FROM children AS c
LEFT JOIN parent_child_relations AS pcr
ON pcr.child_id = c.child_id
AND pcr.user_id = :user_id
AND pcr.status = 1
LEFT JOIN device_bindings AS db
ON db.child_id = c.child_id
AND db.status = 1
LEFT JOIN device_family_members AS dfm
ON dfm.device_id = db.device_id
AND dfm.user_id = :user_id
AND dfm.status = :active_status
WHERE c.status = 1
AND (
pcr.id IS NOT NULL
OR db.owner_user_id = :user_id
OR dfm.id IS NOT NULL
)
) AS accessible_children
WHERE 1 = 1
{cursor_where}
ORDER BY child_id DESC
LIMIT :limit
"""
),
params,
)
).mappings().all()
return list(rows)
async def list_members(self, *, device_id: str, user_id: int) -> list[Mapping]:
if await self.get_binding_for_access(device_id=device_id, user_id=user_id) is None:
return []
rows = (
await self.execute(
text(
"""
SELECT
dfm.id,
dfm.device_id,
dfm.user_id,
dfm.role,
dfm.display_name,
dfm.status,
dfm.joined_at,
dfm.invited_by_user_id,
p.nickname AS account_nickname,
COALESCE(NULLIF(dfm.display_name, ''), p.nickname) AS nickname,
p.avatar_url,
CASE WHEN db.owner_user_id = dfm.user_id THEN 1 ELSE 0 END AS is_owner
FROM device_family_members AS dfm
JOIN device_bindings AS db
ON db.device_id = dfm.device_id
AND db.status = 1
LEFT JOIN parents AS p
ON p.user_id = dfm.user_id
WHERE dfm.device_id = :device_id
AND dfm.status = :active_status
ORDER BY is_owner DESC, dfm.joined_at ASC, dfm.id ASC
"""
),
{
"device_id": device_id,
"active_status": FAMILY_STATUS_ACTIVE,
},
)
).mappings().all()
return list(rows)
async def update_member_display_name(
self,
*,
device_id: str,
member_user_id: int,
owner_user_id: int,
display_name: str | None,
) -> Mapping | None:
owner_binding = await self.get_binding_for_owner(device_id=device_id, user_id=owner_user_id)
if owner_binding is None:
return None
result = await self.execute(
text(
"""
UPDATE device_family_members
SET display_name = :display_name,
updated_at = CURRENT_TIMESTAMP
WHERE device_id = :device_id
AND user_id = :member_user_id
AND status = :active_status
"""
),
{
"device_id": device_id,
"member_user_id": member_user_id,
"display_name": display_name,
"active_status": FAMILY_STATUS_ACTIVE,
},
)
if not result.rowcount:
return None
rows = await self.list_members(device_id=device_id, user_id=owner_user_id)
for row in rows:
if int(row["user_id"]) == member_user_id:
return row
return None
async def list_active_member_user_ids(self, *, device_id: str) -> list[int]:
await self.ensure_owner_member(device_id=device_id)
rows = (
await self.execute(
text(
"""
SELECT user_id
FROM device_family_members
WHERE device_id = :device_id
AND status = :active_status
ORDER BY role ASC, joined_at ASC, id ASC
"""
),
{"device_id": device_id, "active_status": FAMILY_STATUS_ACTIVE},
)
).mappings().all()
return [int(row["user_id"]) for row in rows]
async def count_active_members(self, *, device_id: str) -> int:
result = await self.execute(
text(
"""
SELECT COUNT(*)
FROM device_family_members
WHERE device_id = :device_id
AND status = :active_status
"""
),
{"device_id": device_id, "active_status": FAMILY_STATUS_ACTIVE},
)
return int(result.scalar() or 0)
async def create_invitation(self, *, device_id: str, owner_user_id: int) -> tuple[str, datetime]:
invite_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=24)
await self.execute(
text(
"""
INSERT INTO device_family_invitations (
invite_token,
device_id,
owner_user_id,
status,
expires_at,
created_at,
updated_at
)
VALUES (
:invite_token,
:device_id,
:owner_user_id,
:pending_status,
:expires_at,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
"""
),
{
"invite_token": invite_token,
"device_id": device_id,
"owner_user_id": owner_user_id,
"pending_status": INVITE_STATUS_PENDING,
"expires_at": expires_at,
},
)
return invite_token, expires_at
async def get_invitation(self, *, invite_token: str) -> Mapping | None:
return (
await self.execute(
text(
"""
SELECT
dfi.*,
db.child_id,
c.child_name
FROM device_family_invitations AS dfi
JOIN device_bindings AS db
ON db.device_id = dfi.device_id
AND db.status = 1
LEFT JOIN children AS c
ON c.child_id = db.child_id
AND c.status = 1
WHERE dfi.invite_token = :invite_token
LIMIT 1
"""
),
{"invite_token": invite_token},
)
).mappings().first()
async def mark_invitation_expired(self, *, invitation_id: int) -> None:
await self.execute(
text(
"""
UPDATE device_family_invitations
SET status = :expired_status,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""
),
{"id": invitation_id, "expired_status": INVITE_STATUS_EXPIRED},
)
async def accept_invitation(self, *, invitation: Mapping, user_id: int) -> None:
await self.execute(
text(
"""
INSERT INTO device_family_members (
device_id,
user_id,
role,
status,
invited_by_user_id,
joined_at,
removed_at,
created_at,
updated_at
)
VALUES (
:device_id,
:user_id,
:member_role,
:active_status,
:owner_user_id,
CURRENT_TIMESTAMP,
NULL,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
role = CASE
WHEN role = :owner_role THEN role
ELSE VALUES(role)
END,
invited_by_user_id = VALUES(invited_by_user_id),
joined_at = CURRENT_TIMESTAMP,
removed_at = NULL,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"device_id": invitation["device_id"],
"user_id": user_id,
"owner_user_id": invitation["owner_user_id"],
"owner_role": FAMILY_ROLE_OWNER,
"member_role": FAMILY_ROLE_MEMBER,
"active_status": FAMILY_STATUS_ACTIVE,
},
)
await self.execute(
text(
"""
UPDATE device_family_invitations
SET status = :accepted_status,
accepted_by_user_id = :user_id,
accepted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""
),
{
"id": invitation["id"],
"user_id": user_id,
"accepted_status": INVITE_STATUS_ACCEPTED,
},
)
async def remove_member(self, *, device_id: str, member_user_id: int, removed_by_user_id: int) -> bool:
owner_binding = await self.get_binding_for_owner(device_id=device_id, user_id=removed_by_user_id)
if owner_binding is None:
return False
if int(owner_binding["owner_user_id"]) == member_user_id:
return False
result = await self.execute(
text(
"""
UPDATE device_family_members
SET status = 0,
removed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE device_id = :device_id
AND user_id = :member_user_id
AND status = :active_status
AND role <> :owner_role
"""
),
{
"device_id": device_id,
"member_user_id": member_user_id,
"active_status": FAMILY_STATUS_ACTIVE,
"owner_role": FAMILY_ROLE_OWNER,
},
)
return bool(result.rowcount)
async def leave_family(self, *, device_id: str, user_id: int) -> bool:
binding = await self.get_binding_for_access(device_id=device_id, user_id=user_id)
if binding is None:
return False
if int(binding["owner_user_id"]) == user_id:
return False
result = await self.execute(
text(
"""
UPDATE device_family_members
SET status = 0,
removed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE device_id = :device_id
AND user_id = :user_id
AND status = :active_status
AND role <> :owner_role
"""
),
{
"device_id": device_id,
"user_id": user_id,
"active_status": FAMILY_STATUS_ACTIVE,
"owner_role": FAMILY_ROLE_OWNER,
},
)
return bool(result.rowcount)