Фреймворк для сайтов

Django поставляется с опциональным фреймворком для поддержки нескольких сайтов. Это позволяет держать некоторые объекты и функциональность в одном месте в то же время разделяя по сайтам, используя разные доменные имена и названия Django-сайтов.

Если в рамках одной установки Django требуется разрабатывать более чем один сайт с отличающейся функциональностью, то как раз для этого случая и был разработан фреймворк для сайтов.

The sites framework is mainly based on this model:

class models.Site

Модель для хранения атрибутов domain и name сайта.

domain

Доменное имя, ассоциированное с данным сайтом. Например www.example.com.

name

Название сайта.

Настройка SITE_ID указывает ID объекта Site в базе данных, который связан с текущими настройками и установленным проектом. Если эта настройка не указана, функция get_current_site() попытается получить текущий сайт, сравнивая domain с именем хоста, которое возвращает метод request.get_host().

How you use this is up to you, but Django uses it in a couple of ways automatically via a couple of conventions.

Пример использования

Для наглядности продемонстрируем сей механизм на нескольких примерах.

Связь контента с несколькими сайтами

The LJWorld.com and Lawrence.com sites are operated by the same news organization – the Lawrence Journal-World newspaper in Lawrence, Kansas. LJWorld.com focused on news, while Lawrence.com focused on local entertainment. But sometimes editors wanted to publish an article on both sites.

Решение в лоб - заставлять контент-менеджеров публиковать статьи дважды: и в LJWorld.com, и в Lawrence.com. Это неудобно не только для людей, но и для железа - придётся хранить в БД 2 одинаковых записи.

A better solution removes the content duplication: Both sites use the same article database, and an article is associated with one or more sites. In Django model terminology, that’s represented by a ManyToManyField in the Article model:

from django.contrib.sites.models import Site
from django.db import models

class Article(models.Model):
    headline = models.CharField(max_length=200)
    # ...
    sites = models.ManyToManyField(Site)

Это решение достаточно красиво:

  • Позволяет редактировать контент двух сайтов в одном интерфейсе (админке Django).

  • Позволяет избежать избыточности в плане хранения записей в БД.

  • It lets the site developers use the same Django view code for both sites. The view code that displays a given story checks to make sure the requested story is on the current site. It looks something like this:

    from django.contrib.sites.shortcuts import get_current_site
    
    def article_detail(request, article_id):
        try:
            a = Article.objects.get(id=article_id, sites__id=get_current_site(request).id)
        except Article.DoesNotExist:
            raise Http404("Article does not exist on this site")
        # ...
    

Связь контента с одним сайтом

Кроме того вы можете связать свои модели с Site через отношение один-ко-многим, используя ForeignKey.

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

from django.contrib.sites.models import Site
from django.db import models

class Article(models.Model):
    headline = models.CharField(max_length=200)
    # ...
    site = models.ForeignKey(Site, on_delete=models.CASCADE)

Она имеет преимущества, описанные выше.

Получение значения текущего сайта в представлении

Вы можете использовать фреймворк для построения сайтов в представлениях для выполнения конкретных вещей, необходимых лишь для текущего, например:

from django.conf import settings

def my_view(request):
    if settings.SITE_ID == 3:
        # Do something.
        pass
    else:
        # Do something else.
        pass

Конечно, это ужачный пример завязки на ID сайта который подходит только для мелких исправлений, которые надо выполнить быстро. Гораздо лучше проверять доменное имя:

from django.contrib.sites.shortcuts import get_current_site

def my_view(request):
    current_site = get_current_site(request)
    if current_site.domain == 'foo.com':
        # Do something
        pass
    else:
        # Do something else.
        pass

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

Если у вас нет доступа к объекту запроса, можно получить текущий сайт через метод get_current() класса Site. В этом случае надо быть уверенным, что задана константа SITE_ID. Этот пример эквивалентен предыдущему:

from django.contrib.sites.models import Site

def my_function_without_request():
    current_site = Site.objects.get_current()
    if current_site.domain == 'foo.com':
        # Do something
        pass
    else:
        # Do something else.
        pass

Получение текущего домена для отображения

LJWorld.com и Lawrence.com имеют функциональность по рассылке уведомлений, которая позволяет читателям получать уведомления. Самый простой пример: читатель заполняет форму, и ему тут же приходит письмо «Спасибо, что подписались.»

Было бы избыточным реализовывать этот механизм дважды, так что в реальности выполняется один и тот же код. Однако, сообщение должно быть разным для сайтов. Используя объект Site мы можем подставить соответствующие name и domain.

Покажем это на примере:

from django.contrib.sites.shortcuts import get_current_site
from django.core.mail import send_mail

def register_for_newsletter(request):
    # Check form values, etc., and subscribe the user.
    # ...

    current_site = get_current_site(request)
    send_mail(
        'Thanks for subscribing to %s alerts' % current_site.name,
        'Thanks for your subscription. We appreciate it.\n\n-The %s team.' % (
            current_site.name,
        ),
        'editor@%s' % current_site.domain,
        [user.email],
    )

    # ...

Для Lawrence.com письмо будет содержать строку «Thanks for subscribing to lawrence.com alerts.», для LJWorld.com - «Thanks for subscribing to LJWorld.com alerts.».

Note that an even more flexible (but more heavyweight) way of doing this would be to use Django’s template system. Assuming Lawrence.com and LJWorld.com have different template directories (DIRS), you could farm out to the template system like so:

from django.core.mail import send_mail
from django.template import loader

def register_for_newsletter(request):
    # Check form values, etc., and subscribe the user.
    # ...

    subject = loader.get_template('alerts/subject.txt').render({})
    message = loader.get_template('alerts/message.txt').render({})
    send_mail(subject, message, 'editor@ljworld.com', [user.email])

    # ...

В этом случае нужно было бы создавать шаблоны subject.txt и message.txt для каждого сайта. Такое решение более гибкое, но и более сложное.

Хорошей идеей будет использовать Site везде, где только можно, для удаления дублирования и упрощения кода.

Получение текущего домена для полного URL

Django’s get_absolute_url() convention is nice for getting your objects“ URL without the domain name, but in some cases you might want to display the full URL – with http:// and the domain and everything – for an object. To do this, you can use the sites framework. An example:

>>> from django.contrib.sites.models import Site
>>> obj = MyModel.objects.get(id=3)
>>> obj.get_absolute_url()
'/mymodel/objects/3/'
>>> Site.objects.get_current().domain
'example.com'
>>> 'https://%s%s' % (Site.objects.get_current().domain, obj.get_absolute_url())
'https://example.com/mymodel/objects/3/'

Включение поддержки фреймворка для сайтов

Для того, чтобы воспользоваться описанными выше возможностями, необходимо:

  1. Добавить 'django.contrib.sites' в INSTALLED_APPS.

  2. Задать SITE_ID:

    SITE_ID = 1
    
  3. Запустить migrate.

django.contrib.sites регистрирует обработчик сигнала post_migrate, который создаёт новый сайт с именем example.com и доменом example.com. Эта запись также будет создана после инициализации тестовой БД. Для установки правильного имени и домена для проекта можно воспользоваться data migration.

Чтобы использовать поддержку сайтов на боевом сервере необходимо для каждого SITE_ID создать свой файл настроек (возможно, с импортом общих, чтобы избежать дублирования) и затем указать соответствующий DJANGO_SETTINGS_MODULE.

Кеширование текущего объекта Site

Так как текущий сайт хранится в базе данных, то каждый вызов Site.objects.get_current() приведёт к выполнению SQL запроса. Разработчики Django позаботились об оптимизации: после первого запроса значение кешируется и в дальнейшем возвращается именно оно без обращения к БД.

Если же вам нужно всё-таки выполнять запрос каждый раз, можно очистить кеш путём вызова Site.objects.clear_cache():

# First call; current site fetched from database.
current_site = Site.objects.get_current()
# ...

# Second call; current site fetched from cache.
current_site = Site.objects.get_current()
# ...

# Force a database query for the third call.
Site.objects.clear_cache()
current_site = Site.objects.get_current()

CurrentSiteManager

class managers.CurrentSiteManager

Если вы используете Site в качестве внешнего ключа в какой-либо модели, то вам пригодится класс CurrentSiteManager. Это модель manager, которая автоматически фильтрует запросы на принадлежность к текущему Site.

Обязательная настройка SITE_ID

CurrentSiteManager можно использовать, только если указана настройка SITE_ID.

Используйте CurrentSiteManager для добавления этой функциональности непосредственно в модель:

from django.contrib.sites.models import Site
from django.contrib.sites.managers import CurrentSiteManager
from django.db import models

class Photo(models.Model):
    photo = models.FileField(upload_to='photos')
    photographer_name = models.CharField(max_length=100)
    pub_date = models.DateField()
    site = models.ForeignKey(Site, on_delete=models.CASCADE)
    objects = models.Manager()
    on_site = CurrentSiteManager()

Таким образом, Photo.objects.all() вернёт все объекты Photo, а Photo.on_site.all() только те, которые доступны на данном сайте согласно SITE_ID.

Другими словами эти 2 выражения эквивалентны:

Photo.objects.filter(site=settings.SITE_ID)
Photo.on_site.all()

Каким образом CurrentSiteManager узнаёт какое поле относится к Site? По умолчанию, CurrentSiteManager смотрит на наличие ForeignKey с именем site или ManyToManyField с именем sites. Если вы используете другое название поля, то ищется ссылка на Site. В этом случае имя поля необходимо передать в CurrentSiteManager. В нашем случае поле названо publish_on:

from django.contrib.sites.models import Site
from django.contrib.sites.managers import CurrentSiteManager
from django.db import models

class Photo(models.Model):
    photo = models.FileField(upload_to='photos')
    photographer_name = models.CharField(max_length=100)
    pub_date = models.DateField()
    publish_on = models.ForeignKey(Site, on_delete=models.CASCADE)
    objects = models.Manager()
    on_site = CurrentSiteManager('publish_on')

Если вы передадите в CurrentSiteManager несуществующее имя, то возникнет исключение ValueError.

Напомним, что модель может содержать обычный (не специфичный для сайта) Manager вместе с CurrentSiteManager. Это описано в manager documentation. Если вы зададите менеджер вручную, то Django не будет создавать автоматически objects = models.Manager(). Помимо всего прочего не забывайте, что некоторые части Django (например, админка и обобщённые представления) используют тот менеджер, который задан первым, так что если вы хотите иметь доступ ко всем объектам (а не только специфичным для сайта), определите в модели objects = models.Manager() перед CurrentSiteManager.

Middleware для сайтов

Если вы часто используете подобный шаблон:

from django.contrib.sites.models import Site

def my_view(request):
    site = Site.objects.get_current()
    ...

To avoid repetitions, add django.contrib.sites.middleware.CurrentSiteMiddleware to MIDDLEWARE. The middleware sets the site attribute on every request object, so you can use request.site to get the current site.

Как Django работает с сайтами

Хотя задавать сайты вовсе не обязательно, в то же время всё-таки рекомендуется, т.к. Django использует эту информацию в нескольких местах. Даже если вы создаёте единственный сайт, потратьте пару секунд, чтобы задать domain и name в базе данных и константу SITE_ID в настройках.

Где внутри Django используются сайты:

  • В модуле redirects framework каждый объект перенаправления привязан к конкретному сайту. Django ищет его, учитывая текущий сайт.
  • В модуле flatpages framework каждая статичная страница привязана к определённому сайту. При обращении к ней создаётся Site, который проверятеся на соответствие запрашиваемому сайту в FlatpageFallbackMiddleware.
  • В модуле syndication framework шаблон для title и description автоматически получает доступ к переменной {{ site }} типа Site. Также поддержка URL использует domain из текущего объекта Site, если не указан полный путь.
  • В модуле authentication framework функция django.contrib.auth.views.LoginView() передаёт имя текущего Site в переменную шаблона {{ site_name }}.
  • Популярные представления (django.contrib.contenttypes.views.shortcut) используют домен текущего объекта Site для создания URL.
  • В админке ссылка «view on site» использует текущий Site для генерации полного URL для перехода.

Объект RequestSite

Некоторые приложения из django.contrib могут воспользоваться информацией о сайтах, но спроектированы с учётом того, что её может и не быть. (Некоторые люди не хотят или не могут установить дополнительную таблицу.) В этом случае создаётся заглушка RequestSite.

class requests.RequestSite

Класс предоставляет такой же интерфейс как и Site (включая атрибуты domain и name), но берёт их из объекта HttpRequest, а не из базы данных.

__init__(request)

Задаёт name и domain для метода get_host().

Объект RequestSite имеет схожий с Site интерфейс за исключением метода __init__(), который принимает HttpRequest. Это позволяет вычислить domain и name на основании домена из запроса. Он имеет также методы save() и delete(), вызов которых приведёт к исключению NotImplementedError.

сокращение get_current_site

Для обеспечения обратной совместимости Django предоставляет функцию django.contrib.sites.shortcuts.get_current_site.

shortcuts.get_current_site(request)

Эта функция проверяет, что django.contrib.sites установлен и возвращает текущий объект Site или RequestSite, который основан на запросе. При определении текущего сайта используется request.get_host(), если настройка SITE_ID не определена.

Метод request.get_host() может вернуть домен и порт, если заголовок Host содержит явно указанный порт, например example.com:80. В этих случаях, если не найден ни один сайт в базе данных, порт будет обрезан и выполнится поиск только по домену. Это не относится к RequestSite, который всегда использует неизменное значение хоста.