Рефакторинг инфраструктуры и конфигурации проекта
Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Successful in 1m52s
CI/CD Pipeline / Run Tests (push) Failing after 2m2s
CI/CD Pipeline / Build & Push Images (push) Has been skipped

- Перенесена структура 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:
2026-02-18 13:25:01 +01:00
parent 0f4af561de
commit d5d184537f
71 changed files with 1253 additions and 2318 deletions

View File

@@ -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:

View 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)

View File

@@ -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):

View File

@@ -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")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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"),

View File

@@ -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)