SDNmeeting / api /rest_api.py
Che237
Deploy SD-MMMS FastAPI backend as Docker Space
900edd0
Raw
History Blame Contribute Delete
30.4 kB
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,
)