🏠 Домашнее задание 21 — Урок 45

← К оглавлению урока

⚡ Суть задания

Реализовать сигналы Django для задач (Task): при смене статуса или закрытии задачи — отправить email-уведомление владельцу задачи через ConsoleBackend. Добавить защиту от повторной отправки при последовательных изменениях.

  • Сигнал: post_save на модель Task
  • Условие: статус изменился ИЛИ задача закрыта
  • Email-бэкенд: ConsoleBackend (вывод в консоль)
  • Защита от дублей: хранить предыдущий статус в __init__ или через pre_save

Задание из LMS (Python Advanced: Домашнее задание 21)

Задание 1:
Реализуйте сигналы на закрытие и перевода статусов у главных задач. Если задача переходит в новый статус, или закрывается — тот, кому принадлежит эта задача должен получать Email уведомление на свою почту.

Используйте Базовый тестовый Django сервер, который будет выводить шаблон сообщения в консоль.

Добавить проверку, чтобы уведомление не отправлялось повторно при последовательных изменения статуса, если это нежелательно.

1. Подготовка окружения

1.1 Активация виртуального окружения

# Windows PowerShell
cd your_project
python -m venv venv
venv\Scripts\Activate.ps1

# Установить зависимости (если requirements.txt есть)
pip install django djangorestframework

1.2 Убедиться, что проект работает

python manage.py check
python manage.py migrate

1.3 Структура файлов для задания

Предполагаем, что есть приложение tasks с моделью Task. Нам нужно:

tasks/
    __init__.py
    apps.py        ← изменить: добавить ready()
    models.py      ← изменить: добавить поле status, is_closed, owner
    signals.py     ← создать: обработчик сигнала
    views.py
    ...
settings.py        ← изменить: добавить EMAIL_BACKEND

2. Шаг 1 — Модель Task

Связь с теорией: модель — источник событий, её save() запускает сигналы.

# tasks/models.py
from django.db import models
from django.contrib.auth.models import User


class Task(models.Model):
    STATUS_CHOICES = [
        ('new', 'Новая'),
        ('in_progress', 'В работе'),
        ('done', 'Выполнена'),
        ('closed', 'Закрыта'),
    ]

    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new')
    is_closed = models.BooleanField(default=False)
    owner = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='tasks',
        null=True,
        blank=True,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f'Task #{self.pk}: {self.title} [{self.status}]'

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Сохраняем начальный статус для сравнения в сигнале
        self._original_status = self.status
        self._original_is_closed = self.is_closed

Логика __init__: при загрузке объекта из БД запоминаем текущий статус в атрибутах экземпляра. После вызова .save() в обработчике post_save можно сравнить instance.status и instance._original_status, чтобы понять — изменился ли статус.

3. Шаг 2 — Настройка EMAIL_BACKEND

Связь с теорией (email) и примером 3.

# settings.py — добавить строку
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

ConsoleBackend — тестовый бэкенд. Все письма будут выводиться в консоль сервера, а не отправляться по-настоящему. Это означает: при запуске manage.py runserver в терминале вы увидите полный текст письма с заголовками.

4. Шаг 3 — Обработчик сигнала (signals.py)

Связь с теорией (обработчики) и примером 3.

# tasks/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.mail import send_mail

from .models import Task


@receiver(post_save, sender=Task)
def notify_owner_on_status_change(sender, instance, created, **kwargs):
    """
    Отправляет email владельцу задачи при:
    - создании задачи (created=True)
    - изменении статуса
    - закрытии задачи (is_closed=True)

    Защита от дублей: сравниваем текущий статус с _original_status.
    """
    # Проверяем, есть ли владелец и его email
    if not instance.owner or not instance.owner.email:
        return  # Некому отправлять

    status_changed = instance.status != instance._original_status
    just_closed = instance.is_closed and not instance._original_is_closed

    # Отправляем только если что-то реально изменилось
    if not created and not status_changed and not just_closed:
        return  # Статус не изменился, уведомление не нужно

    # Формируем тему и тело письма
    if just_closed:
        subject = f'Task #{instance.pk} closed'
        message = (
            f'Your task "{instance.title}" has been closed.\n'
            f'Final status: {instance.get_status_display()}'
        )
    elif created:
        subject = f'Task #{instance.pk} created'
        message = (
            f'Your task "{instance.title}" has been created.\n'
            f'Status: {instance.get_status_display()}'
        )
    else:
        subject = f'Task #{instance.pk} status changed'
        message = (
            f'Task "{instance.title}" status changed:\n'
            f'{instance._original_status} → {instance.status} '
            f'({instance.get_status_display()})'
        )

    send_mail(
        subject=subject,
        message=message,
        from_email='noreply@taskmanager.com',
        recipient_list=[instance.owner.email],
        fail_silently=True,  # Не ронять сервер при ошибке email
    )

    # Обновляем _original_status после отправки
    # (чтобы следующий save() снова мог сравнивать)
    instance._original_status = instance.status
    instance._original_is_closed = instance.is_closed

5. Шаг 4 — Регистрация сигнала в apps.py

Связь с теорией (apps.py).

# tasks/apps.py
from django.apps import AppConfig


class TasksConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'tasks'

    def ready(self):
        import tasks.signals  # Регистрируем обработчики сигналов

Убедитесь, что в settings.pyINSTALLED_APPS приложение указано как 'tasks' (Django 3.2+ найдёт TasksConfig автоматически) или явно 'tasks.apps.TasksConfig'.

6. Шаг 5 — Создание и применение миграций

python manage.py makemigrations tasks
python manage.py migrate

7. Шаг 6 — Проверка в консоли

# Запускаем сервер разработки
python manage.py runserver

В другом терминале открываем Django shell:

python manage.py shell
# В Django shell
from django.contrib.auth.models import User
from tasks.models import Task

# Создаём пользователя с email
user = User.objects.create_user('testuser', 'test@example.com', 'password')

# Создаём задачу — должен появиться email в консоли сервера
task = Task.objects.create(title='Test Task', owner=user)

# Меняем статус — должен появиться email с изменением
task.status = 'in_progress'
task.save()

# Снова меняем статус
task.status = 'done'
task.save()

# Закрываем задачу
task.is_closed = True
task.save()

# Сохраняем без изменений — уведомление НЕ должно отправиться
task.save()  # _original_status == status → return

Ожидаемый вывод в консоли runserver при создании задачи:

Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: Task #1 created
From: noreply@taskmanager.com
To: test@example.com
Date: ...

Your task "Test Task" has been created.
Status: Новая

При изменении статуса:

Subject: Task #1 status changed
...
Task "Test Task" status changed:
new → in_progress (В работе)

8. Проверка в VS Code

8.1 Запуск через терминал

  1. Откройте проект в VS Code
  2. Ctrl+` — открыть встроенный терминал
  3. Активируйте venv: venv\Scripts\Activate.ps1 (Windows)
  4. Запустите сервер: python manage.py runserver
  5. В новом терминале (Ctrl+Shift+5): python manage.py shell
  6. Выполняйте команды из шага 6 — наблюдайте email в первом терминале

8.2 Отладка через F5 (launch.json)

Создайте файл .vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Django",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/manage.py",
      "args": ["runserver", "--noreload"],
      "django": true,
      "justMyCode": true
    }
  ]
}

Параметр --noreload важен при отладке — без него Django перезапускает процесс при изменении файлов, что мешает точкам останова.

8.3 Точки останова в signals.py

  1. Откройте tasks/signals.py
  2. Кликните в левое поле строки с status_changed = ... — красная точка (breakpoint)
  3. Нажмите F5 — запустится Django с отладчиком
  4. В другом терминале: python manage.py shell → создайте Task
  5. VS Code остановится на точке останова — в панели Variables увидите instance, created, status_changed
  6. F10 — шаг вперёд, F11 — войти в функцию, F5 — продолжить до следующей точки

9. Связь с материалами урока

Элемент задания Где в уроке
Декоратор @receiver(post_save) Теория: обработчики сигналов
AppConfig.ready() для регистрации Теория: регистрация в apps.py
EMAIL_BACKEND = console Теория: настройка email
Параметр created Теория: параметры post_save
send_mail() Примеры: уведомление администратора
Защита от повтора через _original_status Старый vs Новый: защита от raw
Двойная отправка при ошибке регистрации Ошибки: дублирование обработчика

10. Критерии выполнения

  • При создании задачи в консоли сервера появляется email с темой «Task #N created»
  • При смене статуса — email с темой «Task #N status changed» и обоими статусами
  • При закрытии (is_closed=True) — email с темой «Task #N closed»
  • При повторном .save() без изменения статуса — email НЕ отправляется
  • Сигналы зарегистрированы в apps.py ready(), а не в models.py
  • EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' в settings.py