582 lines
20 KiB
Python
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)
|