воскресенье, 30 июля 2017 г.

Посторение высокоуровневого API запросов: правильный способ использования ORM Django

Перевод: Building a higher-level query API: the right way to use Django's ORM
Автор: Джэми Мэтьюз (Jamie Matthews)

Примечания переводчика
Поначалу может показаться, что в этой статье рассматривается ещё один способ избавления от "магических чисел", который был рассмотрен в статье Правильная обработка choices в полях моделей Django. В конце перевода я покажу, чем это решение отличается от уже рассмотренного и как его можно применить в реальных проектах.
Эта статья основана на обсуждении в группе пользователей Python города Брайтон (Brighton Python User Group) 10 апреля 2012 года.

Аннотация

В этой статье я хочу показать, что использование низкоуровневых методов запросов ORM Django (filter, order_by и т.п.) прямо в представлении обычно является плохой практикой. Вместо этого лучше построить собственный проблемно-ориентированный API запросов на уровне модели, которой принадлежит бизнес-логика. Сделать это в Django не особо просто, но глубоко погрузившись во внутренности ORM, всё же можно найти для этого несколько приемлемых способов.

Обзор

При написании приложений Django мы привыкли добавлять методы к моделям для изоляции бизнес-логики и сокрытия деталей реализации. Этот подход кажется совершенно естественным и он действительно свободно используется во встроенных приложениях Django:
>>> from django.contrib.auth.models import User
>>> user = User.objects.get(pk=5)
>>> user.set_password('super-sekrit')
>>> user.save()
Здесь set_password - это метод, определённый в модели django.contrib.auth.models.User, который скрывает детали реализации хэширования пароля. В наглядном виде этот код выглядит примерно следующим образом:
from django.contrib.auth.hashers import make_password

class User(models.Model):

    # здесь находятся поля модели...

    def set_password(self, raw_password):
        self.password = make_password(raw_password)
Мы построили проблемно-ориентированный API поверх инструментов для обобщённого низкоуровневого объектно-реляционного отображения, которые предоставляются Django. Это основа проблемного-ориентированного подхода: мы увеличиваем количество уровней абстракции, делая менее явным код, взаимодействующий с API. В результате код получается более устойчивым, пригодным для повторного использования и (самое важное) более наглядным.

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

Учебное приложение: список дел

Чтобы проиллюстрировать этот подход, воспользуемся простым приложением для ведения списка дел. Оговоримся ещё раз: это учебное приложение. Тяжело показать настоящее приложение без необходимости углубляться во множество деталей кода. Не обращайте внимание на реализацию списка дел, а вместо этого постарайтесь представить, как этот подход будет работать в одном из ваших полномасштабных приложений.

Вот файл models.py из нашего приложения:
from django.db import models

PRIORITY_CHOICES = [(1, 'High'), (2, 'Low')]

class Todo(models.Model):
    content = models.CharField(max_length=100)
    is_done = models.BooleanField(default=False)
    owner = models.ForeignKey('auth.User')
    priority = models.IntegerField(choices=PRIORITY_CHOICES,
                                   default=1)
Теперь давайте посмотрим, какие запросы к этим данным мы могли бы выполнить. Допустим, мы создаём представление для просмотра списка дел из нашего приложения. Мы хотим отобразить все незавершённые дела с высоким приоритетом, существующие в настоящее время у вошедшего пользователя. Вот наш первоначальный вариант кода:
def dashboard(request):

    todos = Todo.objects.filter(
        owner=request.user
    ).filter(
        is_done=False
    ).filter(
        priority=1
    )

    return render(request, 'todos/list.html', {
        'todos': todos,
    })
Да, я знаю, что этом можно записать проще: request.user.todo_set.filter(is_done=False, priority=1). Но напоминаю: это просто пример!

Почему это плохо?

  • Во-первых, это многословно. Семь строк (в зависимости от того, как вы предпочитаете расставлять переводы строк в цепочках вызовов методов) просто чтобы получить интересующие нас строки. Конечно, это всего-лишь пример. В настоящем приложении код обращения к ORM может быть гораздо более сложным.
  • Здесь наблюдается утечка деталей реализации. Код, который взаимодействует с моделью, должен знать, что здесь существует свойство по имени is_done и это BooleanField. Если вы поменяете реализацию (возможно вам захочется заменить булево поле is_done на поле статуса, которое может принимать одно из нескольких значений), то код сломается.
  • Он не прозрачен - его смысл не понятен с первого взгляда.
  • Наконец, потенциально он будет повторяться. Представьте, что появилось новое требование: написать управляющую команду, которая будет вызываться через планировщик задач каждую неделю и отсылать всем пользователям список незавершённых дел с высоким приоритетом. Вам придётся скопировать и вставить эти семь строк в новый скрипт. Это не соответствует принципу DRY - Do not repeat yourself - не повторяйся.
Давайте подведём итоги: использование низкоуровневых методов ORM прямо в представлении обычно является плохой практикой.

Ну хорошо, а как же это можно улучшить?

Менеджеры запросов и объекты запросов

Перед рассмотрением вариантов решений ненадолго отвлечёмся, чтобы пояснить некоторые понятия.

В Django есть две тесно связанные конструкции, относящиеся к операциям над таблицами: менеджеры запросов и объекты запросов.

Менеджер запросов (экземпляр django.db.models.manager.Manager) описывается как "интерфейс, через который осуществляются операции с моделями Django в базе данных". Менеджер модели - это шлюз к функциональности ORM для доступа к таблицам (экземпляры моделей обычно предоставляют функциональность для доступа к одной строке таблицы). Каждый класс модели предоставляет менеджер по умолчанию, который называется objects.

Объект запроса (django.db.models.query.QuerySet) представляет "коллекцию объектов из базы данных". Это абстракция с отложенным выполнением вычисленного запроса SELECT. Эта абстракция может быть отфильтрована, упорядочена и использована для ограничения или изменения набора строк, который она представляет. Она отвечает за создание и манипулирование экземплярами django.db.models.sql.query.Query, которые преобразуются в настоящие SQL-запросы к одному из поддерживаемых типов нижележащих баз данных.

Уф. Запутались? Хотя разницу между менеджерами запросов и объектами запросов легко объяснить тем, кто хорошо знаком со внутренностями ORM, она не кажется очевидной, особенно новичкам.

Эта путаница усугубляется тем, что знакомый API менеджеров на самом деле немного не такой, каким кажется...

API менеджера - это обман

Методы объектов запросов можно объединять в цепочки. Каждый вызов метода объекта запроса (например filter) возвращает клонированную версию исходного объекта запроса, готового к вызову другого метода. Этот естественный интерфейс - часть прекрасного ORM Django.

Но на самом деле Model.objects - это менеджер запросов (а не объект запроса), что создаёт проблемы: нам нужно начать нашу цепочку методов вызовов с objects, но продолжение цепочки даст в результате объект запроса.

И как же эта проблема решается в коде самого Django? Итак, обман API объясняется: все методы объекта запроса повторно реализуются в менеджере запросов. Версии этих методов из менеджера запросов просто транслируются в только что созданный объект запроса через self.get_query_set():
class Manager(object):

    # Пропускаем служебные вещи...

    def get_query_set(self):
        return QuerySet(self.model, using=self._db)

    def all(self):
        return self.get_query_set()

    def count(self):
        return self.get_query_set().count()

    def filter(self, *args, **kwargs):
        return self.get_query_set().filter(*args, **kwargs)

    # и так далее сто с лишним строк...
Чтобы увидеть весь этот ужас, загляните в исходный код класса Manager.

Мы скоро вернёмся к этому хитромудрому API...

Возвращаемся к списку дел

И так, давайте вернёмся к решению нашей проблемы прояснения нашего беспорядочного API запросов. В документации Django рекомендуется следующий подход: определить собственные подклассы Manager и присоединить их к нужным моделям.

Можно даже добавить в модель несколько дополнительных менеджеров или можно переопределить objects, оставив один менеджер, но добавив к нему собственные методы.

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

Подход 1: несколько собственных менеджеров

class IncompleteTodoManager(models.Manager):
    def get_query_set(self):
        return super(TodoManager, self).get_query_set().filter(is_done=False)

class HighPriorityTodoManager(models.Manager):
    def get_query_set(self):
        return super(TodoManager, self).get_query_set().filter(priority=1)

class Todo(models.Model):
    content = models.CharField(max_length=100)
    # здесь следуют прочие поля...

    objects = models.Manager() # менеджер по умолчанию

    # присоединяем собственные менеджеры:
    incomplete = models.IncompleteTodoManager()
    high_priority = models.HighPriorityTodoManager()
Реализованное здесь API выглядит следующим образом:
>>> Todo.incomplete.all()
>>> Todo.high_priority.all()
К несчастью, этот подход порождает несколько больших проблем.
  • Реализация очень многословная. Нужно определить отдельный класс для каждого кусочка функциональности.
  • Создаётся беспорядок в пространстве имён модели. Разработчики Django привыкли использовать Model.objects в качестве шлюза к таблицам. Это пространство имён, под которым собираются все операции на уровне таблиц. Было бы неприятно расстаться с этим чётким соглашением.
  • Настоящая беда: нельзя пользоваться цепочками фильтров. Нельзя комбинировать менеджеров: для получения незавершённых и высокоприоритетных дел нужно вернуться к низкоуровневому коду ORM: воспользоваться либо Todo.incomplete.filter(priority=1), либо Todo.high_priority.filter(is_done=False).
Я думаю, что минусы полностью перевешивают достоинства этого подхода и создание нескольких менеджеров модели в большинстве случаев является плохой практикой.

Подход 2: методы менеджера

Что ж, давайте попробуем другой подход, разрешённый в Django: собственный менеджер с несколькими методами.
class TodoManager(models.Manager):
    def incomplete(self):
        return self.filter(is_done=False)

    def high_priority(self):
        return self.filter(priority=1)

class Todo(models.Model):
    content = models.CharField(max_length=100)
    # здесь следуют прочие поля...

    objects = TodoManager()
Теперь наш API выглядит следующим образом:
>>> Todo.objects.incomplete()
>>> Todo.objects.high_priority()
Гораздо лучше. Здесь меньше кода (есть только одно определение класса) и методы запроса размещаются в пространстве имён внутри менеджера objects.

Однако, такие запросы по-прежнему нельзя объединять в цепочки. Todo.objects.incomplete() вернёт обычный объект запроса, поэтому нельзя написать Todo.objects.incomplete().high_priority(). Нам по-прежнему придётся писать Todo.objects.incomplete().filter(is_done=False). Не годится.

Подход 3: собственный объект запроса

Теперь мы на неизведанной территории. Этого нельзя найти в документации Django...
class TodoQuerySet(models.query.QuerySet):
    def incomplete(self):
        return self.filter(is_done=False)

    def high_priority(self):
        return self.filter(priority=1)

class TodoManager(models.Manager):
    def get_query_set(self):
        return TodoQuerySet(self.model, using=self._db)

class Todo(models.Model):
    content = models.CharField(max_length=100)
    # здесь следуют прочие поля...

    objects = TodoManager()
Вот как это выглядит при использовании:
>>> Todo.objects.get_query_set().incomplete()
>>> Todo.objects.get_query_set().high_priority()
>>> # или
>>> Todo.objects.all().incomplete()
>>> Todo.objects.all().high_priority()
Мы почти на месте! Здесь не больше кода, чем в подходе 2, имеются те же достоинства, а кроме того (барабанная дробь) - можно использовать цепочки!
>>> Todo.objects.all().incomplete().high_priority()
Однако, совершенство ещё не достигнуто. Собственный менеджер -
ничего более, чем шаблонная заготовка. И этот all() выглядит как нарост, который надоедает набирать. Но важнее то, что он всё запутывает - из-за него код выглядит странно.

Подход 3a: копируем Django, транслируем всё

Теперь пригодится наше обсуждение "API менеджера - это обман": мы знаем, как исправить эту проблему. Мы просто переопределим все методы из объекта запросов в нашем менеджере, транслируя их обратно в наш объект запросов:
class TodoQuerySet(models.query.QuerySet):
    def incomplete(self):
        return self.filter(is_done=False)

    def high_priority(self):
        return self.filter(priority=1)

class TodoManager(models.Manager):
    def get_query_set(self):
        return TodoQuerySet(self.model, using=self._db)

    def incomplete(self):
        return self.get_query_set().incomplete()

    def high_priority(self):
        return self.get_query_set().high_priority()
Мы получаем в точности тот API, какой нам и нужен:
>>> Todo.objects.incomplete().high_priority() # Ура!
Однако код получился многословным и он не соответствует принципу DRY. Каждый раз, когда нужно добавить новый метод в объект запросов или поменять сигнатуру существующего метода, нужно не забыть сделать такие же изменения в менеджере. В противном случае этот метод не будет работать правильно. Похоже на источник будущих проблем.

Подход 3b: django-model-utils

Python - динамический язык. Можно ли избежать повторения шаблонных заготовок? Это возможно при помощи небольшого стороннего приложения, которое называется django-model-utils. Просто запустите pip install django-model-utils, а затем...
from model_utils.managers import PassThroughManager

class TodoQuerySet(models.query.QuerySet):
    def incomplete(self):
        return self.filter(is_done=False)

    def high_priority(self):
        return self.filter(priority=1)

    class Todo(models.Model):
        content = models.CharField(max_length=100)
        # здесь следуют прочие поля...

        objects = PassThroughManager.for_queryset_class(TodoQuerySet)()
Вот так намного приятнее. Мы просто определили наш собственный подкласс объекта запросов, как и в прошлый раз, и добавили его в нашу модель через класс PassThroughManager, который имеется в django-model-utils.

PassThroughManager работает благодаря реализации метода __getattr__, который перехватывает вызовы несуществующих методов и автоматически передаёт их в объект запроса. Он выполняет несколько проверок для предотвращения бесконечной рекурсии при обращении к некоторым свойствам. Именно поэтому я рекомендую использовать испытанную реализацию, предоставляемую django-model-utils, а не пытаться накропать её собственноручно.

Чем это поможет?

Помните код представления, который был приведён выше?
def dashboard(request):

    todos = Todo.objects.filter(
        owner=request.user
    ).filter(
        is_done=False
    ).filter(
        priority=1
    )

    return render(request, 'todos/list.html', {
        'todos': todos,
    })
После небольшой доработки его можно привести к следующему виду:
def dashboard(request):
    todos = Todo.objects.for_user(
        request.user
    ).incomplete().high_priority()

    return render(request, 'todos/list.html', {
        'todos': todos,
    })
Я думаю вы согласитесь, что эта вторая версия намного проще и нагляднее, чем первая?

Может ли помочь Django?

В списке рассылки django-dev обсуждались способы упростить решение рассмотренной проблемы. По итогам обсуждения была заведена заявка. Захари Воз (Zachary Voase) предложил следующее:
class TodoManager(models.Manager):

    @models.querymethod
    def incomplete(query):
        return query.filter(is_done=False)
Это определение задекорированного метода incomplete может волшебным образом сделать его доступным сразу в менеджере и объекте запросов.

Лично я не совсем согласен с идеей использования декоратора. Он слегка затуманивает детали и не выглядит изящным. Я нутром чую, что добавление методов в подкласс объектов запросов (а не в подкласс менеджера) - это лучший, более простой подход.

Можно было бы пойти и дальше. Вернувшись назад и заново продумав проектные решения API Django с нуля, может быть удалось бы найти настоящие, более глубокие улучшения. Можно ли стереть различия между менеджерами и объектами запросов? Или по крайней мере прояснить эти различия?

Я убеждён, что если бы была предпринята такая переработка, то она должна была бы появиться в Django 2.0 или в последующих версиях.

Итак, напомним:

Использование низкоуровневого кода запросов ORM в представлениях и других высокоуровневых частях приложения обычно является плохой практикой. Вместо этого, создав собственное API объектов запросов и присоединив его к модели при помощи PassThroughManager из django-model-utils, мы получаем следующие выгоды:
  • Код получается более компактным и устойчивым.
  • Уменьшаются повторы, увеличивается уровень абстракции.
  • Бизнес-логика помещается на уровень модели, к которой она и принадлежит.
Благодарю за чтение!

Если вы хотите вонзить свои зубы в большие проекты на Django (а также и во множество других интересных вещей), мы можем предложить вам работу.

Дополнение переводчика
Теперь я покажу обещанный мной способ использования описанных в этой статье идей на примере реального проекта. Собственно, эту статью я нашел именно потому, что мне был нужен способ добавить собственный метод в объект запроса. Этот пример хорош ещё и потому, что в нём одновременно используются идеи из прошлой статьи Правильная обработка choices в полях моделей Django и из этой статьи.

Имеется модель, которая содержит настройки менеджера SNMP. Напоминаю, что менеджер - это программа, которая занимается опросом оборудования по SNMP. Настройки на оборудовании - это настройки агента SNMP. Настройки агента сложнее, потому что содержат список сообществ и пользователей, имеющих доступ к оборудованию по SNMP, тип доступа - только чтение или чтение-запись, и ветку дерева OID'ов, к которой относится описываемый доступ - так называемые SNMP View. Так вот, сейчас мы рассматриваем только настройки менеджера SNMP.

Модель так и называется - SNMP и первоначально описывается следующим образом:
class SNMP(models.Model):
    VERSION_1 = 1
    VERSION_2C = 2
    VERSION_3 = 3
    VERSION = (
        (VERSION_1, 'SNMPv1'),
        (VERSION_2C, 'SNMPv2c'),
        (VERSION_3, 'SNMPv3'),
    )

    V3_SECURITY_LEVEL_NO_AUTH_NO_PRIV = 0
    V3_SECURITY_LEVEL_AUTH_NO_PRIV = 1
    V3_SECURITY_LEVEL_AUTH_PRIV = 2
    V3_SECURITY_LEVEL = (
        (V3_SECURITY_LEVEL_NO_AUTH_NO_PRIV, 'noAuthNoPriv'),
        (V3_SECURITY_LEVEL_AUTH_NO_PRIV, 'authNoPriv'),
        (V3_SECURITY_LEVEL_AUTH_PRIV, 'authPriv'),
    )

    V3_AUTH_PROTOCOL_MD5 = 0
    V3_AUTH_PROTOCOL_SHA = 1
    V3_AUTH_PROTOCOL = (
        (V3_AUTH_PROTOCOL_MD5, 'MD5'),
        (V3_AUTH_PROTOCOL_SHA, 'SHA'),
    )

    V3_PRIV_PROTOCOL_DES = 0
    V3_PRIV_PROTOCOL_AES = 1
    V3_PRIV_PROTOCOL = (
        (V3_PRIV_PROTOCOL_DES, 'DES'),
        (V3_PRIV_PROTOCOL_AES, 'AES'),
    )

    DEFAULT_PORT = 161

    snmp_port = models.IntegerField(u'SNMP-порт', blank=True, default=DEFAULT_PORT)
    snmp_version = models.PositiveIntegerField(u'Версия SNMP', choices=VERSION, default=VERSION_1)
    snmp_community = models.CharField(u'SNMP-сообщество', max_length=255, blank=True, default='')
    snmpv3_contextname = models.CharField(u'SNMP-контекст безопасности', max_length=255, blank=True, default='')
    snmpv3_securityname = models.CharField(u'SNMP-имя безопасности', max_length=255, blank=True, default='')
    snmpv3_securitylevel = models.PositiveIntegerField(u'SNMP-уровень безопасности', choices=V3_SECURITY_LEVEL, default=V3_SECURITY_LEVEL_NO_AUTH_NO_PRIV)
    snmpv3_authprotocol = models.PositiveIntegerField(u'SNMP-протокол аутентификации', choices=V3_AUTH_PROTOCOL, default=V3_AUTH_PROTOCOL_MD5)
    snmpv3_authpassphrase = models.CharField(u'SNMP-пароль аутентификации', max_length=255, blank=True, default='')
    snmpv3_privprotocol = models.PositiveIntegerField(u'SNMP-протокол безопасности', choices=V3_PRIV_PROTOCOL, default=V3_PRIV_PROTOCOL_DES) 
    snmpv3_privpassphrase = models.CharField(u'SNMP-пароль безопасности', max_length=255, blank=True, default='')

    class Meta:
        unique_together = (('snmp_port', 'snmp_version', 'snmp_community', 'snmpv3_contextname', 'snmpv3_securityname', 'snmpv3_securitylevel', 'snmpv3_authprotocol', 'snmpv3_authpassphrase', 'snmpv3_privprotocol', 'snmpv3_privpassphrase'),)
        verbose_name = u'Настройки SNMP'
        verbose_name_plural = u'Настройки SNMP'
Все остальные поля, не имеющие отношения к рассматриваемому примеру, были пропущены.

Суть в том, что в зависимости от версии SNMP используются настройки, хранящиеся в разных полях. В случае SNMP версий 1 и 2c используется только поле snmp_community. В случае третьей версии SNMP как минимум используются ещё и поля snmpv3_contextname, snmpv3_securityname и snmpv3_securitylevel.

В зависимости от значения поля snmpv3_securitylevel могут использоваться ещё 4 поля. Если snmpv3_securitylevel соответствует noAuthNoPriv, то дополнительные поля не используются. Если snmpv3_securitylevel соответствует authNoPriv, то дополнительно используются поля snmpv3_authprotocol и snmpv3_authpassphrase. Если snmpv3_securitylevel соответствует authPriv, то используются поля snmpv3_authprotocol, snmpv3_authpassphrase, а так же snmpv3_privprotocol и snmpv3_privpassphrase.

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

Верным решением была бы обязательная валидация данных при создании или изменении объекта SNMP. Но если проконтролировать изменение одного объекта при помощи методов геттеров и сеттеров ещё можно, то проконтролировать запросы, обновляющие массово несколько записей сразу, уже сложнее. Полноценную валидацию на всех возможных этапах использования объектов провести довольно сложно.

Можно немного упростить задачу и воспользоваться решением, описанным в рассматриваемой статье - сделать у объекта SNMP ещё один метод, который будет оставлять в выборке из таблицы только те объекты, которые соответствуют эталонному объекту SNMP. В моём случае это решение располагается перед описанием модели SNMP и выглядит следующим образом:
class SNMPQuerySet(models.query.QuerySet):
    def like(self, snmp):
        qs = self.filter(snmp_port=snmp.snmp_port,
                         snmp_version=snmp.snmp_version)

        if snmp.snmp_version in (self.model.VERSION_1, self.model.VERSION_2C):
            qs = qs.filter(snmp_community=snmp.snmp_community)

        elif snmp.snmp_version == self.model.VERSION_3:
            qs = qs.filter(snmpv3_contextname=snmp.snmpv3_contextname,
                           snmpv3_securityname=snmp.snmpv3_securityname,
                           snmpv3_securitylevel=snmp.snmpv3_securitylevel)

            if snmp.snmpv3_securitylevel == self.model.V3_SECURITY_LEVEL_AUTH_NO_PRIV:
                qs = qs.filter(snmpv3_authprotocol=snmp.snmpv3_authprotocol,
                               snmpv3_authpassphrase=snmp.snmpv3_authpassphrase)
            elif snmp.snmpv3_securitylevel == self.model.V3_SECURITY_LEVEL_AUTH_PRIV:
                qs = qs.filter(snmpv3_authprotocol=snmp.snmpv3_authprotocol,
                               snmpv3_authpassphrase=snmp.snmpv3_authpassphrase,
                               snmpv3_privprotocol=snmp.snmpv3_privprotocol,
                               snmpv3_privpassphrase=snmp.snmpv3_privpassphrase)
        return qs

class SNMPManager(models.Manager):
    def get_queryset(self):
        return SNMPQuerySet(self.model, using=self._db)

    def like(self, snmp):
        return self.get_queryset().like(snmp)
Теперь остаётся только добавить менеджер запросов SNMPManager в качестве менеджера по умолчанию в модель SNMP. Добавим перед строчкой "class Meta:" всего одну строку:
objects = SNMPManager()
После описанной доработки модели, можно действовать следующим образом:
# Получаем настройки SNMP от пользователя
user_snmp_settings = ...

# Ищем подобные настройки в таблице
found_snmp_settings = SNMP.objects.like(user_snmp_settings).first()

# Если настройки уже есть, используем их в последующих операциях
if found_snmp_settings:
    user_snmp_settings = found_snmp_settings
# В противном случае добавляем в таблицу настройки, полученные от пользователя
else:
    user_snmp_settings.save()

# Дальше используем user_snmp_settings
...
Стоит ли пользоваться этим методом или можно решить эту задачу изящнее каким-то другим способом - решать вам. Я хотел лишь сообщить о существовании подобного приёма и описать, как им можно воспользоваться.

воскресенье, 23 июля 2017 г.

Правильная обработка choices в полях моделей Django

Перевод: Handle choices the right way
Автор: Джеймс Беннет (James Bennett)

Примечания переводчика:
Обычно не задумывался над тем, как лучше оформлять кортежи для параметра choices в полях моделей Django.
Не задумывался, видимо, потому что никогда до этого не приходилось фильтровать записи по значениям этого поля. И вот однажды такая необходимость возникла. Подумал, что использование в QuerySet'ах "магических чисел" не способствует наглядности кода и решил поискать - как же лучше оформить эти кортежи? В результате нашёл эту статью, которую и решил впоследствии перевести - вдруг кому-нибудь ещё пригодится? Многие ссылки в исходной статье протухли, поэтому заменил их на то, что удалось найти из ещё доступного.
Часто бывает нужно воспользоваться полем модели, которое будет принимать ограниченный набор значений. Например, поле выбора штата США, что логично, должно разрешать только значения, соответствующие действительно существующим штатам США. В ORM Django это реализуется при помощи аргумента choices в определении поля. В общем случае это - наиболее простое решение.

Но это решение не всегда идеальное: хотя со строковыми значениями (упомянутые выше штаты США в Django могут быть реализованы в виде простого двухбуквенного почтового кода вроде "VA" или "KS") не возникает проблем, с числовыми значениями возникают сложности. Например, в моём блоге используется модель Entry (если интересно - см. полный исходный текст в репозитории Google Code), в которой есть поле "status", позволяющее отличать три разных типа записей:
  1. "Live" - записи, которые опубликованы на сайте.
  2. "Draft" - записи, которые находятся в процессе подготовки и ещё не опубликованы. Когда они будут дописаны, их статус можно будет поменять на "Live".
  3. "Hidden" - записи, которые по какой-либо причине были скрыты. Если записи нужно оставить в базе данных, но они не должны быть опубликованными.
К слову, я настоятельно рекомендую не удалять содержимое из базы данных. Никогда не знаешь, когда оно может потребоваться снова. И хотя можно реализовать функцию восстановления записей, гораздо проще просто включить или выключить признак публикации.

Теперь запишем этот набор опций в виде кортежа choices наиболее очевидным образом:
STATUS_CHOICES = (
    (1, 'Live'),
    (2, 'Draft'),
    (3, 'Hidden'),
)
Для этих значений не так-то просто придумать строковые аббревиатуры (по крайней мере, ни одно из них не может быть интернационализировано), поэтому выходом может оказаться использование целочисленных значений. В таком случае его можно просто добавить к модели:
class Entry(models.Model)
    # ...несколько других полей...
    status = models.IntegerField(choices=STATUS_CHOICES)
Можно чуть-чуть улучшить этот код, так чтобы по умолчанию у записей проставлялось значение “Live”:
status = models.IntegerField(choices=STATUS_CHOICES, default=1)
Теперь можно легко выбрать только опубликованные записи, выполнив такой запрос:
live_entries = Entry.objects.filter(status=1)
Можно даже реализовать собственный менеджер запросов, который заменит метод get_query_set() так, чтобы он возвращал только опубликованные записи (как было сделано здесь).

Но у этого подхода есть большая проблема: теперь в приложении повсюду используются "магические числа".

Плохая магия

"Магические числа" - это любые числовые константы (или любые другие непонятные значения), которые явным образом встречаются в коде. В данном случае значение статуса "Live" - 1 - используется по меньшей мере уже в двух местах: первый раз в объявлении поля модели (в качестве значения по умолчанию) и, даже если мы напишем собственный менеджер запросов для выбора опубликованных записей, как минимум ещё один раз для фильтрации в условии status=1. И это даже если мы просто хотим удобно работать с опубликованными записями. Если же нам когда-нибудь понадобится, скажем, метод-менеджер или QuerySet для черновиков, нам придётся разбросать во множестве мест ещё одно число.

Это плохо по нескольким причинам. Во-первых, что самое важное, нарушается принцип DRY (Don't repeat yourself - не повторяйся), так как нужно повторять одно и то же "магическое" число во множестве мест. Во-вторых, есть риск нарушения родственного принципа "Однажды и только единожды", поскольку легко попасть в ловушку при написании filter(status=1) более чем в одном месте. Наконец, в-третьих, возникают проблемы с поддержкой: нужно держать в уме - что означает это "магическое" значение и список всех мест, где оно используется (поскольку любое будущее изменение потребуется внести во все эти места одновременно).

Удаляем магию

Но остаётся проблема: как ссылаться на это значение без жёстко-закодированных повсюду "магических" чисел? В языках с перечисляемыми типами (такими как enum в C и его родственниках), это - легко решаемая проблема. В одном из этих языков нужно просто определить enum с именами различных вариантов и позволить языку использовать значениями.

Но в Python нет подходящей замены для enum. Мы можем импортировать кортеж STATUS_CHOICES и записать фильтрацию следующим образом:
live_entries = Entry.objects.filter(status=STATUS_CHOICES[0][0])
Но, несмотря на то, что мы больше не используем жёстко закодированные числовые значения, теперь мы опираемся на точное определение кортежа STATUS_CHOICES. Если в первый слот будет помещено какое-то другое значение (например, статус подтверждения записи главным редактором), то сломается всё, что ссылается на STATUS_CHOICES[0][0].

Однако, можно просто определить набор констант:
LIVE_STATUS = 1
DRAFT_STATUS = 2
HIDDEN_STATUS = 3
Теперь можно переопределить кортеж STATUS_CHOICES так, чтобы он опирался эти константы:
STATUS_CHOICES = (
    (LIVE_STATUS, 'Live'),
    (DRAFT_STATUS, 'Draft'),
    (HIDDEN_STATUS, 'Hidden'),
)
А также, теперь можно импортировать и ссылаться на эти константы. Например, поле статуса теперь должно быть записано следующим образом:
status = models.IntegerField(choices=STATUS_CHOICES, default=LIVE_STATUS)
Этими константами можно воспользоваться и для фильтрации только опубликованных записей:
live_entries = Entry.objects.filter(status=LIVE_STATUS)
Черновики теперь можно отбирать, фильтруя их по условию status=DRAFT_STATUS. А скрытые записи можно фильтровать по условию status=HIDDEN_STATUS. Код становится более наглядным в двух аспектах:
  1. Больше нет разложенных повсюду "магических чисел", которые нужно специально обновлять. Одно изменение в определении одной или более констант - это всё, что нужно, чтобы обновить код (важно также обновить и базу данных, но это можно легко исправить одним запросом UPDATE).
  2. Код теперь стал гораздо понятнее: запрос с условием status=1 может значить что угодно, но запрос с условием status=LIVE_STATUS практически объясняет сам себя.

Инкапсуляция

Однако, можно внести ещё одно улучшение: инкапсулировать варианты статуса внутри модели Entry. Эти варианты логически "принадлежат" модели Entry и поэтому не стоит определять их отдельно или импортировать их отдельно от самой модели Entry. Поэтому можно переместить константы и кортеж с вариантами вовнутрь класса Entry:
class Entry(models.Model):
    LIVE_STATUS = 1
    DRAFT_STATUS = 2
    HIDDEN_STATUS = 3
    STATUS_CHOICES = (
        (LIVE_STATUS, 'Live'),
        (DRAFT_STATUS, 'Draft'),
        (HIDDEN_STATUS, 'Hidden'),
    )
    # ...несколько других полей...
    status = models.IntegerField(choices=STATUS_CHOICES, default=LIVE_STATUS)
Теперь можно просто импортировать модель Entry и писать запросы следующим образом:
live_entries = Entry.objects.filter(status=Entry.LIVE_STATUS)
draft_entries = Entry.objects.filter(status=Entry.DRAFT_STATUS)
Можно также сравнивать записи со значениями констант:
if entry_object.status == Entry.LIVE_STATUS:
    # делаем что-то с опубликованной записью

Подведём итоги

Хотя нужно набрать немного больше текста (поскольку сначала нужно определить константы, а затем объявить кортеж choices на их основе), это - лучший способ работы с целочисленными вариантами в полях моделей Django. Возможность ссылаться на константы как на атрибуты класса модели, наподобие Entry.LIVE_STATUS, вместо жёсткого кодирования "магических" чисел или работы с отдельной от класса структурой данных - это почти настолько наглядно, насколько возможно.

воскресенье, 16 июля 2017 г.

Бэкенд для LDAP-аутентификации в Django

Написал небольшой модуль для аутентификации в Django через LDAP-сервер. Для входа нужно ввести полное имя пользователя в формате user@domain.tld и пароль пользователя.

Модуль учитывает состояние пользователя - заблокирован он в каталоге LDAP или нет. Если пользователь найден в LDAP, но отсутствует в таблице auth_user, то пользователь создаётся и помечается активным. Если пользователь не найден в LDAP (может быть не найден из-за неверного пароля или из-за того, что пользователь заблокирован), но найден в таблице auth_user, то отметка активности пользователя в таблице снимается.

При каждом входе из каталога LDAP в таблицу auth_user копируются имя пользователя, фамилия и почтовый ящик.

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

Модуль аутентификации LDAP может использоваться и совместно со стандартным модулем аутентификации Django. Именно поэтому в базу данных каждый раз вносится случайно сгенерированный пароль. Если бы в базу данных каждый раз вносился один и тот же пароль (пусть даже и в хэшированном виде), то всё ещё оставалась бы возможность войти под учётной записью этого пользователя, зная этот стандартный пароль, а не настоящий пароль пользователя в каталоге LDAP.

Сохраним бэкенд в файл application/ldap_backend.py внутри каталога проекта:
# -*- coding: UTF-8 -*-

import random, ldap
from django.contrib.auth.models import User
from django.contrib.auth.hashers import make_password

# Функция, генерирующая пароль или ключ указанной сложности
def genkey(upper=True, lower=True, digits=True, signs=False, length=64):
    chars = ''

    if upper:
        chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    if lower:
        chars += 'abcdefghijklmnopqrstuvwxyz'
    if digits:
        chars += '0123456789'
    if signs:
        chars += '()[]{}<>|/~!@#$%^&,.;:*-+=_'

    key = ''
    num = len(chars)
    for i in xrange(0, length):
        j = int(random.random() * num)
        key += chars[j]
    return key

class LDAPBackend(object):
    def authenticate(self, username=None, password=None):
        # Ищем пользователя в LDAP
        try:
            login, domain = username.split('@')
        except ValueError:
            return None

        base_dn = domain.split('.')
        base_dn = map(lambda x: 'dc=' + x, base_dn)
        base_dn = ','.join(base_dn)

        l = ldap.initialize('ldap://' + domain)
        try:
            l.simple_bind_s(username, password)
        except ldap.INVALID_CREDENTIALS:
            return None
        l.set_option(ldap.OPT_REFERRALS, 0)
        result = l.search_s(base_dn,
                            ldap.SCOPE_SUBTREE,
                            '(&(sAMAccountName=%s)(objectClass=user)(!(UserAccountControl:1.2.840.113556.1.4.803:=2)))' % login,
                            None)
        found, data = result[0]
        l.unbind_s()

        # Ищем пользователя в django или создаём его
        try:
            user = User.objects.get(username=username)
        except User.DoesNotExist:
            user = User(username=username)
            user.password = make_password(genkey())
            user.is_staff = True
            user.is_superuser = False

        # Обновляем пользователя в django
        if found:
            user.email = data.get('mail', [''])[0]
            user.first_name = data.get('givenName', [''])[0].decode('UTF-8') # data.get('middleName', [''])[0].decode('UTF-8')
            user.last_name = data.get('sn', [''])[0].decode('UTF-8')
            user.is_active = True
        else:
            user.is_active = False
        user.save()

        return user

    def get_user(self, user_id):
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None
Для использования этого модуля аутентификации, нужно прописать его в настройках проекта, в файле settings.py:
AUTHENTICATION_BACKENDS = (
  'application.ldap_backend.LDAPBackend',
  'django.contrib.auth.backends.ModelBackend'
)
Если стандартная аутентификация Django не требуется, то стандартный модуль можно отключить.

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

воскресенье, 9 июля 2017 г.

Обработка аутентификации в Django

Частичный перевод статьи: Handling Authentication & Authorization
Автор: Натан Йерглер (Nathan Yergler)

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

Чтобы воспользоваться встроенной поддержкой аутентификации, к проекту нужно подключить приложения django.contrib.auth и django.contrib.sessions.

Как можно увидеть в addressbook/settings.py, при создании проекта Django, они уже включены по умолчанию.
INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Раскомментируйте следующую строку, чтобы включить интерфейс администрирования:
    # 'django.contrib.admin',
    # Раскомментируйте следующую строку, чтобы включить документацию на интерфейс администрирования:
    # 'django.contrib.admindocs',
    'contacts',
)
Кроме установки приложений, нужно также установить промежуточное программное обеспечение.
MIDDLEWARE_CLASSES = (
    'django.middleware.common.CommonMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    # Раскомментируйте следующую строку для включения простой защиты от перехвата щелчков мыши:
    # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
Как мы помним, во время первого запуска syncdb Django задаёт вопрос - нужно ли создать учётную запись суперпользователя. Это происходит, потому что промежуточное программное обеспечение уже установлено.

В модуле auth из стандартной поставки Django имеются модели User - пользователь, Group - группа и Permission - права. Обычно этого бывает достаточно, если не нужно интегрировать приложение с другой системой аутентификации.

django.contrib.auth содержит несколько представлений, поддерживающих базовые действия по аутентификации, такие как вход, выход, сброс пароля и т.п. Отметим, что этот набор включает в себя представления, но не шаблоны. Поэтому в нашем проекте их необходимо создать.

Для рассматриваемого примера нужно просто добавить в наш проект представления login и logout. Для начала добавим представления в
файл addressbook/urls.py.
urlpatterns = patterns('',
    url(r'^login/$', 'django.contrib.auth.views.login'),
    url(r'^logout/$', 'django.contrib.auth.views.logout'),
У обоих представлений login и logout есть имена шаблонов по умолчанию (registration/login.html и registration/logged_out.html, соответственно). Поскольку эти представления специфичны для нашего проекта и не используются повторно приложением Contacts, мы создадим новый каталог templates/registration внутри каталога приложения addressbook:
$ mkdir -p addressbook/templates/registration
И сообщим Django о необходимости искать шаблоны в этом каталоге, настроив TEMPLATE_DIRS в addressbook/settings.py.
TEMPLATE_DIRS = (
    # Поместите здесь строки, такие как "/home/html/django_templates" или "C:/www/django/templates".
    # Всегда используйте прямую косую черту, даже в Windows.
    # Не забывайте, что нужно указывать полный путь, а не относительный.
    'addressbook/templates',
)
Внутри этого каталога сначала создадим файл login.html.
{% extends "base.html" %}

{% block content %}

{% if form.errors %}
<p>Ваше имя пользователя и пароль не подходят. Попробуйте ещё раз.</p>
{% endif %}

<form method="post" action="{% url 'django.contrib.auth.views.login' %}">
{% csrf_token %}
<table>
<tr>
    <td>{{ form.username.label_tag }}</td>
    <td>{{ form.username }}</td>
</tr>
<tr>
    <td>{{ form.password.label_tag }}</td>
    <td>{{ form.password }}</td>
</tr>
</table>

<input type="submit" value="Войти" />
<input type="hidden" name="next" value="{{ next }}" />
</form>
{% endblock %}
Шаблон login наследуется от шаблона base.html и отображает форму входа, переданную в шаблон из представления. Скрытое поле next позволяет представлению впоследствии перенаправить пользователя на запрошенную страницу, если при запросе этой страницы пользователь был перенаправлен на страницу входа.

Шаблон выхода - logged_out.html - значительно проще.
{% extends "base.html" %}

{% block content %}

Вы вышли!

{% endblock %}
Всё, что нужно - только сообщить пользователю, что выход завершился успешно.

Если сейчас запустить сервер разработки с помощью runserver и перейти на страницу по ссылке http://localhost:8000/login, то можно увидеть страницу входа. При попытке входа с неправильными данными можно увидеть сообщение об ошибке. Теперь давайте попробуем войти с данными суперпользователя, который был создан ранее.

Погодите, что случилось? Почему мы попали на страницу /accounts/profile? Мы не вводили такой адрес. После успешного входа представление login перенаправляет пользователя на определённый URL и по умолчанию это страница /accounts/profile. Чтобы заменить её, нужно настроить значение LOGIN_REDIRECT_URL в addressbook/settings.py так, чтобы при первом входе пользователь перенаправлялся бы на страницу со списком контактов.
LOGIN_REDIRECT_URL = '/'
Теперь, когда мы можем войти и выйти, было бы неплохо показывать в заголовке имя вошедшего пользователя и ссылки для входа/выхода. Добавим их в наш шаблон base.html, т.к. нам нужно отображать их везде.
<body>
    <div>
    {{ user }}
    {% if user.is_anonymous %}
    <a href="{% url 'django.contrib.auth.views.login' %}">Войти</a>
    {% else %}
    <a href="{% url 'django.contrib.auth.views.logout' %}">Выйти</a>
    {% endif %}
    </div>

воскресенье, 2 июля 2017 г.

Краткий учебник по Django

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

О терминологии веб-фреймворка Django

  • Проект - совокупность приложений, имеющих общие настройки.
  • Приложение - часть проекта, выполняющая определённую логически неделимую функцию. Состоит из представлений (views), шаблонов (templates) и моделей (models).
  • Шаблон - шаблон HTML-страницы. В терминологии MVC шаблону соответствует представление.
  • Модель - средство доступа к данным.
  • Представление - связующий код между моделью и шаблоном. В терминологии MVC представлению соответствует контроллеру.
  • Маршрут - соответствие между URL'ом и представлением (контроллером в терминологии MVC), отвечающим за этот URL.
Программы, написанные с использованием Django, являются совокупностью отдельных приложений, объединённых в один проект. В отличие от многих других веб-фреймворков, в Django не используется архитектура MVC - Модель-Представление-Контроллер, вместо неё используется собственная архитектура MVT - Модель-Представление-Шаблон. Шаблон Django по функциям ближе всего к представлению в MVC, а представление Django по функциям ближе всего к контроллеру в MVC. Представления Django привязываются к определённому URL через маршруты.

Установка Python и Django

В Debian и производных от него дистрибутивах установить Python и Django можно из репозиториев:
# apt-get install python python-django

Создание проекта и приложения

Создаём проект dj:
$ django-admin startproject dj
Переходим в каталог проекта:
$ cd dj
Смотрим содержимое каталога проекта:
$ find .
.
./manage.py
./dj
./dj/wsgi.py
./dj/settings.py
./dj/__init__.py
./dj/urls.py
Создаём в проекте новое приложение app:
$ ./manage.py startapp app
В каталоге проекта появится новый каталог с именем app, созданный специально для размещения приложения:
$ find .
.
./manage.py
./dj
./dj/wsgi.py
./dj/settings.py
./dj/__init__.py
./dj/urls.py
./dj/settings.pyc
./dj/__init__.pyc
./app
./app/tests.py
./app/views.py
./app/models.py
./app/__init__.py

Создание представления

Создадим новое представление hello в приложении app. Представление принимает в качестве аргумента объект HttpRequest и возвращает объект HttpResponse.

Откроем для редактирования файл app/views.py и придадим ему следующий вид:
# -*- coding: UTF-8 -*-
from django.http import HttpResponse

def hello(request):
    return HttpResponse(u'Здравствуй, мир!')

Создание маршрута

Теперь настроим маршрут, вызывающий это представление для url. Для этого откроем файл dj/urls.py и добавим в него пару строчек.

Первую строчку добавим в начало файла, после других строчек импорта. Строчка импортирует представление hello из приложения app:
from app.views import hello
Теперь найдём функцию patterns, возвращаемое значение которой присваивается переменной urlpatterns, и впишем в аргументы функции следующую строчку:
('^hello/$', hello),
В итоге у меня содержимое файла dj/urls.py приняло следующий вид:
from django.conf.urls import patterns, include, url
from app.views import hello

# Uncomment the next two lines to enable the admin:
# from django.contrib import admin
# admin.autodiscover()

urlpatterns = patterns('',
    # Examples:
    # url(r'^$', 'dj.views.home', name='home'),
    # url(r'^dj/', include('dj.foo.urls')),

    # Uncomment the admin/doc line below to enable admin documentation:
    # url(r'^admin/doc/', include('django.contrib.admindocs.urls')),

    # Uncomment the next line to enable the admin:
    # url(r'^admin/', include(admin.site.urls)),
    ('^hello/$', hello),
)
В шаблоне url в конце обязательно должна быть косая черта, т.к. если клиент запросит страницу без косой черты в конце, Django автоматически добавит её и попытается найти представление, соответствующее этому URL. Нужно ли добавлять косую черту, регулируется настройкой APPEND_SLASH. Если установить её в False, то косая черта добавляться не будет. Шаблоном для корня сайта является '^$', то есть соответствие пустой строке.

Включение приложения в проекте

Чтобы в проекте использовалось наше приложение, его нужно подключить к проекту. Для этого закомментируем на время список стандартных приложений в файле dj/settings.py и пропишем наше приложение:
INSTALLED_APPS = (
    #'django.contrib.auth',
    #'django.contrib.contenttypes',
    #'django.contrib.sessions',
    #'django.contrib.sites',
    #'django.contrib.messages',
    #'django.contrib.staticfiles',
    # Uncomment the next line to enable the admin:
    # 'django.contrib.admin',
    # Uncomment the next line to enable admin documentation:
    # 'django.contrib.admindocs',
    'app',
)
Также закомментируем в настройках подключение приложений-прослоек:
MIDDLEWARE_CLASSES = (
    #'django.middleware.common.CommonMiddleware',
    #'django.contrib.sessions.middleware.SessionMiddleware',
    #'django.middleware.csrf.CsrfViewMiddleware',
    #'django.contrib.auth.middleware.AuthenticationMiddleware',
    #'django.contrib.messages.middleware.MessageMiddleware',
    # Uncomment the next line for simple clickjacking protection:
    # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
)

Запуск сервера разработчика

Теперь настало время запустить сервер разработчика с только что созданным простейшим приложением:
$ ./manage.py runserver 0.0.0.0:8000
Можно попытаться зайти в браузере на страницу /hello/. У меня ссылка для открытия страницы выглядела так:
http://localhost:8000/hello/
Итак, мы создали наш первый проект на Django, который при обращении к странице /hello/ выводит надпись "Здравствуй, мир!" Возможности Django в этом проекте практически не используются - мы не использовали ни моделей, ни шаблонов, но этот проект даёт общее представление о структуре программ, написанных с использованием Django.

Использование шаблонов

Для начала настроим каталог, в котором будут находиться шаблоны. Для этого отредактируем файл dj/settings.py и впишем в кортеж TEMPLATES_DIRS полный путь к каталогу с шаблонами.

После редактирования файла эта настройка в файле settings.py приняла следующий вид:
TEMPLATE_DIRS = (
    # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
    # Always use forward slashes, even on Windows.
    # Don't forget to use absolute paths, not relative paths.
    "/home/stupin/dj/templates",
)
Не забывайте в конце одноэлементного кортежа поставить запятую, чтобы Python мог отличить кортеж от простого выражения в скобках.

Теперь создадим каталог для шаблонов:
$ mkdir templates
И создадим в нём новый шаблон с именем time.tmpl и со следующим содержимым:
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Текущее время</title>
  </head>
  <body>
    <div align="center">
      Сейчас {{ time }}
    </div>
  </body>
</html>
Теперь добавим в файл app/views.py импорт функции для загрузки и отрисовки шаблона:
from django.shortcuts import render_to_response
И создадим представление current_datetime следующим образом:
def current_datetime(request):
    return render_to_response('time.tmpl', {'time' : datetime.now()})
Осталось настроить в файле urls.py маршрут к этому представлению:
('^time/$', current_datetime),
Если сейчас попробовать открыть страницу http://localhost:800/time/, то можно увидеть текущее время на английском языке в часовом поясе Гринвичской обсерватории.

Настройка часового пояса и языка

Чтобы время отображалось правильно - в часовом поясе сервера, пропишем в файл настроек местный часовой пояс. Для этого откроем файл dj/settings.py и пропишем в переменную TIME_ZONE значение 'Asia/Yekaterinburg':
TIME_ZONE = 'Asia/Yekaterinburg'
Чтобы время на странице отображалось в соответствии с правилами, принятыми в России, пропишем в файл настроек язык проекта. Откроем файл dj/settings.py и пропишем в переменную LANGUAGE_CODE значение 'ru-RU':
LANGUAGE_CODE = 'ru-RU'
Теперь текущее время должно отображаться на русском языке в часовом поясе, по которому живёт Уфа :)

Пример более сложного маршрута

Попробуем добавить страницы, которые будут вычитать из текущего времени часы, фигурирующие в URL запрошенной страницы. Для этого в файл dj/urls.py добавим маршруты:
(r'^time/plus/(\d{1,2})$', hours_plus),
(r'^time/minus/(\d{1,2})$', hours_minus),
В каталоге с шаблонами templates разместим два новых шаблона.

Файл templates/time_minus.tmpl со следующим содержимым:
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Прошлое время</title>
  </head>
  <body>
    <div align="center">
      {{ delta }} часов назад было {{ time }}
    </div>
  </body>
</html>
Файл templates/time_plus.tmpl со следующим содержимым:
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Будущее время</title>
  </head>
  <body>
    <div align="center">
      Через {{ delta }} часов будет {{ time }}
    </div>
  </body>
</html>
В файл app/views.py пропишем два представления, которые будут использовать два новых шаблона:
def hours_plus(request, delta):
    try:
      delta = int(delta)
    except ValueError:
      raise Http404()
    time = datetime.now() + timedelta(hours=delta)
    return render_to_response('time_plus.tmpl', {'delta' : delta,
                                                 'time' : time})

def hours_minus(request, delta):
    try:
      delta = int(delta)
    except ValueError:
      raise Http404()
    time = datetime.now() - timedelta(hours=delta)
    return render_to_response('time_minus.tmpl', {'delta' : delta,
                                                  'time' : time})
Файл app/views.py целиком примет следующий вид:
# -*- coding: UTF-8 -*-
from django.http import HttpResponse, Http404
from django.shortcuts import render_to_response
from datetime import datetime, timedelta

def hello(request):
    return HttpResponse(u'Здравствуй, мир!')

def current_datetime(request):
    return render_to_response('time.tmpl', {'time' : datetime.now()})

def hours_plus(request, delta):
    try:
      delta = int(delta)
    except ValueError:
      raise Http404()
    time = datetime.now() + timedelta(hours=delta)
    return render_to_response('time_plus.tmpl', {'delta' : delta,
                                                 'time' : time})

def hours_minus(request, delta):
    try:
      delta = int(delta)
    except ValueError:
      raise Http404()
    time = datetime.now() - timedelta(hours=delta)
    return render_to_response('time_minus.tmpl', {'delta' : delta,
                                                  'time' : time})
Теперь можно перейти по ссылкам http://localhost:8000/time/plus/1 или http://localhost:8000/time/plus/2 и увидеть получающиеся страницы.

Более сложные шаблоны

Условие:
{% if today_is_weekend %}
    <p>Сегодня выходной!</p>
{% endif %}
Условие с двумя вариантами:
{% if today_is_weekend %}
    <p>Сегодня выходной!</p>
{% else %}
    <p>Пора работать.</p>
{% endif %}
Ложным значениями являются: пустой список [], пустой кортеж (), пустой словарь {}, ноль - 0, объект None и объект False.

Можно использовать сочетания условий при помощи and и or, причём and имеет более высокий приоритет. Скобки в условиях не поддерживаются, без них можно обойтись с помощью вложенных условий. Также возможно использовать операторы ==, !=, <, >, >=, <= и in для вычисления условий, по смыслу совпадающих с условными операторами в самом Python. Циклы для перебора значений из списка:
{% for athlete in athlete_list %}
  <li>{{ athlete.name }}</li>
{% endfor %}
Можно перебирать значения из списка в обратном порядке:
{% for athlete in athlete_list reversed %}
...
{% endfor %}
Внутри циклов существуют следующие переменные:
  • {{ forloop.counter }} - номер итерации, начиная с 1,
  • {{ forloop.counter0 }} - номер итерации, начиная с 0,
  • {{ forloop.revcounter }} - количество оставшихся итераций, включая текущую. Внутри первой итерации равно количеству элементов, на последней итерации - 1,
  • {{ forloop.revcounter }} - количество оставшихся итераций. Внутри первой итерации содержит количество элементов за минус единицей, на последней итерации - 0,
  • {% if forloop.first %} - условие выполняется на первой итерации,
  • {% if forloop.last %} - условие выполняется на последней итерации,
  • forloop.parentloop - ссылка на объект родительского цикла.
Комментарии в шаблоне:
{# Это комментарий #}
или
{% comment %}
Многострочный
комментарий.
{% endcomment %}

Включение подшаблонов

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

Включение подшаблона из файла:
{% include "includes/nav.html" %}
Включение подшаблона из переменной:
{% include template_name %}

Наследование шаблонов

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

Продолжим эксперименты с нашим тестовым проектом, добавив в него базовый шаблон.

Базовый шаблон base.tmpl:
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>{% block title %}{% endblock %}</title>
  </head>
  <body>
    <h1>Бесполезный сайт с часами</h1>

    {% block content %}{% endblock %}

    {% block footer %}
    <hr>
    <p>Благодарим за посещение нашего сайта.</p>
    {% endblock %}
  </body>
</html>
В базовом шаблоне помечаются блоки, в которых прописывается часть шаблона, специфичная для этой страницы.

Производный шаблон. Для примера приведу только содержимое шаблона time_plus.tmpl:
{% extends "base.tmpl" %}

{% block title %}Будущее время{% endblock %}

{% block content %}
<p>Через {{ delta }} часов будет {{ time }}</p>
{% endblock %}
В производном шаблоне указывается базовый шаблон и переопределяется содержимое блоков базового шаблона. Шаблоны других страниц можно отредактировать сходным образом, чтобы и они использовали дизайн, общий для всех страниц.

Настройка базы данных MySQL

После того, как мы рассмотрели шаблоны Django, пришло время заняться моделями Django. Но прежде чем приступить непосредственно к изучению моделей, нужно установить необходимые модули, выставить настройки как в самой СУБД, так и в проекте Django.

Установим модуль для доступа к базе данных MySQL из Python (разработчики фреймворка Django рекомендуют использовать PostgreSQL, но мы воспользуемся MySQL, поддержка которого тоже имеется):
# apt-get install python-mysqldb
Настроим кодировку сервера MySQL и порядок сортировки. Для этого в файле /etc/mysql/my.cnf в секцию [mysqld] впишем следующие настройки:
character_set_server=utf8
collation_server=utf8_unicode_ci
Перезапустим сервер базы данных:
# /etc/init.d/mysql restart
В результате вышеописанных действий должен получиться такой результат:
mysql> show variables like 'coll%';
+----------------------+-----------------+
| Variable_name        | Value           |
+----------------------+-----------------+
| collation_connection | utf8_general_ci |
| collation_database   | utf8_unicode_ci |
| collation_server     | utf8_unicode_ci |
+----------------------+-----------------+
3 rows in set (0.00 sec)

mysql> show variables like 'char%';
+--------------------------+----------------------------+
| Variable_name            | Value                      |
+--------------------------+----------------------------+
| character_set_client     | utf8                       |
| character_set_connection | utf8                       |
| character_set_database   | utf8                       |
| character_set_filesystem | binary                     |
| character_set_results    | utf8                       |
| character_set_server     | utf8                       |
| character_set_system     | utf8                       |
| character_sets_dir       | /usr/share/mysql/charsets/ |
+--------------------------+----------------------------+
8 rows in set (0.00 sec)
Создадим базу данных для проекта:
CREATE USER app@localhost IDENTIFIED BY "app_password";
CREATE DATABASE app;
GRANT ALL ON app.* TO app@localhost;
Пропишем в файл настроек проекта dj/settings.py настройки подключения к базе данных:
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'.
        'NAME': 'app',                        # Or path to database file if using sqlite3.
        'USER': 'app',                        # Not used with sqlite3.
        'PASSWORD': 'app_password',           # Not used with sqlite3.
        'HOST': '',                           # Set to empty string for localhost. Not used with sqlite3.
        'PORT': '',                           # Set to empty string for default. Not used with sqlite3.
    }
}
Проверяем правильность настройки:
$ ./manage.py shell
Если всё правильно, то откроется отладочная оболочка Python. Попробуем ввести следующие команды:
from django.db import connection
cursor = connection.cursor()
Если сообщений об ошибках не было, значит всё настроено правильно.

Создание моделей

Каждая модель Django описывают одну таблицу в базе данных. В Django имеется система отображения таблиц в объекты - ORM - Object-Relational Mapping - объектно-реляционное отображение. ORM позволяет манипулировать единичными строками таблиц как объектами Python, а также осуществлять операции массовой выборки, обновления и удаления строк.

В качестве примера, рассмотрим адресный справочник домов в городах. Откроем файл app/models.py и приведём его к следующему виду:
# -*- coding: UTF-8 -*-
from django.db import models

class City(models.Model):
    country = models.ForeignKey(Country)
    title = models.CharField(max_length=150)

class Street(models.Model):
    city = models.ForeignKey(City)
    title = models.CharField(max_length=150)

class Area(models.Model):
    city = models.ForeignKey(City)
    title = models.CharField(max_length=150)

class House(models.Model):
    area = models.ForeignKey(Area)
    street = models.ForeignKey(Street)
    house = models.IntegerField()
    frac = models.CharField(max_length=30)
    comment = models.CharField(max_length=100)
Мы описали объекты, составляющие адресный справочник, и описали взаимоотношения между ними через внешние ключи - ForeignKey. В городе имеются улицы и районы, а каждый дом находится на одной улице и принадлежит одному из районов города.

В Django есть не только текстовые поля и внешние ключи. Имеются числовые поля, списковые поля, логические поля, поля для связей один-к-одному и для связей многие-ко-многим. У полей можно прописать значения по умолчанию, разрешить или запретить использовать значение NULL и запретить или разрешить вводить в строковые поля пустые строки. У поля можно прописать его имя в базе данных, выставить признак - нужно ли создавать индекс по этому полю, можно прописать текстовое описание поля, которое будет использоваться в веб-интерфейсе администратора и в объектах форм Django. У классов моделей, в свою очередь, можно тоже указывать их текстовые описания, прописывать составные индексы, ограничения уникальности записей и т.п. Создание моделей - это обширная тема, рассмотреть которую сколь-нибудь подробно в рамках этого небольшого учебника вряд ли получится.

Теперь мы можем проверить правильность синтаксиса и логики моделей:
$ ./manage.py validate
Чтобы увидеть команды SQL для создания структуры базы данных, требуемой для моделей из приложения app, введём следующую команду:
$ ./manage.py sqlall app
Чтобы выполнить эти операторы SQL и создать в базе данных таблицы, соответствующие моделям, нужно выполнить следующую команду:
$ ./manage.py syncdb
Можно войти в базу данных клиентом и увидеть созданную структуру таблиц и их взаимосвязи.

Для входа в базу данных с настройками проекта, можно воспользоваться следующей командой:
$ ./manage.py dbshell

Создание записей в таблицах

Откроем оболочку Python:
$ ./manage.py shell
Импортируем описание моделей:
from address.models import *
Создадим объект "город":
c = City(title=u'Уфа')
И сохраним его в базу данных:
c.save()
Теперь создадим объект "улица" в этом городе:
s = Street(title=u'ул. Карла Маркса', city=c)
И сохраним объект с улицей:
s.save()
Если нужно отредактировать объект, то можно прописать в него новое свойство и сохранить:
c.title = u'г. Уфа'
c.save()
Недостаток такого рода редактирования объектов заключается в том, что в базе данных обновляются все поля в отредактированной строке таблицы, а не только то поле, которое действительно было изменено. Для раздельного редактирования полей строк можно воспользоваться массовым редактированием, о котором будет рассказано далее.

Извлечение записей из таблиц

Откроем оболочку Python:
$ ./manage.py shell
Импортируем описание модели:
from address.models import *
Загрузим все объекты типа "город":
c = City.objects.all()
Загрузим объект "город", имеющий имя "г. Уфа":
c = City.objects.filter(title=u'г. Уфа')
И убедимся, что загрузился именно он:
print c
Выбор списка объектов:
  • Obj.objects.all() - отобрать как список все объекты типа Obj,
  • Obj.objects.filter(field='') - отобрать как список объекты, у которых поле field имеет указанное значение,
  • Obj.objects.filter(field1='', field2='') - отобрать как список объекты, у которых оба поля одновременно имеют указанные значения,
  • Obj.objects.filter(field__contains='') - отобрать как список объекты, у которых в указанном поле содержится указанное значение,
  • Obj.objects.filter(field__icontains='') - отобрать как список объекты, у которых в указанном поле содержится указанное значение без учёта регистра,
  • Obj.objects.filter(field__iexact='') - отобрать как список объекты, у которых указанное поле совпадает с указанным значением без учёта регистра,
  • Obj.objects.filter(field__startswith='') - отобрать как список объекты, у которых указанное поле начинается с указанного значения,
  • Obj.objects.filter(field__istartswith='') - отобрать как список объекты, у которых указанное поле начинается с указанного значения без учёта регистра,
  • Obj.objects.filter(field__endswith='') - отобрать как список объекты, у которых указанное поле заканчивается указанным значением,
  • Obj.objects.filter(field__iendswith='') - отобрать как список объекты, у которых указанное поле заканчивается указанным значением без учёта регистра.
Условия в фильтре можно комбинировать между собой не только указывая их через запятую, но создавая каскады фильтров:
Obj.objects.filter(field1__iendswith='a').filter(field2='b')
Чтобы отобрать объекты, не подходящие под указанное условие, можно воспользоваться методом фильтрации exclude. Например, следующее выражение отберёт те записи, у которых начало первого поля без учёта регистра совпадает с a, а второе поле не равно b:
Obj.objects.filter(field1__iendswith='a').exclude(field2='')
Выбор одного объекта осуществляется точно таким же способом, как выбор списка, за исключением того, что вместо метода filter используется метод get:
Obj.objects.get(field='')
Если ни один объект не был найден, будет сгенерировано исключение Obj.DoesNotExist. Если указанным критериям соответствует несколько записей, то будет сгенерировано исключение Obj.MultipleObjectsReturned.

Как и в случае выборки списка объектов, можно комбинировать фильтры друг с другом. Но для выборки одного объекта последним методом в цепочке должен быть get. Например, вот так:
Obj.objects.filter(field1__iendswith='a').exclude(field2='').get()
Или, что то же самое, вот так:
Obj.objects.exclude(field2='').get(field1__iendswith='a')
В Django версий 1.6 и более поздних имеется метод first(), не принимающий аргументов, который возвращает первую запись из списка, если она есть. В противном случае возвращается None. Стоит учитывать, что этот метод никак не обрабатывает случаи, когда условию соответствует несколько записей сразу.

Сортировка данных:
  • Obj.objects.order_by("field") - сортировка по одному полю,
  • Obj.objects.order_by("-field") - сортировка по одному полю в обратном порядке,
  • Obj.objects.order_by("field1", "field2") - сортировка по двум полям.
Сортировку по умолчанию можно настроить в свойствах объекта модели, добавив вложенный класс со свойством ordering, которому присвоен список полей для сортировки:
    class Meta:
        ordering = ["field"]
Можно комбинировать методы:
Obj.objects.filter(field="").order_by("-field")
Можно выбирать необходимый фрагмент списка объектов. Например, вот этот запрос вернёт два первых объекта:
Obj.objects.filter(field="").order_by("-field")[0:2]
Массовое обновление объектов осуществляется следующим образом:
Obj.objects.filter(id=52).update(field='')
Запрос возвращает количество обновлённых строк таблицы.

Удалить один объект можно следующим образом:
o = Obj.objects.get(field='')
o.delete()
Удалить объекты массово можно так:
Obj.objects.filter(field='').delete()

Активация интерфейса администратора

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

Для включения интерфейса администрирования нужно внести изменения в файл настроек dj/settings.py:
  1. Вписать в INSTALLED_APPS приложения django.contrib.admin, django.contrib.auth, django.contrib.sessions и django.contrib.contenttypes,
  2. Вписать в MIDDLEWARE_CLASSES приложения-прослойки django.middleware.common.CommonMiddleware, django.contrib.sessions.middleware.SessionMiddleware, django.contrib.auth.middleware.AuthenticationMiddleware и django.contrib.messages.middleware.MessageMiddleware.
Вносим изменения в базу данных, чтобы в ней создались таблицы, необходимые для работы интерфейса администрирования:
$ ./manage.py syncdb
Если при этом отказаться от создания суперпользователя, то потом его можно создать с помощью команды:
$ ./manage.py createsuperuser
Теперь в начало файла dj/urls.py добавим использование модуля и его инициализацию:
from django.contrib import admin
admin.autodiscover()
И пропишем маршрут к интерфейсу администрирования:
urlpatterns = patterns('',
    # ...
    (r'^admin', include(admin.site.urls)),
    # ...
)
Для того, чтобы объекты можно было редактировать прямо из интерфейса администратора, нужно создать в каталоге приложения app файл admin.py со следующим содержимым:
from app.models import *
from django.contrib import admin

admin.site.register(City)
admin.site.register(Area)
admin.site.register(Street)
admin.site.register(House)
После этого можно перейти по ссылке http://localhost:8000/admin/, войти под учётными данными, указанными команде createsuperuser, и пользоваться интерфейсом администрирования для добавления, редактирования и удаления записей в таблицах.

Как уже было сказано, интерфейс администрирования поддаётся глубокой и тонкой настройке, но его настройка выходит за рамки этого учебника.

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