Рефакторинг инфраструктуры и конфигурации проекта
- Перенесена структура Django-конфига в src/core и src/settings - Унифицирована Docker-сборка и docker-compose для dev/prod - Добавлены startup-checks (DB/Redis) и обновлены env-шаблоны - Расширена OpenAPI-документация и ответы API - Удалены устаревшие deploy/requirements/служебные скрипты - Обновлены CI/CD, README и тесты
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
|
||||
@@ -85,11 +86,20 @@ def api_docs(
|
||||
)
|
||||
|
||||
|
||||
def swagger_tag(ru: str, en: str | None = None) -> str:
|
||||
"""Возвращает тег для Swagger в зависимости от текущих настроек."""
|
||||
use_english = getattr(settings, "OPENAPI_USE_ENGLISH_TAGS", False)
|
||||
if use_english and en:
|
||||
return en
|
||||
return ru
|
||||
|
||||
|
||||
def _get_status_description(status_code: int) -> str:
|
||||
"""Возвращает описание HTTP статуса на русском."""
|
||||
descriptions = {
|
||||
200: "Успешный запрос",
|
||||
201: "Ресурс создан",
|
||||
202: "Запрос принят в обработку",
|
||||
204: "Успешно, без содержимого",
|
||||
400: "Некорректный запрос",
|
||||
401: "Не авторизован",
|
||||
@@ -99,6 +109,7 @@ def _get_status_description(status_code: int) -> str:
|
||||
422: "Ошибка валидации",
|
||||
429: "Слишком много запросов",
|
||||
500: "Внутренняя ошибка сервера",
|
||||
503: "Сервис временно недоступен",
|
||||
}
|
||||
return descriptions.get(status_code, f"HTTP {status_code}")
|
||||
|
||||
@@ -273,6 +284,72 @@ class CommonResponses:
|
||||
),
|
||||
)
|
||||
|
||||
SERVICE_UNAVAILABLE = openapi.Response(
|
||||
description="Сервис временно недоступен",
|
||||
schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=False),
|
||||
"errors": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"code": openapi.Schema(
|
||||
type=openapi.TYPE_STRING,
|
||||
default="service_unavailable",
|
||||
),
|
||||
"message": openapi.Schema(
|
||||
type=openapi.TYPE_STRING,
|
||||
default="Сервис временно недоступен",
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponses:
|
||||
"""Переиспользуемые наборы ошибок для OpenAPI responses."""
|
||||
|
||||
PUBLIC = {
|
||||
429: CommonResponses.RATE_LIMITED,
|
||||
500: CommonResponses.SERVER_ERROR,
|
||||
}
|
||||
|
||||
AUTHENTICATED = {
|
||||
401: CommonResponses.UNAUTHORIZED,
|
||||
**PUBLIC,
|
||||
}
|
||||
|
||||
AUTHENTICATED_VALIDATION = {
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
**AUTHENTICATED,
|
||||
}
|
||||
|
||||
AUTHENTICATED_NOT_FOUND = {
|
||||
404: CommonResponses.NOT_FOUND,
|
||||
**AUTHENTICATED,
|
||||
}
|
||||
|
||||
AUTHENTICATED_VALIDATION_NOT_FOUND = {
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
**AUTHENTICATED_NOT_FOUND,
|
||||
}
|
||||
|
||||
ADMIN = {
|
||||
401: CommonResponses.UNAUTHORIZED,
|
||||
403: CommonResponses.FORBIDDEN,
|
||||
**PUBLIC,
|
||||
}
|
||||
|
||||
ADMIN_NOT_FOUND = {
|
||||
404: CommonResponses.NOT_FOUND,
|
||||
**ADMIN,
|
||||
}
|
||||
|
||||
|
||||
# Параметры запроса
|
||||
class CommonParameters:
|
||||
|
||||
91
src/apps/core/startup_checks.py
Normal file
91
src/apps/core/startup_checks.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Startup dependency checks for DB and Redis.
|
||||
|
||||
Fail-fast checks used by long-running entrypoints (web/celery) to avoid
|
||||
silent hangs on connection issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import psycopg2
|
||||
import redis
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
"""Log to stderr to be visible early in startup."""
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def _check_db(timeout_seconds: int) -> tuple[bool, str]:
|
||||
db = settings.DATABASES["default"]
|
||||
params = {
|
||||
"dbname": db.get("NAME"),
|
||||
"user": db.get("USER"),
|
||||
"password": db.get("PASSWORD"),
|
||||
"host": db.get("HOST"),
|
||||
"port": db.get("PORT"),
|
||||
"connect_timeout": timeout_seconds,
|
||||
}
|
||||
|
||||
options = db.get("OPTIONS", {})
|
||||
if options.get("sslmode"):
|
||||
params["sslmode"] = options["sslmode"]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = psycopg2.connect(**params)
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
return True, "OK"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
target = f"{params['host']}:{params['port']}/{params['dbname']}"
|
||||
return False, f"{target} ({exc})"
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _check_redis(timeout_seconds: int) -> tuple[bool, str]:
|
||||
redis_url = settings.CACHES["default"]["LOCATION"]
|
||||
try:
|
||||
client = redis.Redis.from_url(
|
||||
redis_url,
|
||||
socket_connect_timeout=timeout_seconds,
|
||||
socket_timeout=timeout_seconds,
|
||||
)
|
||||
client.ping()
|
||||
return True, "OK"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
parsed = urlparse(redis_url)
|
||||
target = f"{parsed.hostname}:{parsed.port}{parsed.path or ''}"
|
||||
return False, f"{target} ({exc})"
|
||||
|
||||
|
||||
def run_startup_checks(*, component: str = "app") -> None:
|
||||
"""Run startup checks and exit process on failure."""
|
||||
if not getattr(settings, "STARTUP_CHECKS_ENABLED", True):
|
||||
return
|
||||
|
||||
db_timeout = int(getattr(settings, "STARTUP_DB_TIMEOUT_SECONDS", 3))
|
||||
redis_timeout = int(getattr(settings, "STARTUP_REDIS_TIMEOUT_SECONDS", 3))
|
||||
|
||||
db_ok, db_message = _check_db(db_timeout)
|
||||
if not db_ok:
|
||||
_log(
|
||||
f"[startup:{component}] DB check failed "
|
||||
f"(timeout={db_timeout}s): {db_message}"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
redis_ok, redis_message = _check_redis(redis_timeout)
|
||||
if not redis_ok:
|
||||
_log(
|
||||
f"[startup:{component}] Redis check failed "
|
||||
f"(timeout={redis_timeout}s): {redis_message}"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
@@ -26,7 +26,7 @@ class BaseTask(Task):
|
||||
- Логирование исключений
|
||||
|
||||
Пример использования:
|
||||
from config.celery import app
|
||||
from core.celery import app
|
||||
|
||||
@app.task(base=BaseTask, bind=True)
|
||||
def my_task(self, arg1, arg2):
|
||||
|
||||
@@ -11,6 +11,8 @@ import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag
|
||||
from apps.core.serializers import BackgroundJobListSerializer, BackgroundJobSerializer
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
@@ -23,8 +25,8 @@ from rest_framework.views import APIView
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Swagger теги
|
||||
HEALTH_TAG = "Мониторинг"
|
||||
JOBS_TAG = "Фоновые задачи"
|
||||
HEALTH_TAG = swagger_tag("Мониторинг", "monitoring")
|
||||
JOBS_TAG = swagger_tag("Фоновые задачи", "background_jobs")
|
||||
|
||||
|
||||
class HealthCheckView(APIView):
|
||||
@@ -44,6 +46,11 @@ class HealthCheckView(APIView):
|
||||
"Комплексная проверка всех зависимостей системы.\n"
|
||||
"Возвращает статус: healthy, degraded или unhealthy."
|
||||
),
|
||||
responses={
|
||||
200: "Сервис работает в режиме healthy/degraded",
|
||||
503: CommonResponses.SERVICE_UNAVAILABLE,
|
||||
**ErrorResponses.PUBLIC,
|
||||
},
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Run all health checks and return status."""
|
||||
@@ -117,7 +124,7 @@ class HealthCheckView(APIView):
|
||||
def _check_celery(self) -> dict[str, Any]:
|
||||
"""Check Celery worker availability."""
|
||||
try:
|
||||
from config.celery import app as celery_app
|
||||
from core.celery import app as celery_app
|
||||
|
||||
inspector = celery_app.control.inspect(timeout=2.0)
|
||||
active = inspector.active()
|
||||
@@ -144,6 +151,10 @@ class LivenessView(APIView):
|
||||
tags=[HEALTH_TAG],
|
||||
operation_summary="Liveness probe",
|
||||
operation_description="Возвращает 200 если приложение запущено.",
|
||||
responses={
|
||||
200: "Приложение запущено",
|
||||
**ErrorResponses.PUBLIC,
|
||||
},
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Simple liveness check."""
|
||||
@@ -166,6 +177,11 @@ class ReadinessView(APIView):
|
||||
operation_description=(
|
||||
"Возвращает 200 если приложение готово обрабатывать запросы."
|
||||
),
|
||||
responses={
|
||||
200: "Приложение готово обрабатывать запросы",
|
||||
503: CommonResponses.SERVICE_UNAVAILABLE,
|
||||
**ErrorResponses.PUBLIC,
|
||||
},
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Check if app is ready to serve traffic."""
|
||||
@@ -202,10 +218,15 @@ class BackgroundJobStatusView(APIView):
|
||||
"Возвращает статус конкретной фоновой задачи.\n"
|
||||
"Доступно только владельцу задачи или администратору."
|
||||
),
|
||||
responses={
|
||||
200: BackgroundJobSerializer,
|
||||
403: CommonResponses.FORBIDDEN,
|
||||
404: CommonResponses.NOT_FOUND,
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def get(self, request: Request, task_id: str) -> Response:
|
||||
"""Получить статус задачи по task_id."""
|
||||
from apps.core.serializers import BackgroundJobSerializer
|
||||
from apps.core.services import BackgroundJobService
|
||||
|
||||
job = BackgroundJobService.get_by_task_id(task_id)
|
||||
@@ -239,10 +260,13 @@ class BackgroundJobListView(APIView):
|
||||
"Возвращает список фоновых задач текущего пользователя.\n"
|
||||
"Поддерживает фильтрацию по статусу (status) и лимит (limit)."
|
||||
),
|
||||
responses={
|
||||
200: BackgroundJobListSerializer(many=True),
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Получить список задач пользователя."""
|
||||
from apps.core.serializers import BackgroundJobListSerializer
|
||||
from apps.core.services import BackgroundJobService
|
||||
|
||||
status_filter = request.query_params.get("status")
|
||||
|
||||
@@ -26,7 +26,7 @@ from django.conf import settings
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
# Setup Django settings
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev")
|
||||
|
||||
django.setup()
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import hashlib
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag
|
||||
from apps.parsers.models import (
|
||||
FinancialReport,
|
||||
IndustrialCertificateRecord,
|
||||
@@ -44,11 +45,11 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
# Swagger Tags (для группировки в документации)
|
||||
# =============================================================================
|
||||
|
||||
MINPROMTORG_TAG = "Минпромторг"
|
||||
PROVERKI_TAG = "Единый реестр проверок"
|
||||
ZAKUPKI_TAG = "Государственные закупки"
|
||||
FNS_TAG = "ФНС - Бухгалтерская отчетность"
|
||||
SYSTEM_TAG = "Системные"
|
||||
MINPROMTORG_TAG = swagger_tag("Минпромторг", "minpromtorg")
|
||||
PROVERKI_TAG = swagger_tag("Единый реестр проверок", "inspections_registry")
|
||||
ZAKUPKI_TAG = swagger_tag("Государственные закупки", "public_procurements")
|
||||
FNS_TAG = swagger_tag("ФНС - Бухгалтерская отчетность", "fns_financial_reports")
|
||||
SYSTEM_TAG = swagger_tag("Системные", "system")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -78,6 +79,10 @@ class IndustrialCertificateViewSet(ReadOnlyModelViewSet):
|
||||
"Поддерживает фильтрацию по: inn, ogrn, certificate_number, load_batch.\n"
|
||||
"Поддерживает поиск по: organisation_name, certificate_number, inn, ogrn."
|
||||
),
|
||||
responses={
|
||||
200: IndustrialCertificateSerializer(many=True),
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
@@ -86,6 +91,10 @@ class IndustrialCertificateViewSet(ReadOnlyModelViewSet):
|
||||
tags=[MINPROMTORG_TAG],
|
||||
operation_summary="Детали сертификата",
|
||||
operation_description="Возвращает информацию о конкретном сертификате.",
|
||||
responses={
|
||||
200: IndustrialCertificateSerializer,
|
||||
**ErrorResponses.AUTHENTICATED_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
@@ -118,6 +127,10 @@ class ManufacturerViewSet(ReadOnlyModelViewSet):
|
||||
"Поддерживает фильтрацию по: inn, ogrn, load_batch.\n"
|
||||
"Поддерживает поиск по: full_legal_name, inn, ogrn, address."
|
||||
),
|
||||
responses={
|
||||
200: ManufacturerSerializer(many=True),
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
@@ -126,6 +139,10 @@ class ManufacturerViewSet(ReadOnlyModelViewSet):
|
||||
tags=[MINPROMTORG_TAG],
|
||||
operation_summary="Детали производителя",
|
||||
operation_description="Возвращает информацию о конкретном производителе.",
|
||||
responses={
|
||||
200: ManufacturerSerializer,
|
||||
**ErrorResponses.AUTHENTICATED_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
@@ -175,6 +192,10 @@ class InspectionViewSet(ReadOnlyModelViewSet):
|
||||
"Поддерживает поиск по: organisation_name, registration_number, "
|
||||
"inn, ogrn, control_authority."
|
||||
),
|
||||
responses={
|
||||
200: InspectionSerializer(many=True),
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
@@ -183,6 +204,10 @@ class InspectionViewSet(ReadOnlyModelViewSet):
|
||||
tags=[PROVERKI_TAG],
|
||||
operation_summary="Детали проверки",
|
||||
operation_description="Возвращает информацию о конкретной проверке.",
|
||||
responses={
|
||||
200: InspectionSerializer,
|
||||
**ErrorResponses.AUTHENTICATED_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
@@ -235,6 +260,10 @@ class ProcurementViewSet(ReadOnlyModelViewSet):
|
||||
"Поддерживает поиск по: purchase_name, purchase_number, "
|
||||
"customer_name, customer_inn, customer_ogrn."
|
||||
),
|
||||
responses={
|
||||
200: ProcurementSerializer(many=True),
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
@@ -243,6 +272,10 @@ class ProcurementViewSet(ReadOnlyModelViewSet):
|
||||
tags=[ZAKUPKI_TAG],
|
||||
operation_summary="Детали закупки",
|
||||
operation_description="Возвращает информацию о конкретной закупке.",
|
||||
responses={
|
||||
200: ProcurementSerializer,
|
||||
**ErrorResponses.AUTHENTICATED_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
@@ -280,6 +313,10 @@ class FinancialReportViewSet(ReadOnlyModelViewSet):
|
||||
"source, load_batch.\n"
|
||||
"Поддерживает поиск по: ogrn, external_id, file_name."
|
||||
),
|
||||
responses={
|
||||
200: FinancialReportSerializer(many=True),
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
@@ -291,6 +328,10 @@ class FinancialReportViewSet(ReadOnlyModelViewSet):
|
||||
"Возвращает детальную информацию об отчете, "
|
||||
"включая все строки бухгалтерской отчетности."
|
||||
),
|
||||
responses={
|
||||
200: FinancialReportDetailSerializer,
|
||||
**ErrorResponses.AUTHENTICATED_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
@@ -350,7 +391,8 @@ class FNSReportUploadView(APIView):
|
||||
},
|
||||
),
|
||||
),
|
||||
400: "Ошибка валидации файлов",
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def post(self, request): # noqa
|
||||
@@ -462,6 +504,10 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet):
|
||||
"Доступно только администраторам.\n"
|
||||
"Поддерживает фильтрацию по: source, status, batch_id."
|
||||
),
|
||||
responses={
|
||||
200: ParserLoadLogSerializer(many=True),
|
||||
**ErrorResponses.ADMIN,
|
||||
},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
@@ -470,6 +516,10 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet):
|
||||
tags=[SYSTEM_TAG],
|
||||
operation_summary="Детали загрузки",
|
||||
operation_description="Возвращает информацию о конкретной загрузке.",
|
||||
responses={
|
||||
200: ParserLoadLogSerializer,
|
||||
**ErrorResponses.ADMIN_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
@@ -496,6 +546,10 @@ class ProxyViewSet(ReadOnlyModelViewSet):
|
||||
"Доступно только администраторам.\n"
|
||||
"Поддерживает фильтрацию по: is_active."
|
||||
),
|
||||
responses={
|
||||
200: ProxySerializer(many=True),
|
||||
**ErrorResponses.ADMIN,
|
||||
},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
@@ -504,6 +558,10 @@ class ProxyViewSet(ReadOnlyModelViewSet):
|
||||
tags=[SYSTEM_TAG],
|
||||
operation_summary="Детали прокси",
|
||||
operation_description="Возвращает информацию о конкретном прокси.",
|
||||
responses={
|
||||
200: ProxySerializer,
|
||||
**ErrorResponses.ADMIN_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenVerifyView
|
||||
|
||||
from . import views
|
||||
|
||||
@@ -11,7 +10,7 @@ urlpatterns = [
|
||||
path("login/", views.LoginView.as_view(), name="login"),
|
||||
path("logout/", views.LogoutView.as_view(), name="logout"),
|
||||
path("token/refresh/", views.TokenRefreshView.as_view(), name="token_refresh"),
|
||||
path("token/verify/", TokenVerifyView.as_view(), name="token_verify"),
|
||||
path("token/verify/", views.TokenVerifySwaggerView.as_view(), name="token_verify"),
|
||||
# Пользовательские данные
|
||||
path("me/", views.CurrentUserView.as_view(), name="current_user"),
|
||||
path("me/update/", views.UserUpdateView.as_view(), name="user_update"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from drf_yasg import openapi
|
||||
@@ -8,6 +9,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from rest_framework_simplejwt.views import TokenVerifyView as SimpleJWTTokenVerifyView
|
||||
|
||||
from .serializers import (
|
||||
LoginSerializer,
|
||||
@@ -21,8 +23,8 @@ from .serializers import (
|
||||
from .services import ProfileService, UserService
|
||||
|
||||
# Swagger теги для группировки
|
||||
AUTH_TAG = "Аутентификация"
|
||||
USER_TAG = "Пользователь"
|
||||
AUTH_TAG = swagger_tag("Аутентификация", "authentication")
|
||||
USER_TAG = swagger_tag("Пользователь", "user")
|
||||
|
||||
|
||||
class RegisterView(APIView):
|
||||
@@ -39,7 +41,11 @@ class RegisterView(APIView):
|
||||
operation_summary="Регистрация",
|
||||
operation_description="Создание новой учётной записи пользователя.",
|
||||
request_body=UserRegistrationSerializer,
|
||||
responses={201: UserSerializer},
|
||||
responses={
|
||||
201: UserSerializer,
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
**ErrorResponses.PUBLIC,
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = UserRegistrationSerializer(data=request.data)
|
||||
@@ -74,7 +80,12 @@ class LoginView(APIView):
|
||||
operation_summary="Вход",
|
||||
operation_description="Аутентификация по email и паролю. Возвращает JWT токены.",
|
||||
request_body=LoginSerializer,
|
||||
responses={200: TokenSerializer},
|
||||
responses={
|
||||
200: TokenSerializer,
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
401: CommonResponses.UNAUTHORIZED,
|
||||
**ErrorResponses.PUBLIC,
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = LoginSerializer(data=request.data)
|
||||
@@ -108,7 +119,10 @@ class LogoutView(APIView):
|
||||
tags=[AUTH_TAG],
|
||||
operation_summary="Выход",
|
||||
operation_description="Выход из системы (удаление токенов на клиенте).",
|
||||
responses={200: "Успешный выход"},
|
||||
responses={
|
||||
200: "Успешный выход",
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
# Для JWT логаут означает удаление токенов на клиенте.
|
||||
@@ -125,7 +139,10 @@ class CurrentUserView(APIView):
|
||||
tags=[USER_TAG],
|
||||
operation_summary="Текущий пользователь",
|
||||
operation_description="Возвращает данные авторизованного пользователя.",
|
||||
responses={200: UserSerializer},
|
||||
responses={
|
||||
200: UserSerializer,
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def get(self, request):
|
||||
serializer = UserSerializer(request.user)
|
||||
@@ -142,7 +159,10 @@ class UserUpdateView(APIView):
|
||||
operation_summary="Обновить данные",
|
||||
operation_description="Частичное обновление данных пользователя.",
|
||||
request_body=UserUpdateSerializer,
|
||||
responses={200: UserSerializer},
|
||||
responses={
|
||||
200: UserSerializer,
|
||||
**ErrorResponses.AUTHENTICATED_VALIDATION,
|
||||
},
|
||||
)
|
||||
def patch(self, request):
|
||||
serializer = UserUpdateSerializer(request.user, data=request.data, partial=True)
|
||||
@@ -158,6 +178,7 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = ProfileUpdateSerializer
|
||||
http_method_names = ["get", "patch", "head", "options"]
|
||||
|
||||
def get_object(self):
|
||||
profile = ProfileService.get_profile_by_user_id_or_none(self.request.user.id)
|
||||
@@ -172,6 +193,10 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView):
|
||||
tags=[USER_TAG],
|
||||
operation_summary="Получить профиль",
|
||||
operation_description="Возвращает профиль текущего пользователя.",
|
||||
responses={
|
||||
200: ProfileUpdateSerializer,
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
profile = self.get_object()
|
||||
@@ -183,6 +208,10 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView):
|
||||
operation_summary="Обновить профиль",
|
||||
operation_description="Частичное обновление профиля пользователя.",
|
||||
request_body=ProfileUpdateSerializer,
|
||||
responses={
|
||||
200: ProfileUpdateSerializer,
|
||||
**ErrorResponses.AUTHENTICATED_VALIDATION,
|
||||
},
|
||||
)
|
||||
def patch(self, request, *args, **kwargs):
|
||||
profile = self.get_object()
|
||||
@@ -205,7 +234,10 @@ class PasswordChangeView(APIView):
|
||||
operation_summary="Сменить пароль",
|
||||
operation_description="Смена пароля. Требуется текущий пароль для подтверждения.",
|
||||
request_body=PasswordChangeSerializer,
|
||||
responses={200: "Пароль успешно изменен"},
|
||||
responses={
|
||||
200: "Пароль успешно изменен",
|
||||
**ErrorResponses.AUTHENTICATED_VALIDATION,
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = PasswordChangeSerializer(data=request.data)
|
||||
@@ -234,6 +266,10 @@ class PasswordChangeView(APIView):
|
||||
tags=[USER_TAG],
|
||||
operation_summary="Полный профиль",
|
||||
operation_description="Расширенная информация о пользователе и профиле.",
|
||||
responses={
|
||||
200: ProfileUpdateSerializer,
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@@ -261,7 +297,12 @@ class TokenRefreshView(APIView):
|
||||
},
|
||||
required=["refresh"],
|
||||
),
|
||||
responses={200: TokenSerializer},
|
||||
responses={
|
||||
200: TokenSerializer,
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
401: CommonResponses.UNAUTHORIZED,
|
||||
**ErrorResponses.PUBLIC,
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
refresh_token = request.data.get("refresh")
|
||||
@@ -280,3 +321,21 @@ class TokenRefreshView(APIView):
|
||||
return Response(
|
||||
{"error": "Неверный refresh token"}, status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
|
||||
class TokenVerifySwaggerView(SimpleJWTTokenVerifyView):
|
||||
"""Проверка валидности access токена."""
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[AUTH_TAG],
|
||||
operation_summary="Проверить токен",
|
||||
operation_description="Проверяет валидность JWT токена.",
|
||||
responses={
|
||||
200: "Токен валиден",
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
401: CommonResponses.UNAUTHORIZED,
|
||||
**ErrorResponses.PUBLIC,
|
||||
},
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
Reference in New Issue
Block a user