Рефакторинг инфраструктуры и конфигурации проекта
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")