Files
chat_backend/llm_be/chat_backend/views_drive.py
T
westfarn d54094f5e0
Unit Tests / test (push) Successful in 10s
Deploy Beta / unit-tests (push) Successful in 10s
Deploy Beta / docker (push) Successful in 21s
Deploy Beta / deploy-beta (push) Successful in 40s
Tier-gated RAG + Drive document sources (#42) (#54)
## Summary

Implements epic [#42](#42) (children #43–#53) and advances [#11](#11).

- **Entitlement:** `allows_rag` on plans (founders / backer / pro / business; not standard); exposed as `features.rag`
- **Gates:** document REST + WS `PromptType.RAG` use `assert_feature_allowed(..., "rag")`
- **Lifecycle:** dedupe ingest, delete vectors by `document_id`, honor `active`, fix document detail PATCH/DELETE
- **Workspaces:** auto-create default company workspace; fail-closed scoping
- **Drive:** personal + company Google/Microsoft connect (`link_drive` / `link_company_drive`), resource selection, sync, webhooks stubs, `sync_drive_connections` management command
- **Docs/env:** README + `.env*.example` updated

Companion FE: `chat_web_app` branch `feature/rag-epic-42-ui` (#81–#85).

## Test plan

- [x] `SKIP_RAG_INIT=1 uv run python manage.py test` (457 OK)
- [ ] Migrate finance `0004` + chat_backend `0028` on beta
- [ ] Verify Standard user: Documents API 403 + no RAG retrieval
- [ ] Verify Founders/Pro: upload + list + active toggle
- [ ] Connect Google/Microsoft Drive (incremental scopes) and Sync
- [ ] Company manager: `link_company_drive`; non-manager 403
- [ ] Run `manage.py sync_drive_connections`Reviewed-on: #54
2026-08-01 14:02:36 -07:00

205 lines
7.7 KiB
Python

"""Drive connection management, sync, and webhook API (#47-#52)."""
from __future__ import annotations
import logging
from django.db.models import Q
from django.http import HttpResponse
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from finance.services.quotas import FeatureNotAllowed, assert_feature_allowed
from .models import DriveConnection
from .serializers import DriveConnectionResourcesSerializer, DriveConnectionSerializer
from .services.drive_sync import sync_connection
logger = logging.getLogger(__name__)
def _require_rag_feature(user) -> Response | None:
"""Return a 403 Response when the plan doesn't allow Drive/RAG, else None."""
try:
assert_feature_allowed(user, "rag")
except FeatureNotAllowed as exc:
return Response({"detail": exc.message, "code": exc.code}, status=status.HTTP_403_FORBIDDEN)
return None
def _user_can_manage(connection: DriveConnection, user) -> bool:
"""Owner may manage personal connections; only managers manage company ones (#50/#51)."""
if connection.kind == DriveConnection.Kind.PERSONAL:
return connection.user_id == user.id
return connection.company_id == user.company_id and bool(user.is_company_manager)
class DriveConnectionListView(APIView):
"""GET /api/drive/connections/ — user's personal + own-company connections."""
def get(self, request):
denied = _require_rag_feature(request.user)
if denied is not None:
return denied
user = request.user
connections = DriveConnection.objects.filter(
Q(kind=DriveConnection.Kind.PERSONAL, user=user)
| Q(kind=DriveConnection.Kind.COMPANY, company_id=user.company_id)
).order_by("-created")
serializer = DriveConnectionSerializer(connections, many=True)
return Response(serializer.data)
class DriveConnectionDetailView(APIView):
"""DELETE /api/drive/connections/<id>/ — disconnect (owner or company manager)."""
def delete(self, request, connection_id):
denied = _require_rag_feature(request.user)
if denied is not None:
return denied
connection = DriveConnection.objects.filter(id=connection_id).first()
if connection is None:
return Response({"detail": "Connection not found."}, status=status.HTTP_404_NOT_FOUND)
if not _user_can_manage(connection, request.user):
return Response(
{"detail": "You are not allowed to disconnect this connection."},
status=status.HTTP_403_FORBIDDEN,
)
connection.is_active = False
connection.access_token = ""
connection.refresh_token = ""
connection.last_sync_status = DriveConnection.SyncStatus.NEVER
connection.save(
update_fields=[
"is_active",
"access_token",
"refresh_token",
"last_sync_status",
"last_modified",
]
)
return Response(status=status.HTTP_204_NO_CONTENT)
class DriveConnectionResourcesView(APIView):
"""POST /api/drive/connections/<id>/resources/ — set selected folder/drive/site ids."""
def post(self, request, connection_id):
denied = _require_rag_feature(request.user)
if denied is not None:
return denied
connection = DriveConnection.objects.filter(id=connection_id).first()
if connection is None:
return Response({"detail": "Connection not found."}, status=status.HTTP_404_NOT_FOUND)
if not _user_can_manage(connection, request.user):
return Response(
{"detail": "You are not allowed to configure this connection."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = DriveConnectionResourcesSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
connection.selected_resource_ids = serializer.validated_data["resource_ids"]
connection.selected_resource_labels = serializer.validated_data.get(
"resource_labels", []
)
connection.save(
update_fields=["selected_resource_ids", "selected_resource_labels", "last_modified"]
)
return Response(DriveConnectionSerializer(connection).data)
class DriveConnectionSyncView(APIView):
"""POST /api/drive/connections/<id>/sync/ — trigger a sync now."""
def post(self, request, connection_id):
denied = _require_rag_feature(request.user)
if denied is not None:
return denied
connection = DriveConnection.objects.filter(id=connection_id).first()
if connection is None:
return Response({"detail": "Connection not found."}, status=status.HTTP_404_NOT_FOUND)
if not _user_can_manage(connection, request.user):
return Response(
{"detail": "You are not allowed to sync this connection."},
status=status.HTTP_403_FORBIDDEN,
)
if not connection.is_active:
return Response(
{"detail": "This connection is disconnected."},
status=status.HTTP_400_BAD_REQUEST,
)
result = sync_connection(connection)
connection.refresh_from_db()
return Response(
{"result": result, "connection": DriveConnectionSerializer(connection).data}
)
def _maybe_sync_from_webhook(connection_id, provider: str) -> None:
"""Best-effort sync trigger for a webhook payload (#52).
Providers don't reliably identify the local connection without a prior
``watch``/subscription setup that stores our id as the channel token /
``clientState``. When we can't resolve a connection, ack with 200 anyway
per the provider contract (retrying would just repeat the same lookup).
"""
if not connection_id:
return
connection = DriveConnection.objects.filter(
id=connection_id, provider=provider, is_active=True
).first()
if connection is None:
return
try:
sync_connection(connection)
except Exception:
logger.exception(
"Webhook-triggered Drive sync failed for connection=%s", connection_id
)
class DriveWebhookGoogleView(APIView):
"""POST /api/drive/webhooks/google/ — Google Drive push notification stub."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request):
connection_id = request.query_params.get("connection_id") or request.META.get(
"HTTP_X_GOOG_CHANNEL_TOKEN"
)
_maybe_sync_from_webhook(connection_id, DriveConnection.Provider.GOOGLE)
return Response(status=status.HTTP_200_OK)
class DriveWebhookMicrosoftView(APIView):
"""POST /api/drive/webhooks/microsoft/ — Microsoft Graph change notification stub."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request):
validation_token = request.query_params.get("validationToken")
if validation_token:
# Graph subscription-creation handshake: echo the token back as text/plain.
return HttpResponse(validation_token, content_type="text/plain")
connection_id = request.query_params.get("connection_id")
if not connection_id:
for notification in (request.data or {}).get("value", []):
connection_id = notification.get("clientState")
if connection_id:
break
_maybe_sync_from_webhook(connection_id, DriveConnection.Provider.MICROSOFT)
return Response(status=status.HTTP_200_OK)