Шаг 03. Модели Project, Task и Comment

📁 Серия: Капстоун C ⏱️ ~45 мин 🎯 Сложность: Средняя
#models #ForeignKey #M2M #TextChoices #migrations

⚡ Кратко: что делаем на этом шаге

Цель: Создать приложения apps/projects/ и apps/tasks/. Написать модели Project (FK → User-owner, M2M → User-members), Task (FK → Project, FK → User-assignee, TextChoices для статуса и приоритета, soft-delete поле), Comment (FK → Task, FK → User). Применить миграции.

  • Файлы: apps/projects/models.py, apps/tasks/models.py
  • Команды: python manage.py makemigrations, python manage.py migrate
  • Результат: все таблицы созданы, python manage.py check — OK

🎯 Цель этапа

Описываем всю предметную область Task Manager в виде Django-моделей. После этого шага схема БД зафиксирована и следующие шаги (Admin, Serializers, ViewSets) будут работать с этими же классами.

💡 Схема данных:
  • Project — проект: владелец (FK→User), участники (M2M→User), активность
  • Task — задача внутри проекта: FK→Project, исполнитель (FK→User), статус, приоритет, срок, soft-delete (is_deleted)
  • Comment — комментарий к задаче: FK→Task, автор (FK→User), текст

После этого шага у нас будет

  • Два приложения: apps/projects/ и apps/tasks/
  • Все модели с правильными FK/M2M и on_delete
  • TextChoices для статуса и приоритета Task
  • Поле is_deleted для soft-delete (используем в этапе 11)
  • Миграции применены, таблицы в БД созданы

📄 Затрагиваемые файлы

ФайлДействиеОписание
apps/projects/apps.pyИзменитьname = "apps.projects"
apps/projects/models.pyЗаполнитьМодель Project
apps/tasks/apps.pyИзменитьname = "apps.tasks"
apps/tasks/models.pyЗаполнитьМодели Task и Comment
config/settings.pyИзменитьДобавить "apps.projects", "apps.tasks" в INSTALLED_APPS
apps/projects/migrations/0001_initial.pyСоздаётсяМиграция projects
apps/tasks/migrations/0001_initial.pyСоздаётсяМиграция tasks

🔨 Шаги

1. Создаём приложения projects и tasks

💻 Терминал
python manage.py startapp projects apps/projects
python manage.py startapp tasks apps/tasks

2. Правим apps.py для обоих приложений

📄 apps/projects/apps.py
# apps/projects/apps.py
from django.apps import AppConfig


class ProjectsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "apps.projects"
    verbose_name = "Проекты"
📄 apps/tasks/apps.py
# apps/tasks/apps.py
from django.apps import AppConfig


class TasksConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "apps.tasks"
    verbose_name = "Задачи"

3. Добавляем приложения в INSTALLED_APPS

📄 config/settings.py (изменить INSTALLED_APPS)
# config/settings.py — фрагмент INSTALLED_APPS
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # Сторонние
    "rest_framework",
    # Наши приложения
    "apps.users",
    "apps.projects",    # ← добавляем
    "apps.tasks",       # ← добавляем
]

4. Модель Project

📄 apps/projects/models.py
# apps/projects/models.py
from django.conf import settings
from django.db import models


class Project(models.Model):
    """
    Проект — контейнер для задач.

    У каждого проекта есть владелец (owner) и список участников (members).
    Владелец всегда имеет доступ; участники — по приглашению.
    """

    name = models.CharField("Название", max_length=200)
    description = models.TextField("Описание", blank=True)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="Владелец",
        on_delete=models.CASCADE,
        related_name="owned_projects",
    )
    members = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        verbose_name="Участники",
        related_name="projects",
        blank=True,
        help_text="Пользователи, имеющие доступ к проекту.",
    )
    is_active = models.BooleanField(
        "Активен",
        default=True,
        help_text="Неактивные проекты скрыты из API по умолчанию.",
    )
    created_at = models.DateTimeField("Создан", auto_now_add=True)
    updated_at = models.DateTimeField("Обновлён", auto_now=True)

    class Meta:
        verbose_name = "Проект"
        verbose_name_plural = "Проекты"
        ordering = ["-created_at"]

    def __str__(self) -> str:
        return self.name
💡 settings.AUTH_USER_MODEL вместо прямого импорта: Всегда используйте settings.AUTH_USER_MODEL (строка) или get_user_model() в ForeignKey и ManyToManyField — не from django.contrib.auth.models import User. Это гарантирует совместимость с кастомной моделью (нашей apps.users.User).

5. Модели Task и Comment

📄 apps/tasks/models.py
# apps/tasks/models.py
from django.conf import settings
from django.db import models


class Task(models.Model):
    """
    Задача внутри проекта.

    Поддерживает soft-delete (is_deleted=True) вместо физического удаления.
    Статус и приоритет — через TextChoices (современный Django-способ).
    """

    class Status(models.TextChoices):
        TODO = "todo", "К выполнению"
        IN_PROGRESS = "in_progress", "В работе"
        REVIEW = "review", "На проверке"
        DONE = "done", "Выполнено"
        CANCELLED = "cancelled", "Отменено"

    class Priority(models.TextChoices):
        LOW = "low", "Низкий"
        MEDIUM = "medium", "Средний"
        HIGH = "high", "Высокий"
        CRITICAL = "critical", "Критический"

    title = models.CharField("Название", max_length=300)
    description = models.TextField("Описание", blank=True)
    project = models.ForeignKey(
        "projects.Project",
        verbose_name="Проект",
        on_delete=models.CASCADE,
        related_name="tasks",
    )
    assignee = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="Исполнитель",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="assigned_tasks",
    )
    status = models.CharField(
        "Статус",
        max_length=20,
        choices=Status.choices,
        default=Status.TODO,
    )
    priority = models.CharField(
        "Приоритет",
        max_length=10,
        choices=Priority.choices,
        default=Priority.MEDIUM,
    )
    due_date = models.DateField(
        "Срок выполнения",
        null=True,
        blank=True,
    )
    # Soft-delete: задачи не удаляются физически из БД
    is_deleted = models.BooleanField(
        "Удалена",
        default=False,
        help_text="Soft-delete: True = задача скрыта, но не удалена из БД.",
    )
    created_at = models.DateTimeField("Создана", auto_now_add=True)
    updated_at = models.DateTimeField("Обновлена", auto_now=True)

    class Meta:
        verbose_name = "Задача"
        verbose_name_plural = "Задачи"
        ordering = ["-created_at"]
        # Составной индекс для типичных запросов: задачи проекта по статусу
        indexes = [
            models.Index(fields=["project", "status"]),
            models.Index(fields=["assignee", "status"]),
        ]

    def __str__(self) -> str:
        return f"[{self.get_status_display()}] {self.title}"


class Comment(models.Model):
    """
    Комментарий к задаче.

    Автор — FK на кастомного User. При удалении задачи — удаляются и комментарии (CASCADE).
    """

    task = models.ForeignKey(
        Task,
        verbose_name="Задача",
        on_delete=models.CASCADE,
        related_name="comments",
    )
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="Автор",
        on_delete=models.CASCADE,
        related_name="comments",
    )
    text = models.TextField("Текст комментария")
    created_at = models.DateTimeField("Создан", auto_now_add=True)
    updated_at = models.DateTimeField("Обновлён", auto_now=True)

    class Meta:
        verbose_name = "Комментарий"
        verbose_name_plural = "Комментарии"
        ordering = ["created_at"]

    def __str__(self) -> str:
        return f"Комментарий {self.author} к задаче #{self.task_id}"
💡 FK к "projects.Project" через строку: Задача ссылается на проект через строку "projects.Project" (app_label.ModelName), а не через прямой импорт. Это решает проблему циклических импортов между приложениями tasks и projects.

6. Создаём и применяем миграции

💻 Терминал
# Создаём миграции для обоих приложений
python manage.py makemigrations projects tasks

# Применяем
python manage.py migrate

Ожидаемый вывод makemigrations:

Migrations for 'projects':
  apps/projects/migrations/0001_initial.py
    - Create model Project
Migrations for 'tasks':
  apps/tasks/migrations/0001_initial.py
    - Create model Task
    - Create index tasks_task_project_id_...
    - Create model Comment

🧠 Объяснение логики

on_delete: CASCADE vs SET_NULL

Выбор on_delete — бизнес-решение:

  • Task → Project (CASCADE): если проект удалён — все его задачи теряют смысл. Удаляем вместе.
  • Task → assignee (SET_NULL): если пользователь удалён — задача остаётся, но без исполнителя (assignee=None). Задача не должна исчезать.
  • Comment → Task (CASCADE): комментарии без задачи бессмысленны. Удаляем вместе с задачей.
  • Project → owner (CASCADE): если владелец удалён — проект удаляется (жёсткая политика). В реальном проекте можно сделать SET_NULL + поле для нового владельца.

Soft-delete через is_deleted

Вместо физического удаления задач (DELETE из БД) мы помечаем их флагом is_deleted=True. Это даёт:

  • Возможность восстановления задачи
  • Историю изменений (аудит-трейл)
  • Корректную работу FK: комментарии к «удалённой» задаче не теряются

На шаге 11 мы добавим кастомный QuerySet, который автоматически фильтрует is_deleted=True.

Индексы в Meta

Два составных индекса по (project, status) и (assignee, status) ускорят самые частые запросы API: «задачи проекта X со статусом Y» и «задачи исполнителя Z по статусу». Django создаёт их автоматически через миграцию.

⚠️ TextChoices — max_length должен вмещать самое длинное значение: У Status самое длинное значение — "in_progress" (11 символов), у Priority"critical" (8 символов). Мы указали max_length=20 и max_length=10 соответственно с запасом.

✅ Проверка

1. Проверка миграций

💻 Терминал
python manage.py showmigrations projects tasks
Успех:
projects
 [X] 0001_initial
tasks
 [X] 0001_initial

2. Проверка через Django shell

💻 Django shell
from apps.users.models import User
from apps.projects.models import Project
from apps.tasks.models import Task, Comment

# Создаём тестовые данные
user = User.objects.filter(is_superuser=True).first()
project = Project.objects.create(name="Тестовый проект", owner=user)
project.members.add(user)
print(project)  # Тестовый проект

task = Task.objects.create(
    title="Первая задача",
    project=project,
    assignee=user,
    status=Task.Status.TODO,
    priority=Task.Priority.HIGH,
)
print(task)       # [К выполнению] Первая задача
print(task.get_status_display())   # К выполнению
print(task.get_priority_display()) # Высокий

comment = Comment.objects.create(task=task, author=user, text="Первый комментарий")
print(comment)    # Комментарий user@example.com к задаче #1

# Проверяем related_name
print(project.tasks.count())         # 1
print(task.comments.count())         # 1
print(user.owned_projects.count())   # 1
print(user.assigned_tasks.count())   # 1

Диагностика: если что-то пошло не так

  • django.db.utils.OperationalError: no such table: projects_project — не запустили migrate
  • ValueError: Field 'project' expected a number but got... — передаёте объект вместо pk или наоборот; используйте project=project (объект) или project_id=1 (pk)
  • Миграции не найдены — проверьте, что приложения добавлены в INSTALLED_APPS

➡️ Что дальше

На следующем шаге мы регистрируем все модели в Django Admin, настраиваем list_display, list_filter, search_fields и добавляем TaskInline в ProjectAdmin.

  • Готово: все модели, связи, индексы, миграции применены
  • Далее (шаг 04): apps/projects/admin.py, apps/tasks/admin.py — ModelAdmin + inline
  • После шага 04 можно управлять данными через Admin