diff --git a/api_app/permissions.py b/api_app/permissions.py new file mode 100644 index 0000000..5a11353 --- /dev/null +++ b/api_app/permissions.py @@ -0,0 +1,16 @@ +import hmac + +from django.conf import settings +from rest_framework.permissions import BasePermission + + +class ApiTokenPermission(BasePermission): + """Если задан API_TOKEN, запросы должны нести заголовок Authorization: Bearer <токен>""" + + message = "Нужен заголовок Authorization: Bearer " + + def has_permission(self, request, view): + if not settings.API_TOKEN: + return True + header = request.headers.get("Authorization", "") + return hmac.compare_digest(header, f"Bearer {settings.API_TOKEN}") diff --git a/api_app/views.py b/api_app/views.py index eb820ad..2874b1e 100644 --- a/api_app/views.py +++ b/api_app/views.py @@ -1,77 +1,90 @@ -# api_project/api_app/views.py +import logging import os -from rest_framework import status -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.parsers import MultiPartParser, FormParser + from django.conf import settings +from rest_framework import status +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView -from .models import AudioFile, DocumentFile -from .services.vosk_recognizer import recognize_speech -from .services.scan import extract_text_tables from .grpc_client.client import send_to_grpc_server +from .models import AudioFile, DocumentFile +from .services.scan import extract_text_tables +from .services.vosk_recognizer import RecognitionError, recognize_speech -class AudioToTextView(APIView): +logger = logging.getLogger(__name__) + + +def too_large(uploaded): + return uploaded.size > settings.MAX_UPLOAD_SIZE + + +def size_error(): + return Response({'error': f'Файл больше {settings.MAX_UPLOAD_SIZE // 1024 // 1024} МБ'}, + status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) + + +class ProcessView(APIView): parser_classes = (MultiPartParser, FormParser) - + field = "" + model = None + + def process(self, path): + raise NotImplementedError + + def handle(self, uploaded): + record = self.model(file=uploaded) + record.save() + + try: + text = self.process(os.path.join(settings.MEDIA_ROOT, record.file.name)) + except RecognitionError as e: + return Response({'error': str(e)}, status=status.HTTP_422_UNPROCESSABLE_ENTITY) + except Exception: + # подробности — в лог, а не в ответ клиенту + logger.exception("Ошибка обработки %s", record.file.name) + return Response({'error': 'Не удалось обработать файл'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + record.processed_text = text + record.save() + + return Response({'text': text, 'grpc_response': send_to_grpc_server(text)}, status=status.HTTP_200_OK) + + +class AudioToTextView(ProcessView): + model = AudioFile + + def process(self, path): + return recognize_speech(path) + def post(self, request, *args, **kwargs): audio_file = request.FILES.get('audio') - + if not audio_file: return Response({'error': 'Нет аудио файла'}, status=status.HTTP_400_BAD_REQUEST) - - audio_model = AudioFile(file=audio_file) - audio_model.save() - - try: - file_path = os.path.join(settings.MEDIA_ROOT, audio_model.file.name) - - text = recognize_speech(file_path) - - audio_model.processed_text = text - audio_model.save() - - grpc_response = send_to_grpc_server(text) - - return Response({ - 'text': text, - 'grpc_response': grpc_response - }, status=status.HTTP_200_OK) - - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + if too_large(audio_file): + return size_error() + + return self.handle(audio_file) + + +class DocumentToTextView(ProcessView): + model = DocumentFile + + def process(self, path): + return extract_text_tables(path) -class DocumentToTextView(APIView): - parser_classes = (MultiPartParser, FormParser) - def post(self, request, *args, **kwargs): document_file = request.FILES.get('document') - + if not document_file: return Response({'error': 'Нет документа'}, status=status.HTTP_400_BAD_REQUEST) - + file_ext = os.path.splitext(document_file.name)[1].lower() if file_ext not in ['.pdf', '.docx']: - return Response({'error': 'Поддерживаются только PDF и DOCX файлы'}, + return Response({'error': 'Поддерживаются только PDF и DOCX файлы'}, status=status.HTTP_400_BAD_REQUEST) - - doc_model = DocumentFile(file=document_file) - doc_model.save() - - try: - file_path = os.path.join(settings.MEDIA_ROOT, doc_model.file.name) - - text = extract_text_tables(file_path) - - doc_model.processed_text = text - doc_model.save() - - grpc_response = send_to_grpc_server(text) - - return Response({ - 'text': text, - 'grpc_response': grpc_response - }, status=status.HTTP_200_OK) - - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file + if too_large(document_file): + return size_error() + + return self.handle(document_file) diff --git a/api_project/settings.py b/api_project/settings.py index 58e6a56..9272257 100644 --- a/api_project/settings.py +++ b/api_project/settings.py @@ -3,11 +3,20 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -SECRET_KEY = 'django-insecure-)+yykzv8cr7dbc38g2#x(8*ifs@+-f_fyan9!c%mmxg1$ekztq' -DEBUG = True +def env_bool(name, default=False): + return os.environ.get(name, str(default)).lower() in ("1", "true", "yes") -ALLOWED_HOSTS = [] + +DEBUG = env_bool("DJANGO_DEBUG") + +# Ключ по умолчанию — только для разработки (DJANGO_DEBUG=True) +DEV_SECRET_KEY = "django-insecure-dev-only-change-me" +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", DEV_SECRET_KEY if DEBUG else "") +if not SECRET_KEY: + raise RuntimeError("Задайте DJANGO_SECRET_KEY (или DJANGO_DEBUG=True для разработки)") + +ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h] INSTALLED_APPS = [ 'django.contrib.admin', @@ -15,7 +24,7 @@ INSTALLED_APPS = [ 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', - # 'django.contrib.staticfiles', + 'django.contrib.staticfiles', 'rest_framework', # Добавляем DRF 'api_app', # Наше API приложение ] @@ -53,17 +62,38 @@ WSGI_APPLICATION = 'api_project.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'NAME': os.environ.get("DJANGO_DB_PATH", BASE_DIR / 'db.sqlite3'), } } # Путь для загрузки файлов MEDIA_URL = '/media/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", os.path.join(BASE_DIR, 'media')) +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# Распознавание и обработка +VOSK_MODEL_PATH = os.environ.get("VOSK_MODEL_PATH", BASE_DIR / "models" / "vosk-model-small-ru-0.22") +FFMPEG_BINARY = os.environ.get("FFMPEG_BINARY", "ffmpeg") # из PATH; раньше — Windows-путь к ffmpeg.exe +GRPC_SERVER = os.environ.get("GRPC_SERVER", "localhost:50051") # пусто — не отправлять на gRPC +GRPC_TIMEOUT = float(os.environ.get("GRPC_TIMEOUT", "10")) +API_TOKEN = os.environ.get("API_TOKEN", "") # если задан — нужен заголовок Authorization: Bearer +MAX_UPLOAD_SIZE = int(os.environ.get("MAX_UPLOAD_SIZE_MB", "50")) * 1024 * 1024 +FILE_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # крупные файлы — во временный файл, не в память # Настройки для REST Framework REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.AllowAny', # Для тестирования, в продакшне лучше ограничить + 'api_app.permissions.ApiTokenPermission', ] } + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": {"console": {"class": "logging.StreamHandler"}}, + "root": {"handlers": ["console"], "level": "INFO"}, +} + +# как в существующей миграции — без новой миграции +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'