Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions attendee/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {
"project_post": os.getenv("PROJECT_POST_THROTTLE_RATE", "3000/min"),
# Per-member read budget. Generous enough that opening history or polling a transcript
# never trips it, low enough to stop a runaway client hammering the database.
"member_read": os.getenv("MEMBER_READ_THROTTLE_RATE", "600/min"),
},
}

Expand Down
1 change: 1 addition & 0 deletions attendee/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def version_view(request):
path("api/v1/", include("bots.zoom_oauth_connections_api_urls")),
path("api/v1/", include("bots.app_session_api_urls")),
path("api/v1/", include("bots.local_session_api_urls")),
path("api/v1/", include("bots.meetings_api_urls")),
path("api/v1/", include("bots.bots_api_urls")),
]

Expand Down
29 changes: 29 additions & 0 deletions bots/meetings_api_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Member-facing meeting history.

Split by purpose rather than by session type: ``/local_sessions/*`` controls a recording while
it is happening (start, upload, pause, stop), and ``/meetings/*`` is the read side -- history,
status, transcript and deletion -- covering bot meetings and local recordings alike.
"""

from django.urls import path

from . import meetings_api_views

urlpatterns = [
path(
"meetings",
meetings_api_views.MeetingListView.as_view(),
name="meeting-list",
),
# GET returns the meeting + status; DELETE removes it (cancelling it if it hasn't run yet).
path(
"meetings/<str:object_id>",
meetings_api_views.MeetingDetailView.as_view(),
name="meeting-detail",
),
path(
"meetings/<str:object_id>/transcript",
meetings_api_views.MeetingTranscriptView.as_view(),
name="meeting-transcript",
),
]
166 changes: 166 additions & 0 deletions bots/meetings_api_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Per-member meeting history: list, status, transcript and delete.

Scoped to the member named by the verified ``X-User-Token``, covering BOTH meeting bots and
local recordings, so the desktop has one history regardless of how a meeting was captured.

Two rules run through everything here:

* **Ownership is a filter, never a check afterwards.** A meeting that isn't yours is
indistinguishable from one that doesn't exist, so a 404 never confirms it is real.
* **Never build the query from an empty member id.** ``filter(owner_user_id=None)`` compiles to
``IS NULL``, which would return every unowned meeting in the project -- so the id is resolved
(and the request rejected) before any queryset is touched.
"""

import logging

from django.db.models import Count, Q
from rest_framework import status
from rest_framework.pagination import CursorPagination
from rest_framework.response import Response
from rest_framework.views import APIView

from .authentication import ApiKeyAuthentication
from .bots_api_utils import delete_bot
from .bots_api_views import TranscriptView
from .local_session_store import clear_session_state
from .meetings_serializers import MeetingSerializer
from .models import Bot, BotStates, SessionTypes
from .team_day_user_auth import decode_user_id
from .throttling import MemberReadThrottle

logger = logging.getLogger(__name__)

SOURCE_TO_SESSION_TYPE = {"bot": SessionTypes.BOT, "local": SessionTypes.LOCAL}


class MeetingCursorPagination(CursorPagination):
"""Newest first, with the row id as a tiebreak.

The project's existing BotCursorPagination orders by ``created_at`` ascending, which would
open a member's history on their oldest meeting. The ``-id`` matters as much as the order:
without a unique tiebreak, meetings sharing a created_at can be skipped or repeated across
page boundaries.
"""

ordering = ("-created_at", "-id")
page_size = 25


def owned_meetings(request, owner_user_id):
"""Base queryset: this member's meetings in this project, ready for listing.

Annotated with the transcription counts the status needs, so rendering a page costs a fixed
number of queries instead of a few per row.
"""
pending = Q(recordings__utterances__transcription__isnull=True, recordings__utterances__failure_data__isnull=True)
failed = Q(recordings__utterances__failure_data__isnull=False)
return (
Bot.objects.filter(project=request.auth.project, owner_user_id=owner_user_id)
# Deleted meetings keep their row (delete_data wipes contents but marks the row), so
# they must be excluded or they resurface as blank entries.
.exclude(state=BotStates.DATA_DELETED)
# Zoom RTMS app sessions are neither a bot nor a local recording.
.exclude(session_type=SessionTypes.APP_SESSION)
.prefetch_related("recordings")
.annotate(
pending_utterances=Count("recordings__utterances", filter=pending, distinct=True),
failed_utterances=Count("recordings__utterances", filter=failed, distinct=True),
)
)


def find_owned_meeting(request, object_id, owner_user_id):
"""One meeting, or None. Deleted meetings are still addressable so delete stays idempotent."""
return Bot.objects.filter(
object_id=object_id,
project=request.auth.project,
owner_user_id=owner_user_id,
).first()


class MeetingListView(APIView):
authentication_classes = [ApiKeyAuthentication]
throttle_classes = [MemberReadThrottle]

def get(self, request):
owner_user_id = decode_user_id(request)
queryset = owned_meetings(request, owner_user_id)

source = request.query_params.get("source")
if source:
session_type = SOURCE_TO_SESSION_TYPE.get(source)
if session_type is None:
return Response(
{"error": f"source must be one of {sorted(SOURCE_TO_SESSION_TYPE)}"},
status=status.HTTP_400_BAD_REQUEST,
)
queryset = queryset.filter(session_type=session_type)

paginator = MeetingCursorPagination()
page = paginator.paginate_queryset(queryset, request, view=self)
return paginator.get_paginated_response(MeetingSerializer(page, many=True).data)


class MeetingDetailView(APIView):
authentication_classes = [ApiKeyAuthentication]
throttle_classes = [MemberReadThrottle]

def get(self, request, object_id):
owner_user_id = decode_user_id(request)
bot = owned_meetings(request, owner_user_id).filter(object_id=object_id).first()
if bot is None:
return Response({"error": "Meeting not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(MeetingSerializer(bot).data, status=status.HTTP_200_OK)

def delete(self, request, object_id):
owner_user_id = decode_user_id(request)
bot = find_owned_meeting(request, object_id, owner_user_id)
if bot is None:
return Response({"error": "Meeting not found"}, status=status.HTTP_404_NOT_FOUND)

# Already deleted: say so calmly. A double-click, a retry after a lost response, or a
# second device acting on a stale list would otherwise raise out of delete_data().
if bot.state == BotStates.DATA_DELETED:
return Response(status=status.HTTP_204_NO_CONTENT)

# A meeting that hasn't run yet is CANCELLED, not deleted -- a different operation that
# removes the row outright rather than wiping the contents of something that happened.
if bot.state == BotStates.SCHEDULED:
cancelled, error = delete_bot(bot)
if not cancelled:
return Response(error, status=status.HTTP_409_CONFLICT)
logger.info(f"Cancelled scheduled meeting {object_id}")
return Response(status=status.HTTP_204_NO_CONTENT)

# Anything still running cannot be deleted: delete_data() only accepts a finished
# meeting, and letting it raise would surface as a 500 instead of something actionable.
if bot.state not in BotStates.post_meeting_states():
return Response(
{"error": "This meeting is still in progress. Stop it before deleting."},
status=status.HTTP_409_CONFLICT,
)

bot.delete_data()
if bot.session_type == SessionTypes.LOCAL:
# Drop any queued audio/tail/lock so a deleted session leaves nothing behind.
clear_session_state(bot.id)
logger.info(f"Deleted meeting data for {object_id}")
return Response(status=status.HTTP_204_NO_CONTENT)


class MeetingTranscriptView(TranscriptView):
"""Owner-gated transcript for bot and local meetings alike.

Reuses the bot transcript logic unchanged and only adds the ownership gate in front: the
inherited view scopes by project, and one project API key is shared by every desktop
install, so on its own it would let any member read any member's transcript.
"""

throttle_classes = [MemberReadThrottle]

def get(self, request, object_id):
owner_user_id = decode_user_id(request)
if find_owned_meeting(request, object_id, owner_user_id) is None:
return Response({"error": "Meeting not found"}, status=status.HTTP_404_NOT_FOUND)
return super().get(request, object_id)
65 changes: 65 additions & 0 deletions bots/meetings_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Serializer for the member-facing meeting history.

Deliberately NOT BotSerializer: that one exposes `events`, `state`, `transcription_state` and
`recording_state` as method fields, each of which hits the database per row. That is fine for a
single bot, but on a 25-row page it turns one query into dozens (the classic N+1). This carries
only what a history list actually shows, and reads the annotations/prefetches the queryset
already provides, so rendering a page costs a fixed number of queries however long the page is.

Kept in its own module because bots/serializers.py is already ~2400 lines.
"""

from rest_framework import serializers

from . import meeting_status
from .models import SessionTypes

SESSION_TYPE_TO_SOURCE = {
SessionTypes.BOT: "bot",
SessionTypes.LOCAL: "local",
SessionTypes.APP_SESSION: "app",
}


def default_recording_of(bot):
"""The default recording from the prefetched set, or None.

Iterates the prefetched list rather than filtering, which would issue a fresh query per row
and reintroduce the N+1 this module exists to avoid.
"""
for recording in bot.recordings.all():
if recording.is_default_recording:
return recording
return None


class MeetingSerializer(serializers.Serializer):
id = serializers.CharField(source="object_id")
source = serializers.SerializerMethodField()
name = serializers.CharField()
meeting_url = serializers.CharField()
status = serializers.SerializerMethodField()
created_at = serializers.DateTimeField()
started_at = serializers.SerializerMethodField()
ended_at = serializers.SerializerMethodField()

def get_source(self, bot):
return SESSION_TYPE_TO_SOURCE.get(bot.session_type, "bot")

def get_status(self, bot):
# Counts come from the queryset annotation when listing; derive_status falls back to
# counting itself for a single meeting fetched without them.
return meeting_status.derive_status(
bot,
default_recording_of(bot),
getattr(bot, "pending_utterances", None),
getattr(bot, "failed_utterances", None),
)

def get_started_at(self, bot):
recording = default_recording_of(bot)
return recording.started_at if recording else None

def get_ended_at(self, bot):
recording = default_recording_of(bot)
return recording.completed_at if recording else None
13 changes: 13 additions & 0 deletions bots/team_day_user_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ def decode_user_id(request):
return str(user_id)


def quiet_user_id(request):
"""The member id if one can be established, else None -- never raises.

For throttling, which runs before the view's auth and must not turn a bad token into an
error of its own. An unattributable request simply goes unthrottled here; the view still
rejects it.
"""
try:
return optional_user_id(request)
except Exception:
return None


def optional_user_id(request):
"""Same as decode_user_id, but returns None when no token was sent at all.

Expand Down
27 changes: 27 additions & 0 deletions bots/throttling.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,30 @@ def allow_request(self, request, view):
if settings.DISABLE_RATE_LIMITING:
return True
return super().allow_request(request, view)


class MemberReadThrottle(SimpleRateThrottle):
"""Rate-limit reads by the MEMBER making them, not by their project.

Every desktop install shares one project API key, so throttling per project would let a
single runaway client exhaust the budget for everyone. Keying on the team.day user id keeps
a misbehaving client limited to itself. Requests we cannot attribute to a member are left
alone -- they are rejected by the view's auth anyway.
"""

scope = "member_read"

def allow_request(self, request, view):
if request.method not in ("GET", "HEAD"):
return True
if settings.DISABLE_RATE_LIMITING:
return True
return super().allow_request(request, view)

def get_cache_key(self, request, view):
from bots.team_day_user_auth import quiet_user_id

user_id = quiet_user_id(request)
if not user_id:
return None
return self.cache_format % {"scope": self.scope, "ident": user_id}
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"version": "1.50.0"
"version": "1.51.0"
}
Loading