← Назад к вопросам

Как с помощью сигналов отслеживать изменения Many-to-Many отношений?

2.0 Middle🔥 201 комментариев
#Django

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Отслеживание Many-to-Many отношений с помощью Django Signals

Many-to-Many отношения не создают объект, они просто добавляют/удаляют записи в таблице связей. Django signals позволяют отслеживать эти изменения и выполнять действия когда связь добавляется или удаляется.

1. Базовое понимание M2M в Django

from django.db import models

class Student(models.Model):
    name = models.CharField(max_length=100)
    courses = models.ManyToManyField("Course", through="Enrollment")
    
    def __str__(self):
        return self.name

class Course(models.Model):
    name = models.CharField(max_length=100)
    
    def __str__(self):
        return self.name

class Enrollment(models.Model):
    """Промежуточная таблица (through model)"""
    student = models.ForeignKey(Student, on_delete=models.CASCADE)
    course = models.ForeignKey(Course, on_delete=models.CASCADE)
    enrolled_at = models.DateTimeField(auto_now_add=True)
    grade = models.CharField(max_length=2, null=True, blank=True)
    
    class Meta:
        unique_together = ("student", "course")

# Использование M2M
student = Student.objects.get(id=1)
course = Course.objects.get(id=1)

# Добавить связь
student.courses.add(course)

# Удалить связь
student.courses.remove(course)

# Очистить все связи
student.courses.clear()

# Изменить связи
student.courses.set([course1, course2])  # Заменяет все связи

2. Сигналы для M2M (m2m_changed)

m2m_changed срабатывает когда меняется M2M отношение:

from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django.db import models

class Student(models.Model):
    name = models.CharField(max_length=100)
    courses = models.ManyToManyField("Course")

class Course(models.Model):
    name = models.CharField(max_length=100)
    students_count = models.IntegerField(default=0)

@receiver(m2m_changed, sender=Student.courses.through)
def on_student_courses_changed(sender, instance, action, reverse, model, pk_set, **kwargs):
    """
    Срабатывает когда студент добавляется/удаляется из курса
    
    Args:
        sender: Промежуточная таблица (StudentCourses)
        instance: Объект который был изменён (Student или Course)
        action: "pre_add", "post_add", "pre_remove", "post_remove", "pre_clear", "post_clear"
        reverse: True если изменение было с обратной стороны
        model: Модель которая была добавлена/удалена
        pk_set: Set primary keys которые были добавлены/удалены
    """
    
    if action == "post_add":
        print(f"Course(s) {pk_set} added to {instance.name}")
        # Пересчитать количество студентов в курсе
        for course_id in pk_set:
            course = Course.objects.get(id=course_id)
            course.students_count = course.student_set.count()
            course.save()
    
    elif action == "post_remove":
        print(f"Course(s) {pk_set} removed from {instance.name}")
        # Пересчитать количество студентов
        for course_id in pk_set:
            course = Course.objects.get(id=course_id)
            course.students_count = course.student_set.count()
            course.save()
    
    elif action == "post_clear":
        print(f"All courses cleared from {instance.name}")
        # Пересчитать для всех курсов
        Course.objects.all().update(students_count=0)

3. Параметры action в m2m_changed

from django.db.models.signals import m2m_changed
from django.dispatch import receiver

@receiver(m2m_changed, sender="app.Student.courses")
def handle_m2m_change(sender, instance, action, pk_set, **kwargs):
    
    if action == "pre_add":
        # ДО добавления связей
        # Можно проверить валидность
        print(f"About to add courses: {pk_set}")
        # Проверка permissions
        if not instance.can_enroll():
            raise ValueError("Student cannot enroll")
    
    elif action == "post_add":
        # ПОСЛЕ добавления
        print(f"Courses {pk_set} added to {instance}")
        # Отправить notification
        send_enrollment_email(instance, pk_set)
    
    elif action == "pre_remove":
        # ДО удаления
        print(f"About to remove courses: {pk_set}")
    
    elif action == "post_remove":
        # ПОСЛЕ удаления
        print(f"Courses {pk_set} removed from {instance}")
        # Очистить данные связанные с этим курсом
        for course_id in pk_set:
            clear_course_data(instance, course_id)
    
    elif action == "pre_clear":
        # ДО очистки всех связей
        print(f"About to clear all courses from {instance}")
    
    elif action == "post_clear":
        # ПОСЛЕ очистки
        print(f"All courses cleared from {instance}")
        reset_student_progress(instance)

4. Reverse M2M отношения

Можно слушать изменения с обеих сторон отношения:

from django.db.models.signals import m2m_changed
from django.dispatch import receiver

class Student(models.Model):
    name = models.CharField(max_length=100)
    courses = models.ManyToManyField(
        "Course",
        related_name="students"  # Для reverse access
    )

class Course(models.Model):
    name = models.CharField(max_length=100)

# Слушаем добавление студента к курсу
@receiver(m2m_changed, sender=Student.courses.through)
def on_courses_changed(sender, instance, action, reverse, model, pk_set, **kwargs):
    """
    reverse=False → изменение происходит со стороны Student
    reverse=True  → изменение происходит со стороны Course (course.students.add())
    """
    
    if reverse:
        # Изменение со стороны Course
        print(f"Course (id={instance.id}) students changed")
        # instance это Course
        if action == "post_add":
            # Студенты добавлены к курсу
            for student_id in pk_set:
                student = Student.objects.get(id=student_id)
                print(f"Student {student.name} added to course {instance.name}")
    else:
        # Изменение со стороны Student
        print(f"Student (id={instance.id}) courses changed")
        # instance это Student
        if action == "post_add":
            # Курсы добавлены к студенту
            for course_id in pk_set:
                course = Course.objects.get(id=course_id)
                print(f"Course {course.name} added to student {instance.name}")

# Примеры
student = Student.objects.get(id=1)
course = Course.objects.get(id=1)

# reverse=False (со стороны Student)
student.courses.add(course)  # Сигнал: reverse=False, instance=student

# reverse=True (со стороны Course)
course.students.add(student)  # Сигнал: reverse=True, instance=course

5. Практический пример: Отслеживание изменений

from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django.utils import timezone
from django.core.mail import send_mail
from typing import Set
import logging

logger = logging.getLogger(__name__)

class Student(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField()
    courses = models.ManyToManyField("Course", through="Enrollment")
    last_enrollment_change = models.DateTimeField(auto_now=True)

class Course(models.Model):
    name = models.CharField(max_length=100)
    max_students = models.IntegerField(default=30)
    students_count = models.IntegerField(default=0)

class Enrollment(models.Model):
    student = models.ForeignKey(Student, on_delete=models.CASCADE)
    course = models.ForeignKey(Course, on_delete=models.CASCADE)
    enrolled_at = models.DateTimeField(auto_now_add=True)
    status = models.CharField(
        max_length=20,
        choices=[("active", "Active"), ("completed", "Completed"), ("dropped", "Dropped")],
        default="active"
    )

@receiver(m2m_changed, sender=Enrollment)
def on_enrollment_changed(sender, instance, action, pk_set: Set[int], reverse, **kwargs):
    """
    Отслеживаем добавление/удаление студентов из курсов
    """
    
    if action == "pre_add":
        # Валидация ДО добавления
        if reverse:
            # course.students.add(student)
            course = instance
            if course.students_count >= course.max_students:
                logger.warning(f"Course {course.id} is full")
                raise ValueError(f"Course {course.name} has reached maximum capacity")
        else:
            # student.courses.add(course)
            pass  # Валидация со стороны студента
    
    elif action == "post_add":
        # ДЕЙСТВИя ПОСЛЕ добавления
        if reverse:
            # course.students.add(student_ids)
            course = instance
            for student_id in pk_set:
                student = Student.objects.get(id=student_id)
                
                # Логирование
                logger.info(f"Student {student.name} enrolled in {course.name}")
                
                # Обновить счётчик
                course.students_count = course.student_set.count()
                course.save()
                
                # Отправить email
                send_mail(
                    subject=f"Enrollment Confirmation - {course.name}",
                    message=f"You have been enrolled in {course.name}",
                    from_email="noreply@university.com",
                    recipient_list=[student.email],
                )
                
                # Создать начальную запись в logbook
                StudentActivityLog.objects.create(
                    student=student,
                    course=course,
                    action="enrolled",
                    timestamp=timezone.now()
                )
        else:
            # student.courses.add(course_ids)
            student = instance
            logger.info(f"Student {student.name} added {len(pk_set)} courses")
    
    elif action == "post_remove":
        # ДЕЙСТВИЯ после удаления
        if reverse:
            # course.students.remove(student_ids)
            course = instance
            for student_id in pk_set:
                student = Student.objects.get(id=student_id)
                logger.info(f"Student {student.name} removed from {course.name}")
                
                # Обновить счётчик
                course.students_count = course.student_set.count()
                course.save()
                
                # Логирование
                StudentActivityLog.objects.create(
                    student=student,
                    course=course,
                    action="dropped",
                    timestamp=timezone.now()
                )
    
    elif action == "post_clear":
        # ДЕЙСТВИЯ после очистки всех связей
        if reverse:
            # course.students.clear()
            course = instance
            logger.info(f"All students removed from {course.name}")
            course.students_count = 0
            course.save()
        else:
            # student.courses.clear()
            student = instance
            logger.info(f"All courses removed from {student.name}")

class StudentActivityLog(models.Model):
    student = models.ForeignKey(Student, on_delete=models.CASCADE)
    course = models.ForeignKey(Course, on_delete=models.CASCADE)
    action = models.CharField(max_length=50)
    timestamp = models.DateTimeField()

6. Регистрация сигналов в apps.py

from django.apps import AppConfig

class MyAppConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "myapp"
    
    def ready(self):
        # Импортируем сигналы когда приложение загружается
        import myapp.signals  # Это файл где определены @receiver сигналы

Всегда импортируй сигналы в ready() метод, иначе они не будут зарегистрированы!

7. Тестирование M2M сигналов

from django.test import TestCase
from django.db.models.signals import m2m_changed
from unittest.mock import patch

class StudentCourseSignalTests(TestCase):
    def setUp(self):
        self.student = Student.objects.create(name="John", email="john@example.com")
        self.course = Course.objects.create(name="Python 101", max_students=30)
    
    def test_course_added_signal(self):
        """Тест что сигнал срабатывает когда курс добавляется"""
        with patch("myapp.signals.send_mail") as mock_send_mail:
            self.student.courses.add(self.course)
            
            # Проверяем что email был отправлен
            mock_send_mail.assert_called_once()
            
            # Проверяем что счётчик обновился
            self.course.refresh_from_db()
            self.assertEqual(self.course.students_count, 1)
    
    def test_max_students_validation(self):
        """Тест что не можем добавить студента если курс полный"""
        course = Course.objects.create(
            name="Full Course",
            max_students=1,
            students_count=1
        )
        
        with self.assertRaises(ValueError):
            self.student.courses.add(course)
    
    def test_course_removed_signal(self):
        """Тест что логируется когда студент удаляется из курса"""
        self.student.courses.add(self.course)
        
        with patch("myapp.signals.logger") as mock_logger:
            self.student.courses.remove(self.course)
            
            # Проверяем логирование
            mock_logger.info.assert_called()
            
            # Проверяем что счётчик обновился
            self.course.refresh_from_db()
            self.assertEqual(self.course.students_count, 0)

8. Оптимизация: Отключение сигналов при bulk операциях

from django.db.models.signals import m2m_changed
from django.dispatch import receiver

class BulkEnrollmentManager:
    @staticmethod
    def bulk_enroll_students(course, student_ids):
        """Добавить много студентов без срабатывания сигналов каждый раз"""
        # Отключить сигналы
        m2m_changed.disconnect(on_enrollment_changed, sender=Enrollment)
        
        try:
            # Выполнить bulk операцию
            for student_id in student_ids:
                course.students.add(student_id)
            
            # Ручно обновить счётчик один раз
            course.students_count = course.student_set.count()
            course.save()
        finally:
            # Включить сигналы обратно
            m2m_changed.connect(on_enrollment_changed, sender=Enrollment)

Итоговый паттерн

# 1. Определить модели с M2M
class Student(models.Model):
    courses = models.ManyToManyField(Course)

# 2. Создать сигнал обработчик
@receiver(m2m_changed, sender=Student.courses.through)
def on_student_courses_changed(sender, instance, action, pk_set, **kwargs):
    if action == "post_add":
        # Do something when courses are added
        pass

# 3. Зарегистрировать в apps.py
class MyAppConfig(AppConfig):
    def ready(self):
        import myapp.signals

# 4. Тестировать с mock
def test_m2m_signal():
    with patch("signals.send_mail"):
        student.courses.add(course)

Ключевые моменты:

  • Используй post_add/post_remove для действий ПОСЛЕ изменения
  • Используй pre_add/pre_remove для валидации ДО изменения
  • Проверяй reverse параметр для определения стороны изменения
  • Всегда регистрируй сигналы в apps.py ready()
  • Тестируй с mock для изоляции
  • При bulk операциях отключай сигналы для оптимизации
Как с помощью сигналов отслеживать изменения Many-to-Many отношений? | PrepBro