""" Base service classes for business logic layer. Services encapsulate business logic and are independent of HTTP layer. They are easily testable and can manage transactions. """ import logging from datetime import timedelta from typing import Any, Generic, TypeVar import django from apps.core.exceptions import NotFoundError from django.db import models, transaction from django.db.models import Q, QuerySet from django.utils import timezone logger = logging.getLogger(__name__) # Type variable for model M = TypeVar("M", bound=models.Model) class BaseService(Generic[M]): """ Base service class providing common CRUD operations. Usage: class UserService(BaseService[User]): model = User @classmethod def create_user(cls, *, email: str, password: str) -> User: # Business logic here user = cls.model.objects.create_user(email=email, password=password) return user """ model: type[M] @classmethod def get_queryset(cls) -> QuerySet[M]: """Get base queryset for the model. Override to add default filters.""" return cls.model.objects.all() @classmethod def get_by_id(cls, pk: Any) -> M: """ Get entity by primary key. Raises: NotFoundError: If entity not found """ try: return cls.get_queryset().get(pk=pk) except cls.model.DoesNotExist as e: raise NotFoundError( message=f"{cls.model.__name__} with id={pk} not found", code="not_found", details={"model": cls.model.__name__, "id": pk}, ) from e @classmethod def get_by_id_or_none(cls, pk: Any) -> M | None: """Get entity by primary key or None if not found.""" try: return cls.get_queryset().get(pk=pk) except cls.model.DoesNotExist: return None @classmethod def get_all(cls) -> QuerySet[M]: """Get all entities.""" return cls.get_queryset() @classmethod def filter(cls, **kwargs: Any) -> QuerySet[M]: """Filter entities by given criteria.""" return cls.get_queryset().filter(**kwargs) @classmethod def exists(cls, **kwargs: Any) -> bool: """Check if entity with given criteria exists.""" return cls.get_queryset().filter(**kwargs).exists() @classmethod def count(cls, **kwargs: Any) -> int: """Count entities matching criteria.""" if kwargs: return cls.get_queryset().filter(**kwargs).count() return cls.get_queryset().count() @classmethod @transaction.atomic def create(cls, **kwargs: Any) -> M: """ Create new entity. Override this method to add business logic before/after creation. """ return cls.model.objects.create(**kwargs) @classmethod @transaction.atomic def update(cls, instance: M, **kwargs: Any) -> M: """ Update entity fields. Override this method to add business logic before/after update. """ for field, value in kwargs.items(): setattr(instance, field, value) update_fields = set(kwargs.keys()) if hasattr(instance, "updated_at"): update_fields.add("updated_at") instance.save(update_fields=list(update_fields)) return instance @classmethod @transaction.atomic def delete(cls, instance: M) -> None: """ Delete entity. Override this method to implement soft delete or add business logic. """ instance.delete() @classmethod @transaction.atomic def bulk_create(cls, instances: list[M], **kwargs: Any) -> list[M]: """Bulk create entities.""" return cls.model.objects.bulk_create(instances, **kwargs) @classmethod @transaction.atomic def bulk_update(cls, instances: list[M], fields: list[str], **kwargs: Any) -> int: """Bulk update entities.""" return cls.model.objects.bulk_update(instances, fields, **kwargs) class BaseReadOnlyService(Generic[M]): """ Read-only service for entities that should not be modified via API. Useful for reference data, logs, audit trails, etc. """ model: type[M] @classmethod def get_queryset(cls) -> QuerySet[M]: """Get base queryset for the model.""" return cls.model.objects.all() @classmethod def get_by_id(cls, pk: Any) -> M: """Get entity by primary key.""" try: return cls.get_queryset().get(pk=pk) except cls.model.DoesNotExist as e: raise NotFoundError( message=f"{cls.model.__name__} with id={pk} not found", code="not_found", ) from e @classmethod def get_all(cls) -> QuerySet[M]: """Get all entities.""" return cls.get_queryset() @classmethod def filter(cls, **kwargs: Any) -> QuerySet[M]: """Filter entities by given criteria.""" return cls.get_queryset().filter(**kwargs) class TransactionMixin: """ Mixin providing transaction helpers for services. Usage: class PaymentService(TransactionMixin, BaseService[Payment]): @classmethod def process_payment(cls, order_id: int) -> Payment: with cls.atomic(): # Multiple operations in single transaction ... """ @classmethod def atomic(cls): """Get atomic transaction context manager.""" return transaction.atomic() @classmethod def on_commit(cls, func): """Register function to be called after transaction commits.""" transaction.on_commit(func) @classmethod def savepoint(cls): """Create a savepoint within current transaction.""" return transaction.savepoint() @classmethod def savepoint_rollback(cls, sid): """Rollback to a savepoint.""" transaction.savepoint_rollback(sid) @classmethod def savepoint_commit(cls, sid): """Commit a savepoint.""" transaction.savepoint_commit(sid) class BulkOperationsMixin: """ Миксин для расширенных массовых операций. Дополняет BaseService методами: - bulk_create_chunked: создание чанками для больших данных - bulk_update_or_create: upsert операция - bulk_delete: удаление по списку ID - bulk_update_fields: обновление полей по фильтру Использование: class ProductService(BulkOperationsMixin, BaseService[Product]): model = Product # Создание 10000 записей чанками по 500 ProductService.bulk_create_chunked(products, chunk_size=500) # Upsert по уникальному полю ProductService.bulk_update_or_create( items=data, unique_fields=['sku'], update_fields=['price', 'quantity'] ) """ model: type[models.Model] @classmethod @transaction.atomic def bulk_create_chunked( cls, instances: list, *, chunk_size: int = 500, ignore_conflicts: bool = False, update_conflicts: bool = False, update_fields: list[str] | None = None, unique_fields: list[str] | None = None, ) -> int: """ Массовое создание чанками для больших объёмов. Args: instances: Список объектов для создания chunk_size: Размер чанка (по умолчанию 500) ignore_conflicts: Игнорировать конфликты update_conflicts: Обновлять при конфликтах (upsert) update_fields: Поля для обновления при конфликте unique_fields: Уникальные поля для определения конфликта Returns: Количество созданных записей """ total_created = 0 for i in range(0, len(instances), chunk_size): chunk = instances[i : i + chunk_size] kwargs = { "ignore_conflicts": ignore_conflicts, } # Django 4.1+ поддерживает update_conflicts; проект закреплён на Django 3.x. if ( django.VERSION >= (4, 1) and update_conflicts and update_fields and unique_fields ): kwargs["update_conflicts"] = True kwargs["update_fields"] = update_fields kwargs["unique_fields"] = unique_fields created = cls.model.objects.bulk_create(chunk, **kwargs) total_created += len(created) return total_created @classmethod @transaction.atomic def bulk_update_or_create( cls, items: list[dict], *, unique_fields: list[str], update_fields: list[str], create_defaults: dict | None = None, ) -> tuple[int, int]: """ Upsert: обновить существующие или создать новые. Args: items: Список словарей с данными unique_fields: Поля для поиска существующих update_fields: Поля для обновления create_defaults: Значения по умолчанию для создания Returns: (created_count, updated_count) """ created_count = 0 updated_count = 0 defaults = create_defaults or {} for item in items: lookup = {field: item[field] for field in unique_fields} update_data = { field: item[field] for field in update_fields if field in item } obj, created = cls.model.objects.update_or_create( **lookup, defaults={**update_data, **defaults}, ) if created: created_count += 1 else: updated_count += 1 return created_count, updated_count @classmethod @transaction.atomic def bulk_delete( cls, ids: list, *, hard_delete: bool = True, ) -> int: """ Массовое удаление по списку ID. Args: ids: Список ID для удаления hard_delete: Физическое удаление (игнорирует SoftDelete) Returns: Количество удалённых записей """ queryset = cls.model.objects.filter(pk__in=ids) if hard_delete: # Для SoftDelete моделей используем all_objects if hasattr(cls.model, "all_objects"): queryset = cls.model.all_objects.filter(pk__in=ids) deleted, _ = queryset.delete() else: # Мягкое удаление from django.utils import timezone deleted = queryset.update(is_deleted=True, deleted_at=timezone.now()) return deleted @classmethod @transaction.atomic def bulk_update_fields( cls, filters: dict, updates: dict, ) -> int: """ Массовое обновление полей по фильтру. Args: filters: Фильтры для выборки updates: Поля и значения для обновления Returns: Количество обновлённых записей Пример: ProductService.bulk_update_fields( filters={'category': 'electronics'}, updates={'discount': 10, 'is_featured': True} ) """ return cls.model.objects.filter(**filters).update(**updates) class QueryOptimizerMixin: """ Миксин для автоматической оптимизации запросов. Декларативный подход к select_related/prefetch_related. Атрибуты: select_related: Список полей для select_related prefetch_related: Список полей для prefetch_related default_only: Поля для only() (ограничение столбцов) default_defer: Поля для defer() (исключение столбцов) Использование: class OrderService(QueryOptimizerMixin, BaseService[Order]): model = Order select_related = ['user', 'shipping_address'] prefetch_related = ['items', 'items__product'] default_defer = ['description', 'internal_notes'] # Автоматически применяет оптимизации orders = OrderService.get_optimized_queryset() """ model: type[models.Model] select_related: list[str] = [] prefetch_related: list[str] = [] default_only: list[str] = [] default_defer: list[str] = [] @classmethod def get_optimized_queryset(cls) -> QuerySet: """ Получить оптимизированный queryset. Применяет все объявленные оптимизации. """ queryset = cls.model.objects.all() return cls.apply_optimizations(queryset) @classmethod def apply_optimizations( cls, queryset: QuerySet, *, include_select: bool = True, include_prefetch: bool = True, include_only: bool = True, include_defer: bool = True, ) -> QuerySet: """ Применить оптимизации к queryset. Args: queryset: Исходный queryset include_select: Применять select_related include_prefetch: Применять prefetch_related include_only: Применять only() include_defer: Применять defer() """ if include_select and cls.select_related: queryset = queryset.select_related(*cls.select_related) if include_prefetch and cls.prefetch_related: queryset = queryset.prefetch_related(*cls.prefetch_related) if include_only and cls.default_only: queryset = queryset.only(*cls.default_only) if include_defer and cls.default_defer: queryset = queryset.defer(*cls.default_defer) return queryset @classmethod def get_list_queryset(cls) -> QuerySet: """ Queryset для списков (может исключать тяжёлые поля). """ return cls.apply_optimizations( cls.model.objects.all(), include_only=True, include_defer=True, ) @classmethod def get_detail_queryset(cls) -> QuerySet: """ Queryset для детального просмотра (все поля). """ return cls.apply_optimizations( cls.model.objects.all(), include_only=False, include_defer=False, ) @classmethod def with_counts(cls, queryset: QuerySet, *count_fields: str) -> QuerySet: """ Добавить аннотации Count. Args: queryset: Исходный queryset count_fields: Поля для подсчёта Пример: # Добавит items_count и reviews_count qs = ProductService.with_counts(qs, 'items', 'reviews') """ from django.db.models import Count annotations = {f"{field}_count": Count(field) for field in count_fields} return queryset.annotate(**annotations) @classmethod def with_exists(cls, queryset: QuerySet, **subqueries: QuerySet) -> QuerySet: """ Добавить аннотации Exists. Пример: from apps.reviews.models import Review qs = ProductService.with_exists( qs, has_reviews=Review.objects.filter(product=OuterRef('pk')) ) """ from django.db.models import Exists annotations = {name: Exists(subquery) for name, subquery in subqueries.items()} return queryset.annotate(**annotations) class BackgroundJobService(BaseReadOnlyService): """ Сервис для управления фоновыми задачами. Использование: # Создание задачи job = BackgroundJobService.create_job( task_id="abc-123", task_name="apps.myapp.tasks.process_data", user_id=request.user.id, ) # Получение статуса job = BackgroundJobService.get_by_task_id("abc-123") # Список задач пользователя jobs = BackgroundJobService.get_user_jobs(user_id=1) """ # Импорт модели внутри методов для избежания circular import @classmethod def get_model(cls): """Ленивый импорт модели.""" from apps.core.models import BackgroundJob return BackgroundJob @classmethod def get_queryset(cls): """Get base queryset.""" return cls.get_model().objects.all() @classmethod def create_job( cls, *, task_id: str, task_name: str, user_id: int | None = None, meta: dict | None = None, ): """ Создать запись о фоновой задаче. Args: task_id: ID задачи Celery task_name: Имя задачи user_id: ID пользователя (опционально) meta: Дополнительные метаданные Returns: BackgroundJob instance """ BackgroundJob = cls.get_model() return BackgroundJob.objects.create( task_id=task_id, task_name=task_name, user_id=user_id, meta=meta or {}, ) @classmethod def get_by_task_id(cls, task_id: str): """ Получить задачу по ID Celery. Raises: NotFoundError: Если задача не найдена """ BackgroundJob = cls.get_model() try: return BackgroundJob.objects.get(task_id=task_id) except BackgroundJob.DoesNotExist as e: raise NotFoundError( message=f"Job with task_id={task_id} not found", code="job_not_found", ) from e @classmethod def get_by_task_id_or_none(cls, task_id: str): """Получить задачу по ID или None.""" BackgroundJob = cls.get_model() try: return BackgroundJob.objects.get(task_id=task_id) except BackgroundJob.DoesNotExist: return None @classmethod def get_user_jobs( cls, user_id: int, *, status: str | None = None, limit: int = 50, ): """ Получить задачи пользователя. Args: user_id: ID пользователя status: Фильтр по статусу (опционально) limit: Максимальное количество записей Returns: QuerySet задач """ qs = cls.get_queryset().filter(user_id=user_id) if status: qs = qs.filter(status=status) return qs[:limit] @classmethod def get_active_jobs(cls, user_id: int | None = None): """ Получить активные (незавершённые) задачи. Args: user_id: Фильтр по пользователю (опционально) """ from apps.core.models import JobStatus qs = cls.get_queryset().filter( status__in=[JobStatus.PENDING, JobStatus.STARTED, JobStatus.RETRY] ) if user_id: qs = qs.filter(user_id=user_id) return qs @classmethod def cleanup_old_jobs(cls, *, days: int = 30) -> int: """ Удалить старые завершённые задачи. Args: days: Количество дней (задачи старше будут удалены) Returns: Количество удалённых записей """ from datetime import timedelta from apps.core.models import JobStatus from django.utils import timezone cutoff = timezone.now() - timedelta(days=days) deleted, _ = ( cls.get_queryset() .filter( status__in=[JobStatus.SUCCESS, JobStatus.FAILURE, JobStatus.REVOKED], completed_at__lt=cutoff, ) .delete() ) return deleted @classmethod def mark_stale_active_jobs_failed( cls, *, max_age_minutes: int, task_names: set[str] | None = None, meta_sources: set[str] | None = None, ) -> int: """Mark old active jobs as failed after worker restarts or hard kills.""" from apps.core.models import JobStatus cutoff = timezone.now() - timedelta(minutes=max_age_minutes) queryset = cls.get_queryset().filter( status__in=[JobStatus.PENDING, JobStatus.STARTED, JobStatus.RETRY], updated_at__lt=cutoff, ) if task_names: queryset = queryset.filter(task_name__in=task_names) if meta_sources: source_filter = Q() for source in meta_sources: source_filter |= Q(meta__source=source) queryset = queryset.filter(source_filter) stale_message = ( "Stale background job was marked failed after " f"{max_age_minutes} minutes without progress." ) updated = 0 for job in queryset.order_by("created_at"): job.fail(error=stale_message) updated += 1 return updated