## 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
205 lines
7.7 KiB
Python
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)
|