← Назад к вопросам
Как с помощью сигналов отслеживать изменения 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 операциях отключай сигналы для оптимизации