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:
2026-01-21 11:47:26 +01:00
parent 06b30fca02
commit f121445313
72 changed files with 9258 additions and 594 deletions

View File

View 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