feat(core): add core module with mixins, services, and background jobs
- Add Model Mixins: TimestampMixin, SoftDeleteMixin, AuditMixin, etc. - Add Base Services: BaseService, BulkOperationsMixin, QueryOptimizerMixin - Add Base ViewSets with bulk operations - Add BackgroundJob model for Celery task tracking - Add BaseAppCommand for management commands - Add permissions, pagination, filters, cache, logging - Migrate tests to factory_boy + faker - Add CHANGELOG.md - 297 tests passing
This commit is contained in:
249
src/apps/core/views.py
Normal file
249
src/apps/core/views.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Health check views for monitoring and orchestration.
|
||||
|
||||
Provides endpoints for:
|
||||
- Basic liveness check (is the app running?)
|
||||
- Readiness check (is the app ready to serve traffic?)
|
||||
- Detailed health check (DB, Redis, Celery status)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HealthCheckView(APIView):
|
||||
"""
|
||||
Comprehensive health check endpoint.
|
||||
|
||||
GET /api/health/
|
||||
Returns detailed status of all dependencies.
|
||||
|
||||
Response:
|
||||
{
|
||||
"status": "healthy" | "degraded" | "unhealthy",
|
||||
"version": "1.0.0",
|
||||
"checks": {
|
||||
"database": {"status": "up", "latency_ms": 5},
|
||||
"redis": {"status": "up", "latency_ms": 2},
|
||||
"celery": {"status": "up"}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = [] # No auth required
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Run all health checks and return status."""
|
||||
checks = {}
|
||||
overall_status = "healthy"
|
||||
|
||||
# Database check
|
||||
db_check = self._check_database()
|
||||
checks["database"] = db_check
|
||||
if db_check["status"] != "up":
|
||||
overall_status = "unhealthy"
|
||||
|
||||
# Redis check
|
||||
redis_check = self._check_redis()
|
||||
checks["redis"] = redis_check
|
||||
if redis_check["status"] != "up" and overall_status == "healthy":
|
||||
overall_status = "degraded"
|
||||
|
||||
# Celery check (optional, may be slow)
|
||||
if request.query_params.get("include_celery", "").lower() == "true":
|
||||
celery_check = self._check_celery()
|
||||
checks["celery"] = celery_check
|
||||
if celery_check["status"] != "up" and overall_status == "healthy":
|
||||
overall_status = "degraded"
|
||||
|
||||
response_data = {
|
||||
"status": overall_status,
|
||||
"version": getattr(settings, "APP_VERSION", "1.0.0"),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
# 503 only for unhealthy (critical services down)
|
||||
# 200 for healthy and degraded (non-critical services down)
|
||||
status_code = (
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
if overall_status == "unhealthy"
|
||||
else status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
def _check_database(self) -> dict[str, Any]:
|
||||
"""Check database connectivity."""
|
||||
start = time.time()
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
latency = (time.time() - start) * 1000
|
||||
return {"status": "up", "latency_ms": round(latency, 2)}
|
||||
except Exception as e:
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
return {"status": "down", "error": str(e)}
|
||||
|
||||
def _check_redis(self) -> dict[str, Any]:
|
||||
"""Check Redis connectivity."""
|
||||
start = time.time()
|
||||
try:
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
redis_conn = get_redis_connection("default")
|
||||
redis_conn.ping()
|
||||
latency = (time.time() - start) * 1000
|
||||
return {"status": "up", "latency_ms": round(latency, 2)}
|
||||
except ImportError:
|
||||
return {"status": "skipped", "reason": "django_redis not installed"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis health check failed: {e}")
|
||||
return {"status": "down", "error": str(e)}
|
||||
|
||||
def _check_celery(self) -> dict[str, Any]:
|
||||
"""Check Celery worker availability."""
|
||||
try:
|
||||
from config.celery import app as celery_app
|
||||
|
||||
inspector = celery_app.control.inspect(timeout=2.0)
|
||||
active = inspector.active()
|
||||
if active:
|
||||
worker_count = len(active)
|
||||
return {"status": "up", "workers": worker_count}
|
||||
return {"status": "down", "error": "No active workers"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Celery health check failed: {e}")
|
||||
return {"status": "down", "error": str(e)}
|
||||
|
||||
|
||||
class LivenessView(APIView):
|
||||
"""
|
||||
Kubernetes liveness probe endpoint.
|
||||
|
||||
GET /api/health/live/
|
||||
Returns 200 if the application is running.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Simple liveness check."""
|
||||
return Response({"status": "alive"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ReadinessView(APIView):
|
||||
"""
|
||||
Kubernetes readiness probe endpoint.
|
||||
|
||||
GET /api/health/ready/
|
||||
Returns 200 if the application is ready to serve traffic.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Check if app is ready to serve traffic."""
|
||||
# Check database connection
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
except Exception as e:
|
||||
logger.error(f"Readiness check failed - database: {e}")
|
||||
return Response(
|
||||
{"status": "not_ready", "reason": "database unavailable"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
return Response({"status": "ready"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class BackgroundJobStatusView(APIView):
|
||||
"""
|
||||
Получение статуса фоновой задачи.
|
||||
|
||||
GET /api/v1/jobs/{task_id}/
|
||||
Возвращает статус, прогресс и результат задачи.
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "uuid",
|
||||
"task_id": "celery-task-id",
|
||||
"status": "pending|started|success|failure|revoked",
|
||||
"progress": 75,
|
||||
"progress_message": "Обработка данных...",
|
||||
"result": {...},
|
||||
"error": "",
|
||||
"is_finished": false
|
||||
}
|
||||
"""
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
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)
|
||||
|
||||
# Проверка доступа: только владелец или админ
|
||||
if job.user_id and job.user_id != request.user.id and not request.user.is_staff:
|
||||
return Response(
|
||||
{"detail": "Нет доступа к этой задаче"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = BackgroundJobSerializer(job)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class BackgroundJobListView(APIView):
|
||||
"""
|
||||
Список фоновых задач пользователя.
|
||||
|
||||
GET /api/v1/jobs/
|
||||
Возвращает список задач текущего пользователя.
|
||||
|
||||
Query params:
|
||||
status: Фильтр по статусу (pending, started, success, failure)
|
||||
limit: Количество записей (по умолчанию 50)
|
||||
"""
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
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")
|
||||
limit = min(int(request.query_params.get("limit", 50)), 100)
|
||||
|
||||
jobs = BackgroundJobService.get_user_jobs(
|
||||
user_id=request.user.id,
|
||||
status=status_filter,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
serializer = BackgroundJobListSerializer(jobs, many=True)
|
||||
return Response(serializer.data)
|
||||
Reference in New Issue
Block a user