250 lines
10 KiB
Python
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()
|