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

250 lines
10 KiB
Python

from collections.abc import Mapping
from datetime import datetime
from fastapi import HTTPException
from banban.dao.family import (
FAMILY_ROLE_OWNER,
INVITE_STATUS_PENDING,
MAX_FAMILY_MEMBERS,
FamilyDAO,
)
from services.database_service_base import DatabaseServiceBase
class FamilyService(DatabaseServiceBase):
def __init__(self):
super().__init__(service_name="family_service")
def _normalize_display_name(self, display_name: str | None) -> str | None:
normalized = str(display_name or "").strip()
if not normalized:
return None
if len(normalized) > 64:
raise HTTPException(status_code=400, detail="display name must be 64 characters or fewer")
return normalized
async def ensure_device_access(self, *, device_id: str, user_id: int) -> Mapping:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
binding = await dao.get_binding_for_access(device_id=device_id, user_id=user_id)
if binding is None:
raise HTTPException(status_code=404, detail="device not found")
return binding
finally:
await db_session.close()
async def ensure_device_owner(self, *, device_id: str, user_id: int) -> Mapping:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
binding = await dao.get_binding_for_owner(device_id=device_id, user_id=user_id)
if binding is None:
raise HTTPException(status_code=403, detail="only owner can manage family")
await dao.ensure_owner_member(device_id=device_id)
await db_session.commit()
return binding
finally:
await db_session.close()
async def has_child_access(self, *, child_id: int, user_id: int) -> bool:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
return await dao.has_child_access(child_id=child_id, user_id=user_id)
finally:
await db_session.close()
async def get_child_for_access(self, *, child_id: int, user_id: int) -> Mapping | None:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
return await dao.get_child_for_access(child_id=child_id, user_id=user_id)
finally:
await db_session.close()
async def list_children_for_access(self, *, user_id: int, limit: int, cursor: int | None) -> tuple[list[Mapping], bool]:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
rows = await dao.list_children_for_access(user_id=user_id, limit=limit, cursor=cursor)
has_more = len(rows) > limit
return rows[:limit], has_more
finally:
await db_session.close()
async def list_members(self, *, device_id: str, user_id: int) -> list[Mapping]:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
await dao.ensure_owner_member(device_id=device_id)
rows = await dao.list_members(device_id=device_id, user_id=user_id)
if not rows:
raise HTTPException(status_code=404, detail="device not found")
await db_session.commit()
return rows
finally:
await db_session.close()
async def create_invitation(self, *, device_id: str, user_id: int) -> Mapping:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
binding = await dao.get_binding_for_owner(device_id=device_id, user_id=user_id)
if binding is None:
raise HTTPException(status_code=403, detail="only owner can invite family members")
await dao.ensure_owner_member(device_id=device_id)
member_count = await dao.count_active_members(device_id=device_id)
if member_count >= MAX_FAMILY_MEMBERS:
raise HTTPException(status_code=409, detail="family member limit reached")
invite_token, expires_at = await dao.create_invitation(device_id=device_id, owner_user_id=user_id)
await db_session.commit()
return {
"invite_token": invite_token,
"device_id": device_id,
"expires_at": expires_at,
}
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def get_invitation(self, *, invite_token: str) -> Mapping:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
invitation = await dao.get_invitation(invite_token=invite_token)
if invitation is None:
raise HTTPException(status_code=404, detail="family invitation not found")
status = int(invitation["status"])
if status == INVITE_STATUS_PENDING and datetime.utcnow() > invitation["expires_at"]:
await dao.mark_invitation_expired(invitation_id=int(invitation["id"]))
await db_session.commit()
invitation = await dao.get_invitation(invite_token=invite_token)
if invitation is None:
raise HTTPException(status_code=404, detail="family invitation not found")
return invitation
finally:
await db_session.close()
async def accept_invitation(self, *, invite_token: str, user_id: int) -> Mapping:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
invitation = await dao.get_invitation(invite_token=invite_token)
if invitation is None:
raise HTTPException(status_code=404, detail="family invitation not found")
if int(invitation["status"]) != INVITE_STATUS_PENDING:
raise HTTPException(status_code=409, detail="family invitation is not available")
if datetime.utcnow() > invitation["expires_at"]:
await dao.mark_invitation_expired(invitation_id=int(invitation["id"]))
await db_session.commit()
raise HTTPException(status_code=410, detail="family invitation expired")
await dao.ensure_owner_member(device_id=str(invitation["device_id"]))
existing_access = await dao.get_binding_for_access(device_id=str(invitation["device_id"]), user_id=user_id)
if existing_access is None:
member_count = await dao.count_active_members(device_id=str(invitation["device_id"]))
if member_count >= MAX_FAMILY_MEMBERS:
raise HTTPException(status_code=409, detail="family member limit reached")
await dao.accept_invitation(invitation=invitation, user_id=user_id)
await db_session.commit()
return {
"device_id": str(invitation["device_id"]),
"child_id": invitation.get("child_id"),
"child_name": invitation.get("child_name"),
}
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def remove_member(self, *, device_id: str, member_user_id: int, user_id: int) -> bool:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
removed = await dao.remove_member(
device_id=device_id,
member_user_id=member_user_id,
removed_by_user_id=user_id,
)
if not removed:
owner_binding = await dao.get_binding_for_owner(device_id=device_id, user_id=user_id)
if owner_binding is None:
raise HTTPException(status_code=403, detail="only owner can remove family members")
if int(owner_binding["owner_user_id"]) == member_user_id:
raise HTTPException(status_code=400, detail="owner cannot be removed")
raise HTTPException(status_code=404, detail="family member not found")
await db_session.commit()
return True
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def update_member_display_name(
self,
*,
device_id: str,
member_user_id: int,
display_name: str | None,
user_id: int,
) -> Mapping:
normalized_display_name = self._normalize_display_name(display_name)
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
owner_binding = await dao.get_binding_for_owner(device_id=device_id, user_id=user_id)
if owner_binding is None:
raise HTTPException(status_code=403, detail="only owner can edit family member display names")
member = await dao.update_member_display_name(
device_id=device_id,
member_user_id=member_user_id,
owner_user_id=user_id,
display_name=normalized_display_name,
)
if member is None:
raise HTTPException(status_code=404, detail="family member not found")
await db_session.commit()
return member
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def leave_family(self, *, device_id: str, user_id: int) -> bool:
db_session = await self.get_session()
try:
dao = FamilyDAO(db_session)
left = await dao.leave_family(device_id=device_id, user_id=user_id)
if not left:
binding = await dao.get_binding_for_access(device_id=device_id, user_id=user_id)
if binding is None:
raise HTTPException(status_code=404, detail="device not found")
if int(binding["owner_user_id"]) == user_id:
raise HTTPException(status_code=400, detail="owner cannot leave family")
raise HTTPException(status_code=404, detail="family member not found")
await db_session.commit()
return True
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
family_service = FamilyService()