Add initial implementations for forms and organization apps with serializers, factories, and admin configurations
Some checks failed
CI/CD Pipeline / Run Tests (push) Failing after 45s
CI/CD Pipeline / Code Quality Checks (push) Failing after 48s
CI/CD Pipeline / Build Docker Images (push) Has been skipped
CI/CD Pipeline / Push to Gitea Registry (push) Has been skipped
CI/CD Pipeline / Deploy to Server (push) Has been skipped

This commit is contained in:
2026-03-28 18:23:06 +01:00
parent 8ed3e1175c
commit 345b1d0cc8
201 changed files with 15097 additions and 6691 deletions

View File

@@ -7,14 +7,23 @@ Provides endpoints for:
- Detailed health check (DB, Redis, Celery status)
"""
import json
import logging
import time
from typing import Any
from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag
from apps.core.serializers import (
BackgroundJobListResponseSerializer,
BackgroundJobListSerializer,
BackgroundJobSerializer,
)
from django.conf import settings
from django.db import connection
from django.http import StreamingHttpResponse
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
@@ -22,20 +31,15 @@ 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):
"""
Комплексная проверка состояния системы.
Возвращает статус всех зависимостей (БД, Redis, Celery).
"""
"""Комплексная проверка состояния системы."""
permission_classes = [AllowAny]
authentication_classes = [] # No auth required
authentication_classes = []
@swagger_auto_schema(
tags=[HEALTH_TAG],
@@ -44,25 +48,26 @@ 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."""
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
@@ -74,19 +79,14 @@ class HealthCheckView(APIView):
"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:
@@ -94,12 +94,11 @@ class HealthCheckView(APIView):
cursor.fetchone()
latency = (time.time() - start) * 1000
return {"status": "up", "latency_ms": round(latency, 2)}
except Exception as e:
except Exception as e: # noqa: BLE001
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
@@ -110,71 +109,67 @@ class HealthCheckView(APIView):
return {"status": "up", "latency_ms": round(latency, 2)}
except ImportError:
return {"status": "skipped", "reason": "django_redis not installed"}
except Exception as e:
except Exception as e: # noqa: BLE001
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
from core.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": "up", "workers": len(active)}
return {"status": "down", "error": "No active workers"}
except Exception as e:
except Exception as e: # noqa: BLE001
logger.warning(f"Celery health check failed: {e}")
return {"status": "down", "error": str(e)}
class LivenessView(APIView):
"""
Kubernetes liveness probe.
Проверяет, запущено ли приложение.
"""
"""Kubernetes liveness probe."""
permission_classes = [AllowAny]
authentication_classes = []
@swagger_auto_schema(
tags=[HEALTH_TAG],
operation_summary="Liveness probe",
operation_summary="Проверка доступности",
operation_description="Возвращает 200 если приложение запущено.",
responses={
200: "Приложение запущено",
**ErrorResponses.PUBLIC,
},
)
def get(self, request: Request) -> Response:
"""Simple liveness check."""
return Response({"status": "alive"}, status=status.HTTP_200_OK)
class ReadinessView(APIView):
"""
Kubernetes readiness probe.
Проверяет, готово ли приложение обрабатывать запросы.
"""
"""Kubernetes readiness probe."""
permission_classes = [AllowAny]
authentication_classes = []
@swagger_auto_schema(
tags=[HEALTH_TAG],
operation_summary="Readiness probe",
operation_summary="Проверка готовности",
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."""
# Check database connection
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
except Exception as e:
except Exception as e: # noqa: BLE001
logger.error(f"Readiness check failed - database: {e}")
return Response(
{"status": "not_ready", "reason": "database unavailable"},
@@ -185,16 +180,21 @@ class ReadinessView(APIView):
class BackgroundJobStatusView(APIView):
"""
Получение статуса фоновой задачи.
Возвращает статус, прогресс и результат задачи.
"""
"""Получение статуса фоновой задачи."""
from rest_framework.permissions import IsAuthenticated
permission_classes = [IsAuthenticated]
@staticmethod
def _check_access(request: Request, job) -> Response | None:
if not request.user.is_staff and job.user_id != request.user.id:
return Response(
{"detail": "Нет доступа к этой задаче"},
status=status.HTTP_403_FORBIDDEN,
)
return None
@swagger_auto_schema(
tags=[JOBS_TAG],
operation_summary="Статус задачи",
@@ -202,31 +202,127 @@ 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)
# Проверка доступа: только владелец или админ
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,
)
access_error = self._check_access(request, job)
if access_error is not None:
return access_error
serializer = BackgroundJobSerializer(job)
return Response(serializer.data)
class BackgroundJobListView(APIView):
"""
Список фоновых задач пользователя.
class BackgroundJobStreamView(BackgroundJobStatusView):
"""SSE stream with job progress updates until completion."""
Возвращает список задач текущего пользователя с фильтрацией.
"""
poll_interval_seconds = 1.0
@staticmethod
def _build_sse_message(*, event: str, payload: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _build_progress_payload(self, job) -> dict[str, Any]:
return {
"task_id": job.task_id,
"status": "running",
"progress": job.progress,
"message": job.progress_message,
}
def _build_final_payload(self, job) -> tuple[str, dict[str, Any]]:
if job.is_successful:
return (
"completed",
{
"task_id": job.task_id,
"status": "success",
"progress": job.progress,
"result": job.result,
},
)
return (
"failed",
{
"task_id": job.task_id,
"status": "error",
"progress": job.progress,
"message": job.error
or job.progress_message
or "Задача завершилась с ошибкой",
},
)
def _event_stream(self, task_id: str):
from apps.core.services import BackgroundJobService
last_snapshot: tuple[str, int, str, str] | None = None
while True:
job = BackgroundJobService.get_by_task_id(task_id)
snapshot = (
job.status,
job.progress,
job.progress_message,
job.error,
)
if snapshot != last_snapshot:
last_snapshot = snapshot
if job.is_finished:
event, payload = self._build_final_payload(job)
yield self._build_sse_message(event=event, payload=payload)
break
yield self._build_sse_message(
event="progress",
payload=self._build_progress_payload(job),
)
time.sleep(self.poll_interval_seconds)
@swagger_auto_schema(
tags=[JOBS_TAG],
operation_summary="Поток статуса задачи",
operation_description=(
"Открывает SSE-поток со статусом фоновой задачи до её завершения.\n"
"Доступно только владельцу задачи или администратору."
),
responses={
200: "SSE-поток статуса задачи",
403: CommonResponses.FORBIDDEN,
404: CommonResponses.NOT_FOUND,
**ErrorResponses.AUTHENTICATED,
},
)
def get(self, request: Request, task_id: str) -> StreamingHttpResponse | Response:
from apps.core.services import BackgroundJobService
job = BackgroundJobService.get_by_task_id(task_id)
access_error = self._check_access(request, job)
if access_error is not None:
return access_error
response = StreamingHttpResponse(
self._event_stream(task_id),
content_type="text/event-stream",
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
class BackgroundJobListView(APIView):
"""Список фоновых задач пользователя."""
from rest_framework.permissions import IsAuthenticated
@@ -239,14 +335,25 @@ class BackgroundJobListView(APIView):
"Возвращает список фоновых задач текущего пользователя.\n"
"Поддерживает фильтрацию по статусу (status) и лимит (limit)."
),
responses={
200: BackgroundJobListResponseSerializer,
**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")
limit = min(int(request.query_params.get("limit", 50)), 100)
limit_raw = request.query_params.get("limit", "50")
try:
limit = int(limit_raw)
except (TypeError, ValueError) as exc:
raise ValidationError(
{"limit": "Параметр limit должен быть целым числом"}
) from exc
if limit < 1:
raise ValidationError({"limit": "Параметр limit должен быть больше 0"})
limit = min(limit, 100)
jobs = BackgroundJobService.get_user_jobs(
user_id=request.user.id,
@@ -255,4 +362,4 @@ class BackgroundJobListView(APIView):
)
serializer = BackgroundJobListSerializer(jobs, many=True)
return Response(serializer.data)
return Response({"results": serializer.data})