Создание связей между моделями через класс ForeignKey

Курс по Django: https://stepik.org/a/183363

Архив проекта: lesson-9-coolsite.zip

Продолжаем совершенствовать наш учебный сайт и знакомиться с возможностями фреймворка Django. На этом занятии мы с вами добавим еще одну таблицу для категорий и свяжем ее с таблицей постов, так, чтобы каждая статья была соотнесена с той или иной категорией.

Я, думаю, нет необходимости разъяснять, для чего нужна отдельная таблица для категорий? Например, если мы в будущем захотим переименовать какой-либо раздел, то для этого достаточно будет поменять название только в одном месте таблицы и это не затронет тысячи записей из таблицы постов women. Есть много других преимуществ такого подхода. Вообще, разделение данных на несколько таблиц и установление между ними связей, называется нормализацией данных. Этому принципу всегда нужно следовать во избежание больших сложностей при использовании БД.

Итак, в нашем проекте, очевидно, нужно определить еще одну таблицу (модель) для категорий и связать ее с таблицей постов:

Для этого мы добавим еще одно поле cat_id в таблицу women, которое будет определено как внешний ключ и хранить идентификатор категории. А в таблице category определим два поля: идентификатор id и название раздела – name. Давайте выполним эту операцию с использованием ORM Django.

Фреймворк Djnago имеет три специальных класса для организации связей:

  • ForeignKey – для связей Many to One (поля отношений);
  • ManyToManyField – для связей Many to Many (многие ко многим);
  • OneToOneField – для связей One to One (один к одному).

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

https://djbook.ru/rel3.0/topics/db/models.html#relationships

А на данном занятии воспользуемся классом ForeignKey, который необходим для организации связей «многие к одному» или «одного ко многим» для отношений между постами и категориями. Почему именно этот тип связей? Дело в том, что каждой отдельной категории может соответствовать множество статей, или, наоборот, множеству статей – строго одна категория. Это и есть отношение Many to One.

Класс ForeignKey принимает два обязательных аргумента:

  • ссылка или строка класса модели, с которой происходит связывание (в нашем случае это класс Category – модели для категорий);
  • опция on_delete, накладывающая ограничения при удалении внешней записи (в нашем примере – это удаление из таблицы Category).

В свою очередь, опция on_delete может принимать следующие значения:

  • models.CASCADE – при удалении записи из первичной модели (у нас это таблица Category) происходит удаление всех записей из вторичной модели (Women), связанных с удаляемой категорией;
  • models.PROTECT – запрещает удаление записи из первичной модели, если она используется во вторичной (выдает исключение);
  • models.SET_NULL – при удалении записи первичной модели устанавливает значение foreign key в NULL у соответствующих записей вторичной модели;
  • models.SET_DEFAULT – то же самое, что и SET_NULL, только вместо значения NULL устанавливает значение по умолчанию, которое должно быть определено через класс ForeignKey;
  • models.SET() – то же самое, только устанавливает пользовательское значение;
  • models.DO_NOTHING – удаление записи в первичной модели не вызывает никаких действий у вторичных моделей.

Давайте, посмотрим на конкретном примере, как используется класс ForeignKey. Перейдем в файл women/models.py и определим еще одну модель для категорий:

class Category(models.Model):
    name = models.CharField(max_length=100, db_index=True)
 
    def __str__(self):
        return self.name

Здесь все вам должно быть уже знакомо, кроме вот этого параметра db_index. Он указывает СУБД индексировать данное поле, чтобы поиск по нему происходил быстрее. В результате, в таблице category будет два индексируемых поля: id и name.

Далее, пропишем дополнительное поле cat_id во вторичной модели Women:

cat = models.ForeignKey('Category', on_delete=models.PROTECT)

(суффикс _id Django добавит автоматически). Смотрите, мы класс первичной модели Category указали как строку, потому что модель Category в файле models.py записана после модели Women, поэтому, при попытке указать ссылку на этот класс, возникнет ошибка. Только поэтому класс указан как строка. Но вообще, можно записывать и так, и так. Следующий параметр мы определили через функцию PROTECT (это функция, а не константа), которая запрещает удаление категорий, используемых во вторичной модели.

Все, формально модели у нас определены и пришло время создать соответствующие таблицы в БД. Фактически, первую таблицу women нужно лишь модифицировать, добавив одно поле, а вторую таблицу создать целиком. Для этого нам надо сформировать новую миграцию командой:

python manage.py makemigrations

Но полноценно выполниться она не может. Я специально решил показать этот момент, чтобы вы лучше понимали процесс перестройки структуры таблиц в БД. Что здесь не так? Смотрите, у нас в таблице women должно добавиться новое поле cat_id, ссылающееся на запись первичной таблицы category. Но в таблице women уже есть записи и при добавлении этого поля оно оказывается для них пустым. СУБД запрещает такую операцию, так как это поле обязательно должно ссылаться на идентификатор записи из связанной таблицы. Отсюда и возникает эта проблема.

Как ее обойти? Давайте временно разрешим записывать в cat_id значение NULL. Это можно сделать через параметр null:

cat = models.ForeignKey('Category', on_delete=models.PROTECT, null=True)

Завершим предыдущую миграцию (выберем 2) и снова запустим команду:

python manage.py makemigrations

Теперь все прошло в штатном режиме и у нас сформировался второй файл миграции под номером 0002. Выполним миграции, внесем изменения непосредственно в БД с помощью уже известной нам команды:

python manage.py migrate

Видим, что ошибок никаких нет. Откроем программу SQLiteStudio и видим две наши таблицы: women_category и women_women. Причем, в таблице women_women появилось новое поле cat_id со значениями NULL.

Вообще, такие операции по внесению изменений в структуры ранее созданных таблиц БД, крайний шаг. Рекомендуется в самом начале проектирования сайта хорошо продумывать всю организацию данных и связей, а потом только работать с уже имеющимися таблицами, не внося существенных изменений. То, что я сделал – это лишь учебный пример, как в принципе это можно сделать. Но лучше этого избегать.

Все, таблицы сформированы и давайте теперь добавим категории в новую таблицу. Перейдем в терминал, выполним команду:

python manage.py shell

импортируем наши модели в консоль фреймворка:

from women.models import *

и создадим две записи в таблице category:

Category.objects.create(name='Актрисы')
Category.objects.create(name='Певицы')

Затем, у всех записей таблицы women установим поле cat_id равным 1 (певицы). Сначала выбираем все записи:

w_list = Women.objects.all()

и обновляем их:

w_list.update(cat_id=1)

Классы моделей и их экземпляры

Я решил здесь немного подробнее объяснить, как правильно следует воспринимать классы моделей и их экземпляры. Смотрите, когда мы определяем, например, модель Women, то набор атрибутов класса – это, фактически, ссылки на экземпляры классов CharField, TextField и так далее. Мы в этом можем легко убедиться, если в консоли выполним команду:

Women.title

Увидим, что это ссылка на некий объект класса, а не текстовое поле с конкретной информацией. Вот это важный момент: атрибуты на уровне класса модели определяют набор и тип полей и не содержат данные записей.

Затем, Django, используя набор этих атрибутов, имеет возможность формировать структуру таблицы через механизм миграций. Например, title – это ссылка на экземпляр класса CharField и фреймворк создает текстовое поле в таблице women. И так для всех представленных атрибутов модели.

Но, когда мы формируем экземпляр класса модели, например, так:

w1 = Women(title='t1', content='c1', cat_id=1)

то внутри этого экземпляра автоматически формируются свойства с теми же именами, что и атрибуты класса модели Women. И это новые локальные свойства, отличные от атрибутов модели, содержащие конкретную информацию текущей записи:

Причем, свойство cat, как внешний ключ модели, здесь уже будет ссылаться на экземпляр класса модели Category.

Но как произошли такие метаморфозы при создании экземпляра модели Women? Мы же этого нигде не прописывали? Все просто. Это сделал конструктор базового класса Model, от которого наследуются модели в Django. Именно этот конструктор выполняет создание локальных свойств в экземплярах классов моделей и наполняет их конкретным содержимым. А для внешних ключей создает экземпляры классов, с которыми они связаны. Поэтому, когда мы работаем с конкретными записями, то есть, экземплярами моделей, то здесь у нас атрибуты приобретают иное поведение, так как являются ссылками на конкретные данные.

Чтобы окончательно убедиться, что все именно так, достаточно в консоли выполнить команды:

type(w1.title)
type(w1.cat)

Также конструктор автоматически добавляет еще такие локальные свойства: id (pk) – идентификатор записи и cat_id – идентификатор рубрики (связи). Причем, поля id, time_create, time_update, например, пока принимают значение None:

print(w1.id, w1.time_create, w1.time_update)

Это происходит до тех пор, пока запись не будет добавлена непосредственно в таблицу БД. И, действительно, пока у нас не выполнено ни одного SQL-запроса:

from django.db import connection
connection.queries

Но, если обратиться к объекту cat и вывести его имя:

w1.cat.name

то увидим значение «Актрисы». Как оно здесь было получено? Опять же никакой магии, Django обратился к таблице category и прочитал из нее имя рубрики по указанной id записи. И, действительно, теперь в списке мы видим выполнение SQL-запроса:

SELECT "women_category"."id", "women_category"."name" FROM "women_category" WHERE "women_category"."id" = 1 LIMIT 21

То есть, как только нам понадобились данные, которых не было и нужно было брать их из таблицы, то фреймворк это сделал на автомате и вернул нам имя категории. Вот так это все работает. Пока данные не нужны, обращений к БД нет, как только что-то понадобилось, Django только в этот момент обращается к БД и берет нужные данные.

Или, такой пример. Прочитаем из таблицы women запись с id=2:

w2 = Women.objects.get(pk=2)

Здесь выполнен только один SQL-запрос для выбора одной указанной записи. Выведем название категории:

w2.cat

И только в этот момент Django сделал еще один запрос для получения этого имени.

Надеюсь, вы теперь хорошо понимаете роль атрибутов в классах моделей и соответствующих локальных свойств в их экземплярах.

Отображение категорий в шаблоне

После того, как мы создали таблицы и обновили в них данные, сделаем отображение списка категорий в шаблоне base.html. Для этого, в левом сайдбаре, там где идет список ul, заменим пункты на следующую конструкцию (до строки <li class="share">):

{% if cat_selected == 0 %}
                   <li class="selected">Все категории</li>
{% else %}
                   <li><a href="{% url 'home' %}">Все категории</a></li>
{% endif %}
 
{% for c in cats %}
         {% if c.pk == cat_selected %}
                   <li class="selected">{{c.name}}</li>
         {% else %}
                   <li><a href="{{ c.get_absolute_url }}">{{c.name}}</a></li>
         {% endif %}
{% endfor %}

Как это работает? Вначале мы должны отобразить пункт «Все категории». Если он выбран, то ссылки на него не должно быть, если не выбран, то появляется ссылка. Чтобы сделать такой функционал, я добавил еще одну переменную cat_selected, которая принимает значение 0, если выбран первый пункт «Все категории» и значения от 1 и выше (идентификаторы рубрик), при выборе других категорий. Далее, идет тег for для перебора категорий таблицы category. Если параметр cat_selected оказывается равным идентификатору рубрики, то она отображается без ссылки, иначе, идет как ссылка.

Саму ссылку мы формируем с помощью уже знакомого метода get_absolute_url, которую прописываем в модели Category, следующим образом:

    def get_absolute_url(self):
        return reverse('category', kwargs={'cat_id': self.pk})

Она формирует URL по имени маршрута 'category'. Он определяется в файле women/urls.py как:

path('category/<int:cat_id>/', show_category, name='category'),

Осталось записать функцию представления show_category в файле women/views.py:

def show_category(request, cat_id):
    return HttpResponse(f"Отображение категории с id = {cat_id}")

И немного поменять функцию index:

def index(request):
    posts = Women.objects.all()
    cats = Category.objects.all()
 
    context = {
        'posts': posts,
        'cats': cats,
        'menu': menu,
        'title': 'Главная страница',
        'cat_selected': 0,
    }
 
    return render(request, 'women/index.html', context=context)

Здесь мы дополнительно считываем категории из таблицы category и передаем их через словарь context шаблону index.html. Также в этом словаре добавили ключ 'cat_selected' со значением 0 – выбраны все категории.

После обновления главной страницы видим, что категории и ссылки были сформированы успешно. Мало того, у нас почти все готово, чтобы отображать статьи по отдельным рубрикам. Для этого скопируем содержимое функции index в функцию show_category и внесем незначительные изменения:

def show_category(request, cat_id):
    posts = Women.objects.filter(cat_id=cat_id)
    cats = Category.objects.all()
 
    context = {
        'posts': posts,
        'cats': cats,
        'menu': menu,
        'title': 'Главная страница',
        'cat_selected': cat_id,
    }
 
    return render(request, 'women/index.html', context=context)

Здесь идет выборка уже не всех постов, а только тех, что относятся к текущей категории и параметр 'cat_selected' принимает значение не 0, а cat_id – идентификатор выбранной рубрики. Все, теперь можем переходить по категориям и видеть соответствующие страницы.

Взыскательный зритель сразу отметит, что функции index и show_category нарушают принцип DRY – не повторяйся. Мы этот момент поправим чуть позже, когда вместо функций представления будем использовать классы, а также пользовательские теги.

Еще один момент, который следует учесть – генерация исключения для пустой категории. Будем полагать, если в категории нет записей, то это эквивалентно отсутствию страницы. Для этого в функции show_category пропишем проверку:

def show_category(request, cat_id):
    posts = Women.objects.filter(cat_id=cat_id)
    if len(posts) == 0:
        raise Http404()
    ...

Все, теперь при переходе на пустые категории будем получать исключение 404 – страница не найдена.

Добавим еще у каждой статьи вывод названия категории и время ее последнего редактирования. В шаблоне index.html перед заголовком пропишем строчки:

<li><div class="article-panel">
         <p class="first">Категория: {{p.cat}}</p>
         <p class="last">Дата: {{p.time_update|date:"d-m-Y H:i:s"}}</p>
</div>

Смотрите, обращаясь к атрибуту cat (а не cat_id) мы получаем его строковое представление то, которое определили в модели Category через магический метод __str__. То есть, cat – это объект класса Category и как вариант мы можем отображать название категории и через его атрибут name:

<p class="first">Категория: {{p.cat.name}}</p>

Для формирования нужного формата времени используем фильтр date с параметрами: день, месяц, год, часы, минуты, секунды. Теперь перед каждой статьей отображается ее категория и время последнего редактирования.

Курс по Django: https://stepik.org/a/183363

Видео по теме