Рефакторинг инфраструктуры и конфигурации проекта
- Перенесена структура 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")
|
||||
|
||||
Reference in New Issue
Block a user