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"]) @auth_router.post( "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED, ) 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 @auth_router.post("/login", response_model=TokenResponse) 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) @auth_router.get("/me", response_model=UserResponse) 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"]) @meetings_router.get("/", response_model=List[MeetingResponse]) 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] @meetings_router.post( "/", response_model=MeetingResponse, status_code=status.HTTP_201_CREATED, ) 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) @meetings_router.get("/{meeting_id}", response_model=MeetingResponse) 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 # --------------------------------------------------------------------------- @meetings_router.get("/{meeting_id}/members", response_model=List[MemberResponse]) 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 @meetings_router.post( "/{meeting_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED, ) 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, ) @meetings_router.patch( "/{meeting_id}/members/{user_id}", response_model=MemberResponse ) 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, ) @meetings_router.delete( "/{meeting_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT ) 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 # --------------------------------------------------------------------------- @meetings_router.post("/{meeting_id}/invite/rotate", response_model=MeetingResponse) 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) @meetings_router.get("/invite/{token}", response_model=InviteInfoResponse) 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, ) @meetings_router.post("/invite/{token}/accept", response_model=MeetingResponse) 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) @meetings_router.post("/{meeting_id}/end", response_model=MeetingResponse) 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 # --------------------------------------------------------------------------- @meetings_router.get( "/{meeting_id}/microphones", response_model=List[MicrophoneResponse], ) 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()) @meetings_router.post( "/{meeting_id}/microphones", response_model=MicrophoneResponse, status_code=status.HTTP_201_CREATED, ) 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 @meetings_router.patch( "/{meeting_id}/microphones/{mic_id}", response_model=MicrophoneResponse, ) 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 @meetings_router.delete( "/{meeting_id}/microphones/{mic_id}", status_code=status.HTTP_204_NO_CONTENT, ) 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"]) @diagnostics_router.get("/", response_model=List[DiagnosticResponse]) 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()) @diagnostics_router.post( "/", response_model=DiagnosticResponse, status_code=status.HTTP_201_CREATED, ) 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"]) @speech_logs_router.get( "/{meeting_id}", response_model=List[SpeechLogResponse], ) 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"]) @users_router.get("/search", response_model=List[UserSearchResult]) 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, )