Spaces:
Sleeping
Sleeping
| import secrets | |
| from datetime import datetime, timezone | |
| from typing import List, Optional | |
| from fastapi import APIRouter, Depends, HTTPException, status | |
| from fastapi.security import OAuth2PasswordRequestForm | |
| from pydantic import BaseModel, EmailStr, Field, field_validator | |
| from sqlalchemy import func, or_, select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from database.auth import ( | |
| create_access_token, | |
| get_current_user, | |
| hash_password, | |
| verify_password, | |
| ) | |
| from database.connection import get_db | |
| from database.models import ( | |
| Meeting, | |
| MeetingMember, | |
| MeetingRole, | |
| MeetingStatus, | |
| MicrophoneConfig, | |
| SpeechLog, | |
| SystemDiagnostic, | |
| User, | |
| UserRole, | |
| ) | |
| router = APIRouter() | |
| # =========================================================================== | |
| # Pydantic schemas | |
| # =========================================================================== | |
| # --------------------------------------------------------------------------- | |
| # Auth schemas | |
| # --------------------------------------------------------------------------- | |
| class UserRegisterRequest(BaseModel): | |
| username: str = Field(..., min_length=3, max_length=50) | |
| email: EmailStr | |
| password: str = Field(..., min_length=8) | |
| # Role is intentionally not accepted at registration. Every account starts as | |
| # a participant; elevated roles are derived later from meeting ownership. | |
| class UserResponse(BaseModel): | |
| id: int | |
| username: str | |
| email: str | |
| role: UserRole | |
| is_active: bool | |
| created_at: datetime | |
| model_config = {"from_attributes": True} | |
| class TokenResponse(BaseModel): | |
| access_token: str | |
| token_type: str = "bearer" | |
| # --------------------------------------------------------------------------- | |
| # Meeting schemas | |
| # --------------------------------------------------------------------------- | |
| class MeetingCreateRequest(BaseModel): | |
| title: str = Field(..., min_length=1, max_length=200) | |
| description: Optional[str] = Field(default=None, max_length=500) | |
| class MeetingResponse(BaseModel): | |
| id: int | |
| title: str | |
| description: Optional[str] = None | |
| host_id: int | |
| invite_token: Optional[str] = None | |
| created_at: datetime | |
| ended_at: Optional[datetime] | |
| status: MeetingStatus | |
| # Enriched, non-ORM fields populated per request. | |
| my_role: Optional[MeetingRole] = None | |
| member_count: int = 0 | |
| model_config = {"from_attributes": True} | |
| # --------------------------------------------------------------------------- | |
| # Membership / invite schemas | |
| # --------------------------------------------------------------------------- | |
| class MemberResponse(BaseModel): | |
| user_id: int | |
| username: str | |
| email: str | |
| role: MeetingRole | |
| is_host: bool | |
| joined_at: datetime | |
| class AddMemberRequest(BaseModel): | |
| # Identify the user to add by username or email. | |
| identifier: str = Field(..., min_length=1, max_length=255) | |
| role: MeetingRole = MeetingRole.participant | |
| class UpdateMemberRoleRequest(BaseModel): | |
| role: MeetingRole | |
| class InviteInfoResponse(BaseModel): | |
| meeting_id: int | |
| title: str | |
| description: Optional[str] | |
| host_username: str | |
| member_count: int | |
| status: MeetingStatus | |
| already_member: bool | |
| class UserSearchResult(BaseModel): | |
| id: int | |
| username: str | |
| email: str | |
| # --------------------------------------------------------------------------- | |
| # Microphone schemas | |
| # --------------------------------------------------------------------------- | |
| class MicrophoneCreateRequest(BaseModel): | |
| name: str = Field(..., min_length=1, max_length=100) | |
| priority: int = Field(default=5, ge=1, le=10) | |
| is_chairman: bool = False | |
| class MicrophonePatchRequest(BaseModel): | |
| is_muted: Optional[bool] = None | |
| priority: Optional[int] = Field(default=None, ge=1, le=10) | |
| is_chairman: Optional[bool] = None | |
| name: Optional[str] = Field(default=None, min_length=1, max_length=100) | |
| class MicrophoneResponse(BaseModel): | |
| id: int | |
| meeting_id: int | |
| user_id: int | |
| name: str | |
| priority: int | |
| is_chairman: bool | |
| is_muted: bool | |
| created_at: datetime | |
| model_config = {"from_attributes": True} | |
| # --------------------------------------------------------------------------- | |
| # Diagnostics schemas | |
| # --------------------------------------------------------------------------- | |
| class DiagnosticCreateRequest(BaseModel): | |
| cpu_usage: float = Field(default=0.0, ge=0.0, le=100.0) | |
| memory_usage: float = Field(default=0.0, ge=0.0, le=100.0) | |
| audio_latency_ms: float = Field(default=0.0, ge=0.0) | |
| buffer_health: float = Field(default=100.0, ge=0.0, le=100.0) | |
| packet_loss: float = Field(default=0.0, ge=0.0, le=100.0) | |
| class DiagnosticResponse(BaseModel): | |
| id: int | |
| timestamp: datetime | |
| cpu_usage: float | |
| memory_usage: float | |
| audio_latency_ms: float | |
| buffer_health: float | |
| packet_loss: float | |
| model_config = {"from_attributes": True} | |
| # --------------------------------------------------------------------------- | |
| # Speech log schemas | |
| # --------------------------------------------------------------------------- | |
| class SpeechLogResponse(BaseModel): | |
| id: int | |
| meeting_id: int | |
| microphone_id: int | |
| start_time: datetime | |
| end_time: Optional[datetime] | |
| peak_db: float | |
| avg_db: float | |
| model_config = {"from_attributes": True} | |
| # =========================================================================== | |
| # Auth router | |
| # =========================================================================== | |
| auth_router = APIRouter(prefix="/auth", tags=["Authentication"]) | |
| async def register( | |
| body: UserRegisterRequest, | |
| db: AsyncSession = Depends(get_db), | |
| ) -> User: | |
| """Register a new user account.""" | |
| # Check uniqueness | |
| existing = await db.execute( | |
| select(User).where( | |
| (User.username == body.username) | (User.email == body.email) | |
| ) | |
| ) | |
| if existing.scalar_one_or_none(): | |
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, | |
| detail="Username or email already registered", | |
| ) | |
| user = User( | |
| username=body.username, | |
| email=body.email, | |
| hashed_password=hash_password(body.password), | |
| role=UserRole.participant, | |
| ) | |
| db.add(user) | |
| await db.commit() | |
| await db.refresh(user) | |
| return user | |
| async def login( | |
| form: OAuth2PasswordRequestForm = Depends(), | |
| db: AsyncSession = Depends(get_db), | |
| ) -> TokenResponse: | |
| """Authenticate with email + password, return a JWT access token. | |
| The OAuth2 form field is named ``username`` by spec, but we treat its value | |
| as the user's email (with a username fallback for convenience).""" | |
| ident = form.username.strip() | |
| result = await db.execute( | |
| select(User).where(or_(User.email == ident, User.username == ident)) | |
| ) | |
| user: Optional[User] = result.scalar_one_or_none() | |
| if not user or not verify_password(form.password, user.hashed_password): | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Incorrect email or password", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| if not user.is_active: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Account is disabled", | |
| ) | |
| token = create_access_token({"sub": str(user.id)}) | |
| return TokenResponse(access_token=token) | |
| async def get_me(current_user: User = Depends(get_current_user)) -> User: | |
| """Return the profile of the currently authenticated user.""" | |
| return current_user | |
| # =========================================================================== | |
| # Meetings router | |
| # =========================================================================== | |
| meetings_router = APIRouter(prefix="/meetings", tags=["Meetings"]) | |
| async def list_meetings( | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> List[MeetingResponse]: | |
| """List meetings the current user hosts or belongs to.""" | |
| result = await db.execute( | |
| select(Meeting) | |
| .join(MeetingMember, MeetingMember.meeting_id == Meeting.id) | |
| .where(MeetingMember.user_id == current_user.id) | |
| .order_by(Meeting.created_at.desc()) | |
| ) | |
| meetings = list(result.scalars().unique().all()) | |
| return [await _enrich_meeting(db, m, current_user) for m in meetings] | |
| async def create_meeting( | |
| body: MeetingCreateRequest, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> MeetingResponse: | |
| meeting = Meeting( | |
| title=body.title, | |
| description=body.description, | |
| host_id=current_user.id, | |
| invite_token=secrets.token_urlsafe(16), | |
| ) | |
| db.add(meeting) | |
| await db.flush() | |
| # Creator is automatically the host of their meeting. | |
| db.add( | |
| MeetingMember( | |
| meeting_id=meeting.id, | |
| user_id=current_user.id, | |
| role=MeetingRole.host, | |
| ) | |
| ) | |
| await db.commit() | |
| await db.refresh(meeting) | |
| return await _enrich_meeting(db, meeting, current_user) | |
| async def get_meeting( | |
| meeting_id: int, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> MeetingResponse: | |
| meeting = await _get_meeting_or_404(db, meeting_id) | |
| await _require_membership(db, meeting_id, current_user) | |
| return await _enrich_meeting(db, meeting, current_user) | |
| # --------------------------------------------------------------------------- | |
| # Membership management | |
| # --------------------------------------------------------------------------- | |
| async def list_members( | |
| meeting_id: int, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> List[MemberResponse]: | |
| meeting = await _get_meeting_or_404(db, meeting_id) | |
| await _require_membership(db, meeting_id, current_user) | |
| result = await db.execute( | |
| select(MeetingMember, User) | |
| .join(User, User.id == MeetingMember.user_id) | |
| .where(MeetingMember.meeting_id == meeting_id) | |
| .order_by(MeetingMember.joined_at.asc()) | |
| ) | |
| members: List[MemberResponse] = [] | |
| for member, user in result.all(): | |
| members.append( | |
| MemberResponse( | |
| user_id=user.id, | |
| username=user.username, | |
| email=user.email, | |
| role=member.role, | |
| is_host=user.id == meeting.host_id, | |
| joined_at=member.joined_at, | |
| ) | |
| ) | |
| return members | |
| async def add_member( | |
| meeting_id: int, | |
| body: AddMemberRequest, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> MemberResponse: | |
| """Add an existing user to the meeting by username or email.""" | |
| meeting = await _get_meeting_or_404(db, meeting_id) | |
| await _require_meeting_role( | |
| db, meeting_id, current_user, {MeetingRole.host, MeetingRole.moderator} | |
| ) | |
| ident = body.identifier.strip() | |
| target = ( | |
| await db.execute( | |
| select(User).where(or_(User.username == ident, User.email == ident)) | |
| ) | |
| ).scalar_one_or_none() | |
| if target is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"No user found matching '{ident}'", | |
| ) | |
| existing = ( | |
| await db.execute( | |
| select(MeetingMember).where( | |
| MeetingMember.meeting_id == meeting_id, | |
| MeetingMember.user_id == target.id, | |
| ) | |
| ) | |
| ).scalar_one_or_none() | |
| if existing is not None: | |
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, | |
| detail=f"{target.username} is already a member", | |
| ) | |
| # Only the host may grant the host role; moderators can add up to moderator. | |
| role = _coerce_assignable_role(body.role) | |
| member = MeetingMember(meeting_id=meeting_id, user_id=target.id, role=role) | |
| db.add(member) | |
| await db.commit() | |
| await db.refresh(member) | |
| return MemberResponse( | |
| user_id=target.id, | |
| username=target.username, | |
| email=target.email, | |
| role=member.role, | |
| is_host=target.id == meeting.host_id, | |
| joined_at=member.joined_at, | |
| ) | |
| async def update_member_role( | |
| meeting_id: int, | |
| user_id: int, | |
| body: UpdateMemberRoleRequest, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> MemberResponse: | |
| meeting = await _get_meeting_or_404(db, meeting_id) | |
| # Only the host can change roles. | |
| await _require_meeting_role(db, meeting_id, current_user, {MeetingRole.host}) | |
| if user_id == meeting.host_id: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="The meeting host's role cannot be changed", | |
| ) | |
| member, user = await _get_member_or_404(db, meeting_id, user_id) | |
| member.role = body.role | |
| await db.commit() | |
| await db.refresh(member) | |
| return MemberResponse( | |
| user_id=user.id, | |
| username=user.username, | |
| email=user.email, | |
| role=member.role, | |
| is_host=user.id == meeting.host_id, | |
| joined_at=member.joined_at, | |
| ) | |
| async def remove_member( | |
| meeting_id: int, | |
| user_id: int, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> None: | |
| meeting = await _get_meeting_or_404(db, meeting_id) | |
| # Hosts/moderators can remove others; any member can remove themselves (leave). | |
| if user_id != current_user.id: | |
| await _require_meeting_role( | |
| db, meeting_id, current_user, {MeetingRole.host, MeetingRole.moderator} | |
| ) | |
| if user_id == meeting.host_id: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="The meeting host cannot be removed", | |
| ) | |
| member, _user = await _get_member_or_404(db, meeting_id, user_id) | |
| await db.delete(member) | |
| await db.commit() | |
| # --------------------------------------------------------------------------- | |
| # Invite links | |
| # --------------------------------------------------------------------------- | |
| async def rotate_invite( | |
| meeting_id: int, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> MeetingResponse: | |
| """Regenerate the invite token, invalidating the previous link.""" | |
| meeting = await _get_meeting_or_404(db, meeting_id) | |
| await _require_meeting_role(db, meeting_id, current_user, {MeetingRole.host}) | |
| meeting.invite_token = secrets.token_urlsafe(16) | |
| await db.commit() | |
| await db.refresh(meeting) | |
| return await _enrich_meeting(db, meeting, current_user) | |
| async def get_invite_info( | |
| token: str, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> InviteInfoResponse: | |
| """Preview a meeting from its invite token (authentication required).""" | |
| meeting = await _get_meeting_by_token_or_404(db, token) | |
| host = ( | |
| await db.execute(select(User).where(User.id == meeting.host_id)) | |
| ).scalar_one_or_none() | |
| member_count = ( | |
| await db.execute( | |
| select(func.count()) | |
| .select_from(MeetingMember) | |
| .where(MeetingMember.meeting_id == meeting.id) | |
| ) | |
| ).scalar_one() | |
| already = ( | |
| await db.execute( | |
| select(MeetingMember).where( | |
| MeetingMember.meeting_id == meeting.id, | |
| MeetingMember.user_id == current_user.id, | |
| ) | |
| ) | |
| ).scalar_one_or_none() | |
| return InviteInfoResponse( | |
| meeting_id=meeting.id, | |
| title=meeting.title, | |
| description=meeting.description, | |
| host_username=host.username if host else "unknown", | |
| member_count=member_count, | |
| status=meeting.status, | |
| already_member=already is not None, | |
| ) | |
| async def accept_invite( | |
| token: str, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> MeetingResponse: | |
| """Join a meeting via its invite token as a participant.""" | |
| meeting = await _get_meeting_by_token_or_404(db, token) | |
| if meeting.status == MeetingStatus.ended: | |
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, | |
| detail="This meeting has ended", | |
| ) | |
| existing = ( | |
| await db.execute( | |
| select(MeetingMember).where( | |
| MeetingMember.meeting_id == meeting.id, | |
| MeetingMember.user_id == current_user.id, | |
| ) | |
| ) | |
| ).scalar_one_or_none() | |
| if existing is None: | |
| db.add( | |
| MeetingMember( | |
| meeting_id=meeting.id, | |
| user_id=current_user.id, | |
| role=MeetingRole.participant, | |
| ) | |
| ) | |
| await db.commit() | |
| await db.refresh(meeting) | |
| return await _enrich_meeting(db, meeting, current_user) | |
| async def end_meeting( | |
| meeting_id: int, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> Meeting: | |
| meeting = await _get_meeting_or_404(db, meeting_id) | |
| _require_host_or_admin(meeting, current_user) | |
| if meeting.status == MeetingStatus.ended: | |
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, | |
| detail="Meeting is already ended", | |
| ) | |
| meeting.status = MeetingStatus.ended | |
| meeting.ended_at = datetime.now(timezone.utc).replace(tzinfo=None) | |
| await db.commit() | |
| await db.refresh(meeting) | |
| return meeting | |
| # --------------------------------------------------------------------------- | |
| # Microphone sub-resource | |
| # --------------------------------------------------------------------------- | |
| async def list_microphones( | |
| meeting_id: int, | |
| db: AsyncSession = Depends(get_db), | |
| _: User = Depends(get_current_user), | |
| ) -> List[MicrophoneConfig]: | |
| await _get_meeting_or_404(db, meeting_id) | |
| result = await db.execute( | |
| select(MicrophoneConfig).where(MicrophoneConfig.meeting_id == meeting_id) | |
| ) | |
| return list(result.scalars().all()) | |
| async def add_microphone( | |
| meeting_id: int, | |
| body: MicrophoneCreateRequest, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> MicrophoneConfig: | |
| meeting = await _get_meeting_or_404(db, meeting_id) | |
| if meeting.status == MeetingStatus.ended: | |
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, | |
| detail="Cannot add microphone to an ended meeting", | |
| ) | |
| # If this mic is designated chairman, demote any existing chairman | |
| if body.is_chairman: | |
| existing_chairmen = await db.execute( | |
| select(MicrophoneConfig).where( | |
| MicrophoneConfig.meeting_id == meeting_id, | |
| MicrophoneConfig.is_chairman.is_(True), | |
| ) | |
| ) | |
| for chm in existing_chairmen.scalars().all(): | |
| chm.is_chairman = False | |
| mic = MicrophoneConfig( | |
| meeting_id=meeting_id, | |
| user_id=current_user.id, | |
| name=body.name, | |
| priority=body.priority, | |
| is_chairman=body.is_chairman, | |
| ) | |
| db.add(mic) | |
| await db.commit() | |
| await db.refresh(mic) | |
| return mic | |
| async def update_microphone( | |
| meeting_id: int, | |
| mic_id: int, | |
| body: MicrophonePatchRequest, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> MicrophoneConfig: | |
| mic = await _get_mic_or_404(db, meeting_id, mic_id) | |
| # Promoting a new chairman demotes all others in the meeting | |
| if body.is_chairman is True and not mic.is_chairman: | |
| existing_chairmen = await db.execute( | |
| select(MicrophoneConfig).where( | |
| MicrophoneConfig.meeting_id == meeting_id, | |
| MicrophoneConfig.is_chairman.is_(True), | |
| ) | |
| ) | |
| for chm in existing_chairmen.scalars().all(): | |
| chm.is_chairman = False | |
| if body.is_muted is not None: | |
| mic.is_muted = body.is_muted | |
| if body.priority is not None: | |
| mic.priority = body.priority | |
| if body.is_chairman is not None: | |
| mic.is_chairman = body.is_chairman | |
| if body.name is not None: | |
| mic.name = body.name | |
| await db.commit() | |
| await db.refresh(mic) | |
| return mic | |
| async def remove_microphone( | |
| meeting_id: int, | |
| mic_id: int, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> None: | |
| mic = await _get_mic_or_404(db, meeting_id, mic_id) | |
| await db.delete(mic) | |
| await db.commit() | |
| # =========================================================================== | |
| # Diagnostics router | |
| # =========================================================================== | |
| diagnostics_router = APIRouter(prefix="/diagnostics", tags=["Diagnostics"]) | |
| async def get_diagnostics( | |
| limit: int = 20, | |
| db: AsyncSession = Depends(get_db), | |
| _: User = Depends(get_current_user), | |
| ) -> List[SystemDiagnostic]: | |
| result = await db.execute( | |
| select(SystemDiagnostic) | |
| .order_by(SystemDiagnostic.timestamp.desc()) | |
| .limit(limit) | |
| ) | |
| return list(result.scalars().all()) | |
| async def record_diagnostic( | |
| body: DiagnosticCreateRequest, | |
| db: AsyncSession = Depends(get_db), | |
| _: User = Depends(get_current_user), | |
| ) -> SystemDiagnostic: | |
| diag = SystemDiagnostic(**body.model_dump()) | |
| db.add(diag) | |
| await db.commit() | |
| await db.refresh(diag) | |
| return diag | |
| # =========================================================================== | |
| # Speech logs router | |
| # =========================================================================== | |
| speech_logs_router = APIRouter(prefix="/speech-logs", tags=["Speech Logs"]) | |
| async def get_speech_logs( | |
| meeting_id: int, | |
| db: AsyncSession = Depends(get_db), | |
| _: User = Depends(get_current_user), | |
| ) -> List[SpeechLog]: | |
| await _get_meeting_or_404(db, meeting_id) | |
| result = await db.execute( | |
| select(SpeechLog) | |
| .where(SpeechLog.meeting_id == meeting_id) | |
| .order_by(SpeechLog.start_time.desc()) | |
| ) | |
| return list(result.scalars().all()) | |
| # =========================================================================== | |
| # Users router (directory search for inviting people) | |
| # =========================================================================== | |
| users_router = APIRouter(prefix="/users", tags=["Users"]) | |
| async def search_users( | |
| q: str = "", | |
| limit: int = 8, | |
| db: AsyncSession = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ) -> List[UserSearchResult]: | |
| """Find users by username or email fragment, to add them to a meeting.""" | |
| q = q.strip() | |
| if len(q) < 2: | |
| return [] | |
| pattern = f"%{q}%" | |
| result = await db.execute( | |
| select(User) | |
| .where( | |
| User.id != current_user.id, | |
| or_(User.username.ilike(pattern), User.email.ilike(pattern)), | |
| ) | |
| .limit(min(limit, 25)) | |
| ) | |
| return [ | |
| UserSearchResult(id=u.id, username=u.username, email=u.email) | |
| for u in result.scalars().all() | |
| ] | |
| # =========================================================================== | |
| # Aggregate router (mounted in main.py under /api) | |
| # =========================================================================== | |
| router.include_router(auth_router) | |
| router.include_router(meetings_router) | |
| router.include_router(users_router) | |
| router.include_router(diagnostics_router) | |
| router.include_router(speech_logs_router) | |
| # =========================================================================== | |
| # Private helpers | |
| # =========================================================================== | |
| async def _get_meeting_or_404(db: AsyncSession, meeting_id: int) -> Meeting: | |
| result = await db.execute(select(Meeting).where(Meeting.id == meeting_id)) | |
| meeting: Optional[Meeting] = result.scalar_one_or_none() | |
| if meeting is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"Meeting {meeting_id} not found", | |
| ) | |
| return meeting | |
| async def _get_mic_or_404( | |
| db: AsyncSession, meeting_id: int, mic_id: int | |
| ) -> MicrophoneConfig: | |
| result = await db.execute( | |
| select(MicrophoneConfig).where( | |
| MicrophoneConfig.id == mic_id, | |
| MicrophoneConfig.meeting_id == meeting_id, | |
| ) | |
| ) | |
| mic: Optional[MicrophoneConfig] = result.scalar_one_or_none() | |
| if mic is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"Microphone {mic_id} not found in meeting {meeting_id}", | |
| ) | |
| return mic | |
| def _require_host_or_admin(meeting: Meeting, user: User) -> None: | |
| if meeting.host_id != user.id and user.role != UserRole.admin: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Only the meeting host or an admin can perform this action", | |
| ) | |
| async def _get_meeting_by_token_or_404(db: AsyncSession, token: str) -> Meeting: | |
| result = await db.execute( | |
| select(Meeting).where(Meeting.invite_token == token) | |
| ) | |
| meeting: Optional[Meeting] = result.scalar_one_or_none() | |
| if meeting is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail="Invite link is invalid or has been revoked", | |
| ) | |
| return meeting | |
| async def _get_membership( | |
| db: AsyncSession, meeting_id: int, user_id: int | |
| ) -> Optional[MeetingMember]: | |
| return ( | |
| await db.execute( | |
| select(MeetingMember).where( | |
| MeetingMember.meeting_id == meeting_id, | |
| MeetingMember.user_id == user_id, | |
| ) | |
| ) | |
| ).scalar_one_or_none() | |
| async def _require_membership( | |
| db: AsyncSession, meeting_id: int, user: User | |
| ) -> MeetingMember: | |
| membership = await _get_membership(db, meeting_id, user.id) | |
| if membership is None and user.role != UserRole.admin: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="You are not a member of this meeting", | |
| ) | |
| return membership | |
| async def _require_meeting_role( | |
| db: AsyncSession, meeting_id: int, user: User, allowed: set | |
| ) -> MeetingMember: | |
| membership = await _get_membership(db, meeting_id, user.id) | |
| if user.role == UserRole.admin: | |
| return membership | |
| if membership is None or membership.role not in allowed: | |
| names = ", ".join(sorted(r.value for r in allowed)) | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail=f"This action requires one of these meeting roles: {names}", | |
| ) | |
| return membership | |
| async def _get_member_or_404(db: AsyncSession, meeting_id: int, user_id: int): | |
| result = await db.execute( | |
| select(MeetingMember, User) | |
| .join(User, User.id == MeetingMember.user_id) | |
| .where( | |
| MeetingMember.meeting_id == meeting_id, | |
| MeetingMember.user_id == user_id, | |
| ) | |
| ) | |
| row = result.first() | |
| if row is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail="That user is not a member of this meeting", | |
| ) | |
| return row[0], row[1] | |
| def _coerce_assignable_role(requested: MeetingRole) -> MeetingRole: | |
| """No one is granted the host role when being added; the host is the | |
| creator. Host requests are downgraded to moderator.""" | |
| if requested == MeetingRole.host: | |
| return MeetingRole.moderator | |
| return requested | |
| async def _enrich_meeting( | |
| db: AsyncSession, meeting: Meeting, user: User | |
| ) -> MeetingResponse: | |
| """Build a MeetingResponse with the caller's role and the member count.""" | |
| member_count = ( | |
| await db.execute( | |
| select(func.count()) | |
| .select_from(MeetingMember) | |
| .where(MeetingMember.meeting_id == meeting.id) | |
| ) | |
| ).scalar_one() | |
| membership = await _get_membership(db, meeting.id, user.id) | |
| return MeetingResponse( | |
| id=meeting.id, | |
| title=meeting.title, | |
| description=meeting.description, | |
| host_id=meeting.host_id, | |
| invite_token=meeting.invite_token, | |
| created_at=meeting.created_at, | |
| ended_at=meeting.ended_at, | |
| status=meeting.status, | |
| my_role=membership.role if membership else None, | |
| member_count=member_count, | |
| ) | |