Сигналы pre_save и post_save

Эти сигналы позволяют выполнять код до и после сохранения объектов модели. Они являются мощным инструментом для автоматизации бизнес-логики и поддержания целостности данных.

Основы работы с сигналами

Django предоставляет несколько типов сигналов для моделей:

  • pre_save - выполняется перед сохранением объекта
  • post_save - выполняется после сохранения объекта
  • pre_delete - выполняется перед удалением объекта
  • post_delete - выполняется после удаления объекта
  • m2m_changed - выполняется при изменении ManyToMany отношений

Простой пример с pre_save

 1from django.db.models.signals import pre_save
 2from django.dispatch import receiver
 3from django.utils.text import slugify
 4from .models import Article, Book, Product
 5
 6@receiver(pre_save, sender=Article)
 7def article_pre_save(sender, instance, **kwargs):
 8    """Автоматическое создание slug для статьи"""
 9    if not instance.slug:
10        instance.slug = slugify(instance.title)
11
12    # Автоматическое обновление поля updated_at
13    from django.utils import timezone
14    instance.updated_at = timezone.now()
15
16    # Логирование изменений
17    if instance.pk:  # Обновление существующего объекта
18        try:
19            old_instance = Article.objects.get(pk=instance.pk)
20            if old_instance.title != instance.title:
21                print(f'Заголовок изменен с "{old_instance.title}" на "{instance.title}"')
22        except Article.DoesNotExist:
23            pass
24
25@receiver(pre_save, sender=Book)
26def book_pre_save(sender, instance, **kwargs):
27    """Автоматическое форматирование названия книги"""
28    # Приводим название к правильному формату
29    instance.title = instance.title.title()
30
31    # Автоматическое вычисление цены со скидкой
32    if instance.discount_percent > 0:
33        instance.final_price = instance.price * (1 - instance.discount_percent / 100)
34    else:
35        instance.final_price = instance.price
36
37    # Валидация данных
38    if instance.price < 0:
39        raise ValueError("Цена не может быть отрицательной")
40
41    if instance.discount_percent > 100:
42        raise ValueError("Скидка не может превышать 100%")
43
44@receiver(pre_save, sender=Product)
45def product_pre_save(sender, instance, **kwargs):
46    """Автоматическое обновление статуса продукта"""
47    # Если остаток равен 0, помечаем как недоступный
48    if instance.stock == 0:
49        instance.is_available = False
50    elif instance.stock > 0 and not instance.is_available:
51        instance.is_available = True
52
53    # Автоматическое обновление поля last_updated
54    from django.utils import timezone
55    instance.last_updated = timezone.now()

Сложные сценарии с post_save

 1from django.db.models.signals import post_save
 2from django.dispatch import receiver
 3from django.core.cache import cache
 4from django.core.mail import send_mail
 5from django.template.loader import render_to_string
 6from .models import User, Order, Notification, UserProfile
 7
 8@receiver(post_save, sender=User)
 9def user_post_save(sender, instance, created, **kwargs):
10    """Автоматические действия после создания/обновления пользователя"""
11    if created:
12        # Создаем профиль пользователя
13        UserProfile.objects.create(
14            user=instance,
15            bio=f"Профиль пользователя {instance.username}",
16            avatar="default_avatar.png"
17        )
18
19        # Отправляем приветственное письмо
20        try:
21            context = {
22                'username': instance.username,
23                'email': instance.email
24            }
25            html_message = render_to_string(
26                'emails/welcome.html',
27                context
28            )
29
30            send_mail(
31                subject='Добро пожаловать!',
32                message=f'Привет, {instance.username}!',
33                from_email='noreply@example.com',
34                recipient_list=[instance.email],
35                html_message=html_message
36            )
37        except Exception as e:
38            # Логируем ошибку, но не прерываем процесс
39            print(f"Ошибка отправки email: {e}")
40
41        # Создаем уведомление
42        Notification.objects.create(
43            user=instance,
44            title="Добро пожаловать!",
45            message="Ваш аккаунт успешно создан.",
46            type="welcome"
47        )
48
49    # Очищаем кеш пользователя
50    cache_key = f'user_profile_{instance.id}'
51    cache.delete(cache_key)
52
53@receiver(post_save, sender=Order)
54def order_post_save(sender, instance, created, **kwargs):
55    """Обработка заказов после сохранения"""
56    if created:
57        # Отправляем уведомление о новом заказе
58        Notification.objects.create(
59            user=instance.user,
60            title="Новый заказ",
61            message=f"Ваш заказ #{instance.id} принят в обработку.",
62            type="order_created"
63        )
64
65        # Отправляем email администратору
66        try:
67            admin_emails = User.objects.filter(
68                is_staff=True
69            ).values_list('email', flat=True)
70
71            if admin_emails:
72                send_mail(
73                    subject=f'Новый заказ #{instance.id}',
74                    message=f'Получен новый заказ на сумму {instance.total_amount}',
75                    from_email='noreply@example.com',
76                    recipient_list=list(admin_emails)
77                )
78        except Exception as e:
79            print(f"Ошибка отправки email администратору: {e}")
80
81    # Обновляем статистику пользователя
82    try:
83        profile = instance.user.userprofile
84        profile.total_orders += 1
85        profile.total_spent += instance.total_amount
86        profile.save(update_fields=['total_orders', 'total_spent'])
87    except Exception as e:
88        print(f"Ошибка обновления статистики: {e}")
89
90    # Очищаем кеш заказов
91    cache.delete(f'user_orders_{instance.user.id}')

Сигналы с условной логикой

 1from django.db.models.signals import pre_save, post_save
 2from django.dispatch import receiver
 3from django.db.models import F
 4from .models import Comment, Post, UserActivity
 5
 6@receiver(pre_save, sender=Comment)
 7def comment_pre_save(sender, instance, **kwargs):
 8    """Предварительная обработка комментария"""
 9    # Автоматическое создание slug для комментария
10    if not instance.slug:
11        from django.utils.text import slugify
12        instance.slug = slugify(f"{instance.post.title}-{instance.author.username}")
13
14    # Проверка на спам (простая логика)
15    spam_words = ['spam', 'реклама', 'купить', 'заказать']
16    content_lower = instance.content.lower()
17    if any(word in content_lower for word in spam_words):
18        instance.is_moderated = False
19        instance.moderation_note = "Автоматически помечен как спам"
20    else:
21        instance.is_moderated = True
22
23@receiver(post_save, sender=Comment)
24def comment_post_save(sender, instance, created, **kwargs):
25    """Обработка комментария после сохранения"""
26    if created:
27        # Увеличиваем счетчик комментариев в посте
28        Post.objects.filter(id=instance.post.id).update(
29            comment_count=F('comment_count') + 1
30        )
31
32        # Создаем запись активности
33        UserActivity.objects.create(
34            user=instance.author,
35            action="comment_created",
36            target_model="Post",
37            target_id=instance.post.id,
38            description=f"Оставил комментарий к посту '{instance.post.title}'"
39        )
40
41        # Отправляем уведомление автору поста
42        if instance.author != instance.post.author:
43            Notification.objects.create(
44                user=instance.post.author,
45                title="Новый комментарий",
46                message=f"{instance.author.username} оставил комментарий к вашему посту",
47                type="new_comment"
48            )
49
50    # Обновляем статистику пользователя
51    user = instance.author
52    user.comment_count = user.comments.count()
53    user.save(update_fields=['comment_count'])
54
55@receiver(post_save, sender=Post)
56def post_post_save(sender, instance, created, **kwargs):
57    """Обработка поста после сохранения"""
58    if created:
59        # Создаем запись активности
60        UserActivity.objects.create(
61            user=instance.author,
62            action="post_created",
63            target_model="Post",
64            target_id=instance.id,
65            description=f"Создал пост '{instance.title}'"
66        )
67
68        # Отправляем уведомления подписчикам
69        followers = instance.author.followers.all()
70        for follower in followers:
71            Notification.objects.create(
72                user=follower,
73                title="Новый пост",
74                message=f"{instance.author.username} опубликовал новый пост",
75                type="new_post"
76            )
77
78    # Обновляем статистику автора
79    author = instance.author
80    author.post_count = author.posts.count()
81    author.save(update_fields=['post_count'])

Сигналы с транзакциями и обработкой ошибок

  1from django.db.models.signals import pre_save, post_save
  2from django.dispatch import receiver
  3from django.db import transaction
  4from django.core.exceptions import ValidationError
  5import logging
  6
  7logger = logging.getLogger(__name__)
  8
  9@receiver(pre_save, sender=Order)
 10def order_pre_save(sender, instance, **kwargs):
 11    """Валидация заказа перед сохранением"""
 12    try:
 13        # Проверяем наличие товаров
 14        if instance.items.count() == 0:
 15            raise ValidationError("Заказ должен содержать хотя бы один товар")
 16
 17        # Проверяем достаточность средств
 18        if instance.user.balance < instance.total_amount:
 19            raise ValidationError("Недостаточно средств на счете")
 20
 21        # Автоматическое вычисление скидки
 22        if instance.total_amount > 10000:
 23            instance.discount = 0.1  # 10% скидка
 24        elif instance.total_amount > 5000:
 25            instance.discount = 0.05  # 5% скидка
 26        else:
 27            instance.discount = 0
 28
 29        # Применяем скидку
 30        instance.final_amount = instance.total_amount * (1 - instance.discount)
 31
 32    except Exception as e:
 33        logger.error(f"Ошибка валидации заказа: {e}")
 34        raise
 35
 36@receiver(post_save, sender=Order)
 37def order_post_save(sender, instance, created, **kwargs):
 38    """Обработка заказа после сохранения с транзакциями"""
 39    if created:
 40        try:
 41            with transaction.atomic():
 42                # Списываем средства с баланса пользователя
 43                user = instance.user
 44                user.balance -= instance.final_amount
 45                user.save(update_fields=['balance'])
 46
 47                # Создаем запись о транзакции
 48                Transaction.objects.create(
 49                    user=user,
 50                    amount=-instance.final_amount,
 51                    type="order_payment",
 52                    description=f"Оплата заказа #{instance.id}"
 53                )
 54
 55                # Обновляем статистику продаж
 56                for item in instance.items.all():
 57                    product = item.product
 58                    product.sold_count += item.quantity
 59                    product.stock -= item.quantity
 60                    product.save(update_fields=['sold_count', 'stock'])
 61
 62                # Отправляем уведомления
 63                self.send_order_notifications(instance)
 64
 65        except Exception as e:
 66            logger.error(f"Ошибка обработки заказа {instance.id}: {e}")
 67            # Можно отправить уведомление администратору
 68            self.notify_admin_about_error(instance, str(e))
 69
 70def send_order_notifications(self, order):
 71    """Отправка уведомлений о заказе"""
 72    try:
 73        # Уведомление пользователю
 74        Notification.objects.create(
 75            user=order.user,
 76            title="Заказ подтвержден",
 77            message=f"Ваш заказ #{order.id} успешно оформлен",
 78            type="order_confirmed"
 79        )
 80
 81        # Email подтверждения
 82        context = {
 83            'order': order,
 84            'user': order.user
 85        }
 86        html_message = render_to_string(
 87            'emails/order_confirmation.html',
 88            context
 89        )
 90
 91        send_mail(
 92            subject=f'Подтверждение заказа #{order.id}',
 93            message=f'Ваш заказ на сумму {order.final_amount} подтвержден',
 94            from_email='noreply@example.com',
 95            recipient_list=[order.user.email],
 96            html_message=html_message
 97        )
 98
 99    except Exception as e:
100        logger.error(f"Ошибка отправки уведомлений: {e}")
101
102def notify_admin_about_error(self, order, error_message):
103    """Уведомление администратора об ошибке"""
104    try:
105        admin_users = User.objects.filter(is_staff=True)
106        for admin in admin_users:
107            Notification.objects.create(
108                user=admin,
109                title="Ошибка обработки заказа",
110                message=f"Ошибка в заказе #{order.id}: {error_message}",
111                type="admin_error"
112            )
113    except Exception as e:
114        logger.error(f"Ошибка уведомления администратора: {e}")

Сигналы для кеширования и оптимизации

 1from django.db.models.signals import post_save, post_delete
 2from django.dispatch import receiver
 3from django.core.cache import cache
 4from django.db.models import Count, Avg
 5from .models import Product, Category, Review
 6
 7@receiver(post_save, sender=Product)
 8def product_post_save(sender, instance, created, **kwargs):
 9    """Обновление кеша после изменения продукта"""
10    # Очищаем кеш продукта
11    cache.delete(f'product_{instance.id}')
12    cache.delete(f'product_slug_{instance.slug}')
13
14    # Очищаем кеш категории
15    if instance.category:
16        cache.delete(f'category_products_{instance.category.id}')
17        cache.delete(f'category_stats_{instance.category.id}')
18
19    # Очищаем общий кеш продуктов
20    cache.delete('all_products')
21    cache.delete('featured_products')
22
23    # Обновляем статистику категории
24    if instance.category:
25        self.update_category_stats(instance.category)
26
27@receiver(post_delete, sender=Product)
28def product_post_delete(sender, instance, **kwargs):
29    """Очистка кеша после удаления продукта"""
30    # Очищаем все связанные кеши
31    cache.delete(f'product_{instance.id}')
32    cache.delete(f'product_slug_{instance.slug}')
33
34    if instance.category:
35        cache.delete(f'category_products_{instance.category.id}')
36        cache.delete(f'category_stats_{instance.category.id}')
37        self.update_category_stats(instance.category)
38
39    cache.delete('all_products')
40    cache.delete('featured_products')
41
42@receiver(post_save, sender=Review)
43def review_post_save(sender, instance, created, **kwargs):
44    """Обновление рейтинга продукта после отзыва"""
45    if created:
46        # Обновляем средний рейтинг продукта
47        product = instance.product
48        avg_rating = product.reviews.aggregate(
49            avg_rating=Avg('rating')
50        )['avg_rating'] or 0
51
52        product.avg_rating = round(avg_rating, 2)
53        product.review_count = product.reviews.count()
54        product.save(update_fields=['avg_rating', 'review_count'])
55
56        # Очищаем кеш продукта
57        cache.delete(f'product_{product.id}')
58
59        # Обновляем статистику категории
60        if product.category:
61            self.update_category_stats(product.category)
62
63def update_category_stats(self, category):
64    """Обновление статистики категории"""
65    try:
66        # Вычисляем статистику
67        stats = Product.objects.filter(
68            category=category
69        ).aggregate(
70            total_products=Count('id'),
71            avg_price=Avg('price'),
72            total_reviews=Count('reviews')
73        )
74
75        # Сохраняем в кеш
76        cache.set(
77            f'category_stats_{category.id}',
78            stats,
79            3600  # 1 час
80        )
81
82    except Exception as e:
83        logger.error(f"Ошибка обновления статистики категории: {e}")
84
85@receiver(post_save, sender=Category)
86def category_post_save(sender, instance, **kwargs):
87    """Обновление кеша категории"""
88    cache.delete(f'category_{instance.id}')
89    cache.delete(f'category_slug_{instance.slug}')
90    cache.delete('all_categories')
91
92    # Обновляем статистику
93    self.update_category_stats(instance)

Отключение сигналов

 1from django.db.models.signals import post_save
 2from django.dispatch import receiver
 3from django.core.management.base import BaseCommand
 4
 5class Command(BaseCommand):
 6    help = 'Импорт данных с отключенными сигналами'
 7
 8    def handle(self, *args, **options):
 9        # Отключаем сигналы для массового импорта
10        from django.db.models.signals import post_save
11        from .signals import user_post_save
12
13        # Отключаем конкретный сигнал
14        post_save.disconnect(user_post_save, sender=User)
15
16        try:
17            # Выполняем импорт
18            self.import_users()
19        finally:
20            # Восстанавливаем сигнал
21            post_save.connect(user_post_save, sender=User)
22
23    def import_users(self):
24        """Импорт пользователей без сигналов"""
25        # Здесь логика импорта
26        pass
27
28# Альтернативный способ - использование контекстного менеджера
29from contextlib import contextmanager
30
31@contextmanager
32def disable_signals(*signals):
33    """Контекстный менеджер для отключения сигналов"""
34    disconnected = []
35    try:
36        for signal, receiver, sender in signals:
37            signal.disconnect(receiver, sender=sender)
38            disconnected.append((signal, receiver, sender))
39        yield
40    finally:
41        for signal, receiver, sender in disconnected:
42            signal.connect(receiver, sender=sender)
43
44# Использование
45with disable_signals(
46    (post_save, user_post_save, User),
47    (post_save, order_post_save, Order)
48):
49    # В этом блоке сигналы отключены
50    bulk_create_users()
51    bulk_create_orders()

Лучшие практики

  • Используй pre_save для валидации и изменения данных - данные еще не сохранены в БД
  • Используй post_save для действий после сохранения - отправка уведомлений, обновление кеша
  • Обрабатывай ошибки gracefully - не прерывай основной процесс сохранения
  • Используй транзакции для сложных операций - обеспечивай атомарность
  • Логируй важные события - для отладки и мониторинга
  • Очищай кеш после изменений - поддерживай актуальность данных
  • Отключай сигналы при массовых операциях - избегай лишних вызовов
  • Используй update_fields - избегай бесконечных циклов
  • Тестируй сигналы отдельно - изолируй логику сигналов
  • Документируй сложную логику - объясняй назначение каждого сигнала

FAQ

Q: Когда использовать pre_save vs post_save?
A: pre_save для изменения данных перед сохранением (валидация, форматирование), post_save для действий после сохранения (уведомления, обновление кеша, создание связанных объектов).

Q: Как избежать бесконечных циклов в сигналах?
A: Используй update_fields при сохранении, проверяй измененные поля, отключай сигналы при необходимости.

Q: Можно ли использовать сигналы в тестах?
A: Да, но лучше тестировать сигналы отдельно и использовать @override_settings для отключения в интеграционных тестах.

Q: Как отключить сигналы для массовых операций?
A: Используй signal.disconnect() перед операцией и signal.connect() после, или создай контекстный менеджер.

Q: Что делать, если сигнал вызывает ошибку?
A: Обрабатывай исключения в сигнале, логируй ошибки, не прерывай основной процесс сохранения.

Q: Как оптимизировать производительность сигналов?
A: Используй bulk операции, отключай сигналы при массовых операциях, кешируйте результаты, избегайте N+1 запросов.

Q: Можно ли использовать сигналы для валидации?
A: Да, но лучше использовать валидаторы моделей и форм. Сигналы подходят для бизнес-логики и автоматизации.