From 0eb883287c8ec0956009e9ce024632b5d26abdbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:00:25 +0000 Subject: [PATCH] Add AI API endpoints with permission-protected views Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com> --- src/documents/serialisers.py | 122 +++++++++++++ src/documents/views.py | 337 +++++++++++++++++++++++++++++++++++ src/paperless/urls.py | 31 ++++ 3 files changed, 490 insertions(+) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index f04bb70da..dae87293e 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2696,3 +2696,125 @@ class StoragePathTestSerializer(SerializerWithPerms): label="Document", write_only=True, ) + + +class AISuggestionsRequestSerializer(serializers.Serializer): + """Serializer for requesting AI suggestions for a document.""" + + document_id = serializers.IntegerField( + required=True, + label="Document ID", + help_text="ID of the document to analyze", + ) + + +class AISuggestionSerializer(serializers.Serializer): + """Serializer for a single AI suggestion.""" + + id = serializers.IntegerField() + name = serializers.CharField() + confidence = serializers.FloatField() + + +class AISuggestionsResponseSerializer(serializers.Serializer): + """Serializer for AI suggestions response.""" + + document_id = serializers.IntegerField() + tags = AISuggestionSerializer(many=True, required=False) + correspondent = AISuggestionSerializer(required=False, allow_null=True) + document_type = AISuggestionSerializer(required=False, allow_null=True) + storage_path = AISuggestionSerializer(required=False, allow_null=True) + title_suggestion = serializers.CharField(required=False, allow_null=True) + custom_fields = serializers.DictField(required=False) + + +class ApplyAISuggestionsSerializer(serializers.Serializer): + """Serializer for applying AI suggestions to a document.""" + + document_id = serializers.IntegerField( + required=True, + label="Document ID", + help_text="ID of the document to apply suggestions to", + ) + apply_tags = serializers.BooleanField( + default=False, + label="Apply Tags", + help_text="Whether to apply tag suggestions", + ) + apply_correspondent = serializers.BooleanField( + default=False, + label="Apply Correspondent", + help_text="Whether to apply correspondent suggestion", + ) + apply_document_type = serializers.BooleanField( + default=False, + label="Apply Document Type", + help_text="Whether to apply document type suggestion", + ) + apply_storage_path = serializers.BooleanField( + default=False, + label="Apply Storage Path", + help_text="Whether to apply storage path suggestion", + ) + apply_title = serializers.BooleanField( + default=False, + label="Apply Title", + help_text="Whether to apply title suggestion", + ) + selected_tags = serializers.ListField( + child=serializers.IntegerField(), + required=False, + label="Selected Tags", + help_text="Specific tag IDs to apply (optional)", + ) + + +class AIConfigurationSerializer(serializers.Serializer): + """Serializer for AI configuration settings.""" + + auto_apply_threshold = serializers.FloatField( + required=False, + min_value=0.0, + max_value=1.0, + label="Auto Apply Threshold", + help_text="Confidence threshold for automatic application (0.0-1.0)", + ) + suggest_threshold = serializers.FloatField( + required=False, + min_value=0.0, + max_value=1.0, + label="Suggest Threshold", + help_text="Confidence threshold for suggestions (0.0-1.0)", + ) + ml_enabled = serializers.BooleanField( + required=False, + label="ML Features Enabled", + help_text="Enable/disable ML features", + ) + advanced_ocr_enabled = serializers.BooleanField( + required=False, + label="Advanced OCR Enabled", + help_text="Enable/disable advanced OCR features", + ) + + +class DeletionApprovalSerializer(serializers.Serializer): + """Serializer for approving/rejecting deletion requests.""" + + request_id = serializers.IntegerField( + required=True, + label="Request ID", + help_text="ID of the deletion request", + ) + action = serializers.ChoiceField( + choices=["approve", "reject"], + required=True, + label="Action", + help_text="Action to take on the deletion request", + ) + reason = serializers.CharField( + required=False, + allow_blank=True, + label="Reason", + help_text="Reason for approval/rejection (optional)", + ) diff --git a/src/documents/views.py b/src/documents/views.py index 822647fdb..7b00909ad 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -3150,3 +3150,340 @@ def serve_logo(request, filename=None): filename=app_logo.name, as_attachment=True, ) + + +class AISuggestionsView(GenericAPIView): + """ + API view to get AI suggestions for a document. + + Requires: can_view_ai_suggestions permission + """ + + permission_classes = [IsAuthenticated, CanViewAISuggestionsPermission] + serializer_class = AISuggestionsResponseSerializer + + def post(self, request): + """Get AI suggestions for a document.""" + from documents.ai_scanner import get_ai_scanner + from documents.models import Document, Tag, Correspondent, DocumentType, StoragePath + from documents.serialisers import AISuggestionsRequestSerializer + + # Validate request + request_serializer = AISuggestionsRequestSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + + document_id = request_serializer.validated_data['document_id'] + + try: + document = Document.objects.get(pk=document_id) + except Document.DoesNotExist: + return Response( + {"error": "Document not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check if user has permission to view this document + if not has_perms_owner_aware(request.user, 'documents.view_document', document): + return Response( + {"error": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get AI scanner and scan document + scanner = get_ai_scanner() + scan_result = scanner.scan_document(document, document.content or "") + + # Build response + response_data = { + "document_id": document.id, + "tags": [], + "correspondent": None, + "document_type": None, + "storage_path": None, + "title_suggestion": scan_result.title_suggestion, + "custom_fields": {} + } + + # Format tag suggestions + for tag_id, confidence in scan_result.tags: + try: + tag = Tag.objects.get(pk=tag_id) + response_data["tags"].append({ + "id": tag.id, + "name": tag.name, + "confidence": confidence + }) + except Tag.DoesNotExist: + pass + + # Format correspondent suggestion + if scan_result.correspondent: + corr_id, confidence = scan_result.correspondent + try: + correspondent = Correspondent.objects.get(pk=corr_id) + response_data["correspondent"] = { + "id": correspondent.id, + "name": correspondent.name, + "confidence": confidence + } + except Correspondent.DoesNotExist: + pass + + # Format document type suggestion + if scan_result.document_type: + type_id, confidence = scan_result.document_type + try: + doc_type = DocumentType.objects.get(pk=type_id) + response_data["document_type"] = { + "id": doc_type.id, + "name": doc_type.name, + "confidence": confidence + } + except DocumentType.DoesNotExist: + pass + + # Format storage path suggestion + if scan_result.storage_path: + path_id, confidence = scan_result.storage_path + try: + storage_path = StoragePath.objects.get(pk=path_id) + response_data["storage_path"] = { + "id": storage_path.id, + "name": storage_path.name, + "confidence": confidence + } + except StoragePath.DoesNotExist: + pass + + # Format custom fields + for field_id, (value, confidence) in scan_result.custom_fields.items(): + response_data["custom_fields"][str(field_id)] = { + "value": value, + "confidence": confidence + } + + return Response(response_data) + + +class ApplyAISuggestionsView(GenericAPIView): + """ + API view to apply AI suggestions to a document. + + Requires: can_apply_ai_suggestions permission + """ + + permission_classes = [IsAuthenticated, CanApplyAISuggestionsPermission] + + def post(self, request): + """Apply AI suggestions to a document.""" + from documents.ai_scanner import get_ai_scanner + from documents.models import Document, Tag, Correspondent, DocumentType, StoragePath + from documents.serialisers import ApplyAISuggestionsSerializer + + # Validate request + serializer = ApplyAISuggestionsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + document_id = serializer.validated_data['document_id'] + + try: + document = Document.objects.get(pk=document_id) + except Document.DoesNotExist: + return Response( + {"error": "Document not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check if user has permission to change this document + if not has_perms_owner_aware(request.user, 'documents.change_document', document): + return Response( + {"error": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get AI scanner and scan document + scanner = get_ai_scanner() + scan_result = scanner.scan_document(document, document.content or "") + + # Apply suggestions based on user selections + applied = [] + + if serializer.validated_data.get('apply_tags'): + selected_tags = serializer.validated_data.get('selected_tags', []) + if selected_tags: + # Apply only selected tags + tags_to_apply = [tag_id for tag_id, _ in scan_result.tags if tag_id in selected_tags] + else: + # Apply all high-confidence tags + tags_to_apply = [tag_id for tag_id, conf in scan_result.tags if conf >= scanner.auto_apply_threshold] + + for tag_id in tags_to_apply: + try: + tag = Tag.objects.get(pk=tag_id) + document.add_nested_tags([tag]) + applied.append(f"tag: {tag.name}") + except Tag.DoesNotExist: + pass + + if serializer.validated_data.get('apply_correspondent') and scan_result.correspondent: + corr_id, confidence = scan_result.correspondent + try: + correspondent = Correspondent.objects.get(pk=corr_id) + document.correspondent = correspondent + applied.append(f"correspondent: {correspondent.name}") + except Correspondent.DoesNotExist: + pass + + if serializer.validated_data.get('apply_document_type') and scan_result.document_type: + type_id, confidence = scan_result.document_type + try: + doc_type = DocumentType.objects.get(pk=type_id) + document.document_type = doc_type + applied.append(f"document_type: {doc_type.name}") + except DocumentType.DoesNotExist: + pass + + if serializer.validated_data.get('apply_storage_path') and scan_result.storage_path: + path_id, confidence = scan_result.storage_path + try: + storage_path = StoragePath.objects.get(pk=path_id) + document.storage_path = storage_path + applied.append(f"storage_path: {storage_path.name}") + except StoragePath.DoesNotExist: + pass + + if serializer.validated_data.get('apply_title') and scan_result.title_suggestion: + document.title = scan_result.title_suggestion + applied.append(f"title: {scan_result.title_suggestion}") + + # Save document + document.save() + + return Response({ + "status": "success", + "document_id": document.id, + "applied": applied + }) + + +class AIConfigurationView(GenericAPIView): + """ + API view to get/update AI configuration. + + Requires: can_configure_ai permission + """ + + permission_classes = [IsAuthenticated, CanConfigureAIPermission] + + def get(self, request): + """Get current AI configuration.""" + from documents.ai_scanner import get_ai_scanner + from documents.serialisers import AIConfigurationSerializer + + scanner = get_ai_scanner() + + config_data = { + "auto_apply_threshold": scanner.auto_apply_threshold, + "suggest_threshold": scanner.suggest_threshold, + "ml_enabled": scanner.ml_enabled, + "advanced_ocr_enabled": scanner.advanced_ocr_enabled, + } + + serializer = AIConfigurationSerializer(config_data) + return Response(serializer.data) + + def post(self, request): + """Update AI configuration.""" + from documents.ai_scanner import get_ai_scanner, AIDocumentScanner, _scanner_instance + from documents.serialisers import AIConfigurationSerializer + + serializer = AIConfigurationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Create new scanner with updated configuration + config = {} + if 'auto_apply_threshold' in serializer.validated_data: + config['auto_apply_threshold'] = serializer.validated_data['auto_apply_threshold'] + if 'suggest_threshold' in serializer.validated_data: + config['suggest_threshold'] = serializer.validated_data['suggest_threshold'] + if 'ml_enabled' in serializer.validated_data: + config['enable_ml_features'] = serializer.validated_data['ml_enabled'] + if 'advanced_ocr_enabled' in serializer.validated_data: + config['enable_advanced_ocr'] = serializer.validated_data['advanced_ocr_enabled'] + + # Update global scanner instance + global _scanner_instance + _scanner_instance = AIDocumentScanner(**config) + + return Response({ + "status": "success", + "message": "AI configuration updated" + }) + + +class DeletionApprovalView(GenericAPIView): + """ + API view to approve/reject deletion requests. + + Requires: can_approve_deletions permission + """ + + permission_classes = [IsAuthenticated, CanApproveDeletionsPermission] + + def post(self, request): + """Approve or reject a deletion request.""" + from documents.models import DeletionRequest + from documents.serialisers import DeletionApprovalSerializer + + serializer = DeletionApprovalSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + request_id = serializer.validated_data['request_id'] + action = serializer.validated_data['action'] + reason = serializer.validated_data.get('reason', '') + + try: + deletion_request = DeletionRequest.objects.get(pk=request_id) + except DeletionRequest.DoesNotExist: + return Response( + {"error": "Deletion request not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check if user has permission + if deletion_request.user != request.user and not request.user.is_superuser: + return Response( + {"error": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + if action == "approve": + deletion_request.status = DeletionRequest.STATUS_APPROVED + deletion_request.save() + + # Perform the actual deletion + # This would integrate with the AI deletion manager + return Response({ + "status": "success", + "message": "Deletion request approved", + "request_id": request_id + }) + + elif action == "reject": + deletion_request.status = DeletionRequest.STATUS_REJECTED + deletion_request.save() + + return Response({ + "status": "success", + "message": "Deletion request rejected", + "request_id": request_id + }) + + +# Import the new permission classes +from documents.permissions import ( + CanViewAISuggestionsPermission, + CanApplyAISuggestionsPermission, + CanApproveDeletionsPermission, + CanConfigureAIPermission, +) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index e24d1a459..90a5a5dd4 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -15,11 +15,15 @@ from drf_spectacular.views import SpectacularAPIView from drf_spectacular.views import SpectacularSwaggerView from rest_framework.routers import DefaultRouter +from documents.views import AIConfigurationView +from documents.views import AISuggestionsView +from documents.views import ApplyAISuggestionsView from documents.views import BulkDownloadView from documents.views import BulkEditObjectsView from documents.views import BulkEditView from documents.views import CorrespondentViewSet from documents.views import CustomFieldViewSet +from documents.views import DeletionApprovalView from documents.views import DocumentTypeViewSet from documents.views import GlobalSearchView from documents.views import IndexView @@ -200,6 +204,33 @@ urlpatterns = [ TrashView.as_view(), name="trash", ), + re_path( + "^ai/", + include( + [ + re_path( + "^suggestions/$", + AISuggestionsView.as_view(), + name="ai_suggestions", + ), + re_path( + "^suggestions/apply/$", + ApplyAISuggestionsView.as_view(), + name="ai_apply_suggestions", + ), + re_path( + "^config/$", + AIConfigurationView.as_view(), + name="ai_config", + ), + re_path( + "^deletions/approve/$", + DeletionApprovalView.as_view(), + name="ai_deletion_approval", + ), + ], + ), + ), re_path( r"^oauth/callback/", OauthCallbackView.as_view(),