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:
0
src/apps/core/management/__init__.py
Normal file
0
src/apps/core/management/__init__.py
Normal file
0
src/apps/core/management/commands/__init__.py
Normal file
0
src/apps/core/management/commands/__init__.py
Normal file
252
src/apps/core/management/commands/base.py
Normal file
252
src/apps/core/management/commands/base.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Базовый класс для management commands.
|
||||
|
||||
Предоставляет:
|
||||
- Структурированное логирование
|
||||
- Отображение прогресса
|
||||
- Обработку ошибок
|
||||
- Измерение времени выполнения
|
||||
- Dry-run режим
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseAppCommand(BaseCommand):
|
||||
"""
|
||||
Базовый класс для management commands проекта.
|
||||
|
||||
Возможности:
|
||||
- Автоматическое логирование начала и завершения
|
||||
- Измерение времени выполнения
|
||||
- Поддержка dry-run режима
|
||||
- Прогресс-бар для итераций
|
||||
- Транзакционное выполнение
|
||||
- Обработка ошибок с правильными кодами выхода
|
||||
|
||||
Использование:
|
||||
class Command(BaseAppCommand):
|
||||
help = 'Описание команды'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
super().add_arguments(parser) # Добавляет --dry-run
|
||||
parser.add_argument('--my-arg', type=str)
|
||||
|
||||
def execute_command(self, *args, **options):
|
||||
# Основная логика команды
|
||||
items = MyModel.objects.all()
|
||||
|
||||
for item in self.progress_iter(items, desc="Обработка"):
|
||||
self.process_item(item)
|
||||
|
||||
return "Обработано успешно"
|
||||
"""
|
||||
|
||||
# Переопределяемые атрибуты
|
||||
requires_migrations_checks = True
|
||||
requires_system_checks = "__all__"
|
||||
use_transaction = False # Обернуть в транзакцию
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
"""Добавление базовых аргументов."""
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Режим тестового запуска без изменений в базе данных",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--silent",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Минимальный вывод (только ошибки)",
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> str | None:
|
||||
"""Основной обработчик команды."""
|
||||
self.dry_run = options.get("dry_run", False)
|
||||
self.silent = options.get("silent", False)
|
||||
self.verbosity = options.get("verbosity", 1)
|
||||
|
||||
command_name = self.__class__.__module__.split(".")[-1]
|
||||
|
||||
# Логирование старта
|
||||
self.log_info(f"Запуск команды: {command_name}")
|
||||
if self.dry_run:
|
||||
self.log_warning("Режим dry-run: изменения НЕ будут сохранены")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if self.use_transaction:
|
||||
with transaction.atomic():
|
||||
result = self._execute_with_rollback(*args, **options)
|
||||
else:
|
||||
result = self.execute_command(*args, **options)
|
||||
|
||||
# Логирование успеха
|
||||
elapsed = time.time() - start_time
|
||||
self.log_success(f"Команда завершена за {elapsed:.2f}с")
|
||||
|
||||
return result
|
||||
|
||||
except CommandError:
|
||||
raise
|
||||
except Exception as e:
|
||||
elapsed = time.time() - start_time
|
||||
self.log_error(f"Ошибка после {elapsed:.2f}с: {e}")
|
||||
logger.exception("Command failed", extra={"command": command_name})
|
||||
raise CommandError(str(e)) from e
|
||||
|
||||
def _execute_with_rollback(self, *args: Any, **options: Any) -> str | None:
|
||||
"""Выполнение с откатом в dry-run режиме."""
|
||||
result = self.execute_command(*args, **options)
|
||||
|
||||
if self.dry_run:
|
||||
# Откатываем транзакцию в dry-run
|
||||
transaction.set_rollback(True)
|
||||
self.log_warning("Dry-run: транзакция откачена")
|
||||
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
def execute_command(self, *args: Any, **options: Any) -> str | None:
|
||||
"""
|
||||
Основная логика команды. Переопределяется в наследниках.
|
||||
|
||||
Returns:
|
||||
Строка с результатом или None
|
||||
"""
|
||||
raise NotImplementedError("Метод execute_command должен быть реализован")
|
||||
|
||||
# ==================== Методы вывода ====================
|
||||
|
||||
def log_info(self, message: str) -> None:
|
||||
"""Информационное сообщение."""
|
||||
if not self.silent:
|
||||
self.stdout.write(message)
|
||||
logger.info(message)
|
||||
|
||||
def log_success(self, message: str) -> None:
|
||||
"""Сообщение об успехе (зелёное)."""
|
||||
if not self.silent:
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
logger.info(message)
|
||||
|
||||
def log_warning(self, message: str) -> None:
|
||||
"""Предупреждение (жёлтое)."""
|
||||
if not self.silent:
|
||||
self.stdout.write(self.style.WARNING(message))
|
||||
logger.warning(message)
|
||||
|
||||
def log_error(self, message: str) -> None:
|
||||
"""Ошибка (красное)."""
|
||||
self.stderr.write(self.style.ERROR(message))
|
||||
logger.error(message)
|
||||
|
||||
def log_debug(self, message: str) -> None:
|
||||
"""Отладочное сообщение (только при verbosity >= 2)."""
|
||||
if self.verbosity >= 2:
|
||||
self.stdout.write(self.style.HTTP_INFO(message))
|
||||
logger.debug(message)
|
||||
|
||||
# ==================== Прогресс ====================
|
||||
|
||||
def progress_iter(
|
||||
self,
|
||||
iterable,
|
||||
desc: str = "Обработка",
|
||||
total: int | None = None,
|
||||
) -> Generator:
|
||||
"""
|
||||
Итератор с отображением прогресса.
|
||||
|
||||
Args:
|
||||
iterable: Итерируемый объект
|
||||
desc: Описание операции
|
||||
total: Общее количество (если известно)
|
||||
|
||||
Yields:
|
||||
Элементы итератора
|
||||
|
||||
Использование:
|
||||
for item in self.progress_iter(items, "Обработка записей"):
|
||||
process(item)
|
||||
"""
|
||||
if total is None:
|
||||
try:
|
||||
total = len(iterable)
|
||||
except TypeError:
|
||||
total = None
|
||||
|
||||
processed = 0
|
||||
last_percent = -1
|
||||
|
||||
for item in iterable:
|
||||
yield item
|
||||
processed += 1
|
||||
|
||||
if total and not self.silent:
|
||||
percent = int(processed * 100 / total)
|
||||
if percent != last_percent and percent % 10 == 0:
|
||||
self.stdout.write(f"{desc}: {percent}% ({processed}/{total})")
|
||||
last_percent = percent
|
||||
|
||||
if not self.silent:
|
||||
self.log_info(f"{desc}: завершено ({processed} элементов)")
|
||||
|
||||
@contextmanager
|
||||
def timed_operation(self, operation_name: str) -> Generator:
|
||||
"""
|
||||
Контекстный менеджер для измерения времени операции.
|
||||
|
||||
Использование:
|
||||
with self.timed_operation("Загрузка данных"):
|
||||
load_data()
|
||||
"""
|
||||
start = time.time()
|
||||
self.log_debug(f"Начало: {operation_name}")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = time.time() - start
|
||||
self.log_debug(f"Завершено: {operation_name} ({elapsed:.2f}с)")
|
||||
|
||||
# ==================== Утилиты ====================
|
||||
|
||||
def confirm(self, message: str) -> bool:
|
||||
"""
|
||||
Запрос подтверждения у пользователя.
|
||||
|
||||
Args:
|
||||
message: Текст вопроса
|
||||
|
||||
Returns:
|
||||
True если пользователь подтвердил
|
||||
"""
|
||||
if self.dry_run:
|
||||
self.log_warning(f"[Dry-run] Пропуск подтверждения: {message}")
|
||||
return True
|
||||
|
||||
self.stdout.write(f"\n{message} [y/N]: ", ending="")
|
||||
response = input().strip().lower()
|
||||
return response in ("y", "yes", "да", "д")
|
||||
|
||||
def abort(self, message: str) -> None:
|
||||
"""Прерывание команды с сообщением."""
|
||||
raise CommandError(message)
|
||||
|
||||
def check_dry_run(self) -> bool:
|
||||
"""Проверка режима dry-run (для условного выполнения)."""
|
||||
return self.dry_run
|
||||
Reference in New Issue
Block a user