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

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

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

Поддержка сайтов базируется в основном на простой модели:

class models.Site

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

domain

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

Изменено в Django 1.9:

Поле domain теперь содержит unique.

name

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

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

Как использовать данную модель решать вам, но Django предоставляет несколько способов взаимодействия через соглашения.

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

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

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

Сайты газеты Lawrence Journal-World LJWorld.com и Lawrence.com написаны на Django. LJWorld.com ориентирован на освещение глобальных событий, в то время как Lawrence.com фокусируется на местных. Время от времени возникает необходимость публиковать статьи на обе площадки.

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

Если чуть-чуть подумать то можно реализовать более гибкое и в то же время простое решение: оба сайта используют одну и ту же базу статей, каждая из кторых связана с одним или более сайтом. В Django это реализуется через ManyToManyField в модели Article:

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

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

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

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

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

  • Позволяет разработчикам сайтов использовать один и тот же код для двух сайтов. Код представления лишь проверяет надо ли выводить данную статью на текущем сайте. Например, как-нибудь так:

    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.db import models
from django.contrib.sites.models import Site

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.”.

Заметим, что более гибкой (но и более тяжёлой) была бы реализация через шаблонизатор Django. Предполагая, что Lawrence.com и LJWorld.com имеют разные пути к шаблонам (DIRS), вышло бы что-то типа:

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

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

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

    # ...

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

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

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

Функция get_absolute_url() полезна для получения URL без протокола и имени домена. Если же нужен полный URL, то его можно сгенерировать следующим образом:

>>> 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.db import models
from django.contrib.sites.models import Site
from django.contrib.sites.managers import CurrentSiteManager

class Photo(models.Model):
    photo = models.FileField(upload_to='/home/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.db import models
from django.contrib.sites.models import Site
from django.contrib.sites.managers import CurrentSiteManager

class Photo(models.Model):
    photo = models.FileField(upload_to='/home/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()
    ...

то есть простой способ избежать дублирования кода. Добавьте django.contrib.sites.middleware.CurrentSiteMiddleware в MIDDLEWARE_CLASSES. Таким образом для каждого объекта запроса добавится атрибут site (request.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.login() передаёт имя текущего 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, который всегда использует неизменное значение хоста.

Изменено в Django 1.8:

Была добавлена возможность определить текущий сайт по request.get_host().

Изменено в Django 1.9:

Был добавлен функционал поиска сайта после обрезания порта.