diff --git a/attendee/settings/base.py b/attendee/settings/base.py index cc9252f36..b971b2ad9 100644 --- a/attendee/settings/base.py +++ b/attendee/settings/base.py @@ -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"), }, } diff --git a/attendee/urls.py b/attendee/urls.py index b2574e737..0ede09325 100644 --- a/attendee/urls.py +++ b/attendee/urls.py @@ -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")), ] diff --git a/bots/meetings_api_urls.py b/bots/meetings_api_urls.py new file mode 100644 index 000000000..9f7e6703c --- /dev/null +++ b/bots/meetings_api_urls.py @@ -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/", + meetings_api_views.MeetingDetailView.as_view(), + name="meeting-detail", + ), + path( + "meetings//transcript", + meetings_api_views.MeetingTranscriptView.as_view(), + name="meeting-transcript", + ), +] diff --git a/bots/meetings_api_views.py b/bots/meetings_api_views.py new file mode 100644 index 000000000..bf3b5cd9f --- /dev/null +++ b/bots/meetings_api_views.py @@ -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) diff --git a/bots/meetings_serializers.py b/bots/meetings_serializers.py new file mode 100644 index 000000000..317c241a0 --- /dev/null +++ b/bots/meetings_serializers.py @@ -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 diff --git a/bots/team_day_user_auth.py b/bots/team_day_user_auth.py index 8506a1634..8182faa9c 100644 --- a/bots/team_day_user_auth.py +++ b/bots/team_day_user_auth.py @@ -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. diff --git a/bots/throttling.py b/bots/throttling.py index 4d4dd639b..74b65db67 100644 --- a/bots/throttling.py +++ b/bots/throttling.py @@ -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} diff --git a/version.json b/version.json index 2d5653e40..bebef9816 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "1.50.0" + "version": "1.51.0" }