mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-21 14:06:55 +01:00
Add AI API endpoints with permission-protected views
Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com>
This commit is contained in:
parent
476b08a23b
commit
0eb883287c
3 changed files with 490 additions and 0 deletions
|
|
@ -2696,3 +2696,125 @@ class StoragePathTestSerializer(SerializerWithPerms):
|
||||||
label="Document",
|
label="Document",
|
||||||
write_only=True,
|
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)",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3150,3 +3150,340 @@ def serve_logo(request, filename=None):
|
||||||
filename=app_logo.name,
|
filename=app_logo.name,
|
||||||
as_attachment=True,
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,15 @@ from drf_spectacular.views import SpectacularAPIView
|
||||||
from drf_spectacular.views import SpectacularSwaggerView
|
from drf_spectacular.views import SpectacularSwaggerView
|
||||||
from rest_framework.routers import DefaultRouter
|
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 BulkDownloadView
|
||||||
from documents.views import BulkEditObjectsView
|
from documents.views import BulkEditObjectsView
|
||||||
from documents.views import BulkEditView
|
from documents.views import BulkEditView
|
||||||
from documents.views import CorrespondentViewSet
|
from documents.views import CorrespondentViewSet
|
||||||
from documents.views import CustomFieldViewSet
|
from documents.views import CustomFieldViewSet
|
||||||
|
from documents.views import DeletionApprovalView
|
||||||
from documents.views import DocumentTypeViewSet
|
from documents.views import DocumentTypeViewSet
|
||||||
from documents.views import GlobalSearchView
|
from documents.views import GlobalSearchView
|
||||||
from documents.views import IndexView
|
from documents.views import IndexView
|
||||||
|
|
@ -200,6 +204,33 @@ urlpatterns = [
|
||||||
TrashView.as_view(),
|
TrashView.as_view(),
|
||||||
name="trash",
|
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(
|
re_path(
|
||||||
r"^oauth/callback/",
|
r"^oauth/callback/",
|
||||||
OauthCallbackView.as_view(),
|
OauthCallbackView.as_view(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue