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()