Рецепты от ПанГурмана

в 10:21, , рубрики: ajax, django, тесты, метки: , ,

Недавно запустили сервис по бронированию ресторанов ПанГурман. Внутри это более-менее типичный django-сайт. Попробую рассказать, как там что устроено (с картинками). В статье не будет ничего супер-хитрого, но, надеюсь, кому-нибудь пара трюков или идей покажутся полезными и как-то упростят жизнь.

Набор инструментов стандартный — PostgreSQL, Django 1.4 (+GeoDjango), Sentry, Celery (+redis), Fabric.

Стартовая страница

image

На стартовой странице ничего необычного: форма для регистрации. Как уже писал, формы-вьюхи самопальные, django-registration выпилил с негодованием после того, как понял, что не использую оттуда ни одной вьюхи или формы, пытаюсь заманкипатчить модель, а баг-трекер у проекта отключен :)

При регистрации отправляется письмо с приветствием, оно сверстано как html. Для отправки html-писем используем templated-emails: в templates есть папка emails, в которой хранятся все письма. Каждое письмо — в своей папке, содержащей 3 файла: email.html (html-версия письма), email.txt (текстовая версия письма) и short.txt (тема).

image

Отправляются письма вот так:

send_templated_email([user], 'emails/activation', context=context)

в список адресатов можно передать User-ов или просто емейл-адреса. Ерунда, но просто и удобно, на разных проектах используем уже довольно долго этот подход.

С html-письмами еще одна проблема есть — не очень понятно, как их при разработке смотреть без постоянной отправки себе. Чтоб локально проверять, используем django-eml-email-backend (тупейшее приложение с email backend'ом, сохраняющим письма в eml-файлы вместо отправки). Eml-файлы большинство почтовых клиентов открывают без проблем (на маке даже по пробелу предпросмотр есть).

Ок, зарегались

После регистрации и настройки аккаунта человек попадает на главную страницу.

image

На ней можно обратить внимание на форму поиска.

image

Элементы изначально были сверстаны примерно так:

    <div class="b-filter_selector time">
        <select name="persons" data-behavior="Chosen">
            <option value="1">1 персону</option>
            <option value="2" selected="selected>2 персоны</option>
            <option value="3">3 персоны</option>
        </select>
    </div>

Ребята стараются придерживаться БЭМ, инпуты нередко заворачиваются в div и им часто нужно приписывать какие-то css-классы, data-атрибуты и тд.

Зашивать html в питоньем коде — дурной тон, html должен быть в шаблонах. Нормального встроенного средства влиять на рендеринг элементов форм в шаблоне в джанге нет (а нам тут нужно добавить data-behavior к полю). Из-за таких штук очень активно используем django-widget-tweaks (bitbucket, github) при прикручивании верстки, это приложение позволяет процесс прикручивания упростить и обескостылить.

<div class="b-filter_selector time">
    {% render_field search_form.persons data-behavior='Chosen' %}
</div>

Если нужна какая-то стандартная обвязка для поля, делаем html-сниппет, который можно потом подключать через {% include %} (примерно так):

    {% comment %}
    Пример:

        {% include "inc/input.html" with field=form.query div_classes="search" no_label=0 %}

    {% endcomment %}
    {% load widget_tweaks %}
    {% if no_label != 1 %}{{ field.label_tag }}{% endif %}
    <div class="b-input {{ div_classes }} {% if field.errors %}error{% endif %}">
        {{ field|add_class:'b-input_input' }}
    </div>

То же самое для вывода ошибок.

{% render_field %} из django-widget-tweaks умеет также дописывать атрибуты (css-классы, например) к уже имеющимся:

    {% render_field form.emails rows="2" class+="b-input_textarea gray" autofocus="" %}
    {% include "inc/errors.html" with errors=form.emails.errors %}
Блок «Доступны сегодня»

image

В этом блоке у нас 5 ресторанов, которые доступны для бронирования сегодня. Получаются они вот так:

restaurants = Restaurant.objects.enabled().free_today_in_city(city).prefetch_related('scenes')[:5]

Как видите, тут строится цепочка своих методов (.enabled().free_today_in_city(city)). Встроенный джанговский подход — вынесение таких штук в менеджер — этого делать не позволяет, т.к. методы менеджера возвращают QuerySet, у которого кастомных методов уже нет — для фильтрации, получается, можно только 1 свой метод использовать.

Чтоб обойти это ограничение, используем PassThroughManager из django-model-utils и пишем QuerySet вместо менеджера:

    class RestaurantQueryset(QuerySet):
        def enabled(self):
            return self.filter(enabled=True)

        def free_today_in_city(self, city):
            today = city.now().date()
            return self.filter(city=city).free(today)

        def free(self, date):
            # ...

    class Restaurant(models.Model):
        # ...
        objects = PassThroughManager.for_queryset_class(RestaurantQueryset)()

Эдакие «менеджеры на стероидах» получаются (картинку решил не подбирать :)). Их по всему проекту вместо стандартных менеджеров используем.

Кликаем на «Посмотреть все»

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

image

У меня есть небольшой личный «пунктик» по поводу js: сайтом должно быть можно пользоваться без js. Это дает при разработке некоторые преимущества. Помимо очевидных (ошибка в js не делает сайт неработоспособным) есть и менее очевидные: например, такой сайт проще тестировать (можно больше всего проверить без помощи selenium) + возможно, проще писать)

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

    {% extends 'base.html' %}
    <!-- обвязка: шапка, форма поиска и тд -->
    ...
    <div class="b-search-results_result">
        {% include 'restaurants/ajax/list.html' %}
    </div>
    ...
    <!-- обвязка: форма поиска, подвал и тд -->

Аяксовый шаблон:

    <ul class="b-search-results_result_list">
    {% for restaurant in restaurants %}
        <li>...</li>
    {% endfor %}
    </ul>

Шаблон выбирается на основе request.is_ajax(); на клиенте есть декларативный js, который умеет любую обычную html-форму превращать в аяксовую:

    def restaurant_list(request, city_slug):
        # ...
        response = TemplateResponse(request, 'restaurants/list.html', {...})
        if request.is_ajax():
            response.template_name = 'restaurants/ajax/list.html'
        return response

В ПанГурмане решил немного с этим поэкспериментировать. Сначала это был декоратор @ajax_template, который добавлял вьюхе аяксовость:

    @ajax_template('restaurants/ajax/list.html')
    def restaurant_list(request, city_slug):
        # ...
        return TemplateResponse(request, 'restaurants/list.html', {...})

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

Но в этом варианте повторяется 'restaurants/(ajax/)?list.html' (что надоедает и является источником ошибок), + у вьюхи может быть несколько точек возврата и не все нужно делать аяксовыми (а значит вариант хуже, чем простая проверка без декоратора).

Поэтому от @ajax_template отказались и пока остановились на таком варианте:

    def restaurant_list(request, city_slug):
        # ...
        return show(request, 'restaurants/list.html', {...})

show — это наследник от TemplateResponse, который для аяксовых запросов пробует сначала шаблон в подпапке ajax. См. https://gist.github.com/2416695

show имеет еще пару методов, которых мне всегда не хватало — но, возможно, зря не хватало, т.к. с ним есть пара тонкостей и не все замечательно (например, .with_context упадет, если предыдущая вьюха вернула редирект), но использовать вполне можно (да и к аяксу проблемы имеют мало отношения).

Короче говоря, делаем сайт, как будто ajax-а не существует, а добавляем его потом, где нужно, вынесением нужной части шаблона в подпапку.

Что-то нашли, тыкаем в ресторан

Попадаем на страницу ресторана.

image

На ней тоже есть аякс (в форме заказа через ajax-запросы проверяется доступное время), но реализован он еще другим способом :)

Календарик строится на клиенте (без js будут обычные поля для ввода) и поэтому проще с сервера получать json, а не html. С помощью django-tastypie по-быстрому соорудил API, и через него json нужный отдается. Это не сильно дольше, чем написать вьюху, отдающую json, руками, но API все равно пригодится + разные плюшки вроде троттлинга и форматов выдачи нахаляву получаем.

В другом проекте использовали для API django-piston; субъективно — django-tastypie приятнее.

Очень примитивная (и не вполне полная) справка к API генерируется автоматически такой вот вьюхой:

    def _api_resources(api):
        resources = {}
        api_name = api.api_name

        for name in sorted(api._registry.keys()):
            resource = api._registry[name]
            resources[name] = {
                'list_endpoint': api._build_reverse_url("api_dispatch_list", kwargs={
                    'api_name': api_name,
                    'resource_name': name,
                }),
                'schema': api._build_reverse_url("api_get_schema", kwargs={
                    'api_name': api_name,
                    'resource_name': name,
                }),
                'doc': resource.__doc__,
                'resource': resource
            }
        return resources


    def browse(request, api):
        resources = _api_resources(api)
        return TemplateResponse(request, 'tasty_browser/index.html', {
            'api': api,
            'resources': resources
        })

Шаблон:

    <h1>API {{ api.api_name }}</h1>
    <table class='tastypie-api'>
        <tr><th>Resource</th><th>url</th><th>Structure</th><th>Description</th></tr>
        {% for name, resource in resources.items %}
            <tr>
                <td>{{ name }}</td>
                <td>
                    <a href='{{ resource.list_endpoint }}?format=json'>
                    {{ resource.list_endpoint }}</a>
                </td>
                <td>
                    <a href='{{ resource.schema }}?format=json'>
                    {{ resource.schema }}</a>
                </td>
                <td>
                    {{ resource.doc }}
                </td>
            </tr>
        {% endfor %}
    </table>

Можно посмотреть тут: http://pangurman.ru/api-docs/

Оплата прикручена через django-robokassa (потом еще варианты добавятся), смс-уведомления о брони — через imobis + celery.

Финансы

image

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

Начитавшись Фаулера, уже несколько лет использую следующий подход к реализации «личного счета»: сумма на счете нигде не хранится (впрочем, может кешироваться иногда), в базе храним операции на личном счете. Это позволяет естественным образом получить историю операций + уберегает от ошибок.

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

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

    class MoneyTransfer(models.Model):

        PURCHASE = 'purchase'
        ROBOKASSA = 'robokassa'
        INVITE_BONUS = 'invite-bonus'
        REFUND = 'refund'
        PROMOCODE = 'promocode'

        TRANSFER_TYPE_CHOICES = (
            (u'Снятие со счета', (
                (PURCHASE, u'Оплата бронирования'),
            )),
            (u'Пополнение счета', (
                (ROBOKASSA, u'Платеж через Робокассу'),
                (INVITE_BONUS, u'Бонус за приглашение друга'),
                (PROMOCODE, u'Активация промокода'),
                (REFUND, u'Возврат'),
            )),
        )

        user = models.ForeignKey(User, verbose_name=u'Пользователь')
        amount = models.DecimalField(u'Сумма', max_digits=10, decimal_places=2)
        created_at = models.DateTimeField(u'Дата/время операции', auto_now_add=True)
        comment = models.CharField(u'Описание', max_length=255, blank=True, null=True)

        transfer_type = models.CharField(u'Тип', max_length=20,
                                         null=True, blank=True,
                                         choices = TRANSFER_TYPE_CHOICES)

        content_type = models.ForeignKey(ContentType, null=True, blank=True)
        object_id = models.PositiveIntegerField(null=True, blank=True)
        reason = GenericForeignKey('content_type', 'object_id')

Тут можно обратить внимание на способ задания choices. Писать больше, зато нет магических констант и по ошибке не запишешь в базу 'invite_bonus' вместо 'invite-bonus' — MoneyTransfer.INVITE_BONUS всегда один и сразу упадет с AttributeError, если его написать неправильно (да и автокомплит появляется).

Столик-то забронировали, но стоп, а как это работает?

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

image

В django для этого есть flatpages, но мы обычно используем другой подход: все страницы делаем обычными вьюхами с нормальными шаблонами, храним все в VCS, а куски текста, которые нужно редактировать, помечаем блоками из django-flatblocks:

    <div class="b-text">
        {% flatblock "how-it-works" 600 %}
    </div>

Для админов при наведении мышки на текст показывается ссылка на редактирование
(это сделано путем задания своего шаблона flatblocks/flatblock.html):

    {% load url from future %}
    {% load markup %}

    {% if user.is_staff %}
        <div class="b-flatblock-edit">
            <a class="flatblock-edit-link markitup-flatblocks-edit-icon"
               title = "Редактировать блок"
               href='{% url "edit_flatblock" flatblock.pk %}?next={{request.path}}'>
            </a>
            {{ flatblock.content|markdown:"video" }}
        </div>
    {% else %}
        {{ flatblock.content|markdown:"video" }}
    {% endif %}

В блоках хратится разметка в markdown, на странице редактирования показываем виджет из django-markitup (с предпросмотром):

image

Все тексты на сайте, в которых допустима какая-то разметка, редактируются с помощью django-markitup (вдохновенные описания ресторанов с фотографиями, например). Фильтр markdown настроен таким образом, что не трогает html-теги, так что при необходимости в текст можно добавить любую разметку.
А, еще расширение прикручено ( python-markdown-video ), которое ссылки на youtube или vimeo в ролики превращает — по-хорошему это через oembed делать, наверное нужно.

Панель для ресторанов

Для ресторанов есть специальная панель, через которую они сейчас могут управлять временем работы (когда столики доступны, когда нет). Панель реализована через еще один отдельный экземпляр AdminSite. Туда можнут попасть пользователи, которые назначены «менеджерами ресторана». Тонкость в том, что у этих пользователей не должно быть флажка is_staff, а то они смогут заходить в обычную админку, чего бы не хотелось. А так — обычная кастомизированная админка.

[rant on]
Часто пишут, что, мол, джанговская админка негибкая и тд. — так и не понял, что люди конкретно имеют в виду :) Админка предоставляет CRUD, верстку и дизайн «из коробки», и если знать, что можно настроить, а что сложно, то проблем не возникает. В качестве православных альтернатив обычно предлагают какие-то фреймворки для написания дэшбордов — так это штука перпендикулярная CRUD, и для джанговской админки такие фреймворки есть (django-admin-tools, nexus).

Если какое-то взаимодействие не укладывается в CRUD — никаких проблем, ничто не мешает написать свою вьюху и поставить на нее ссылку откуда-то из админки (readonly_fields и list_display тут часто удобны; можно и шаблон переопределить). Выкинуть или переписать админку желания за несколько лет ни разу не возникало, используем ее во всех проектах, экономит кучу времени. Возможно, специфика проектов такая, или я действительно что-то очевидное упускаю, не знаю.
[rant off]

В админке вместо raw_id_fields активно используем приложение со страшным названием django-salmonella, которое «заражает» виджеты для выбора связанных записей полезным поведением (для FK и M2M:

image

image

Кроме id объекта становится видно его название (обновляется аяксом); по клику на название можно сразу попасть на страничку изменения объекта, что тоже удобно.

Тесты

Для тестов стали использовать factory-boy (форк), про который уже было на хабре. Со времени той записи в factory-boy появилась одна супер-полезная штука: RelatedFactory. Это как SubFactory, только наоборот (например, можно сделать фабрику для пользователя, которая будет сразу создавать ему профиль). Документацию можно почитать тут. Пример:

class ProfileF(factory.DjangoModelFactory):
    FACTORY_FOR = Profile
    city = factory.SubFactory(CityF)
    balance = 0

    @classmethod
    def _prepare(cls, create, **kwargs):
        # поддержка 'balance=555'
        balance = kwargs.pop('balance')
        profile = super(ProfileF, cls)._prepare(create, **kwargs)
        if balance > 0:
            MoneyTransferF(user=profile.user, amount=balance)
        return profile

class UserF(factory.DjangoModelFactory):
    FACTORY_FOR = User

    username = factory.Sequence(lambda n: "username%s" % n)
    first_name = factory.Sequence(lambda n: u"Имя %s" % n)
    last_name = factory.Sequence(lambda n: u"Фамилия %s" % n)
    email = factory.Sequence(lambda n: "email%s@example.com" % n)
    is_staff = False
    is_active = False
    is_superuser = False
    password = '123'
    profile = factory.RelatedFactory(ProfileF, 'user')

    @classmethod
    def _prepare(cls, create, **kwargs):
        kwargs['password'] = make_password(kwargs['password'])
        return super(UserF, cls)._prepare(create, **kwargs)

Теперь в тестах можно так писать:

user = UserF(profile__balance=100)

В качестве разминки сделал в factory-boy pull request с добавлением поддержки 3го питона, но 3й питон — это тема для отдельного рассказа)

В Django 1.4 с тестами есть еще одна хитрость. По умолчанию для хеширования паролей стал использоваться алгоритм PBKDF2. У PBKDF2 одной из характеристик, обеспечивающих защищенность, является низкая скорость работы. Но кроме защищенности низкая скорость работы обеспечивает еще низкую скорость работы :) На основном сайте это совсем не проблема (пользователи не так часто регистрируются-логинятся, да и задержка невелика), а вот в тестах оказалось, что это штука критичная, особенно если использовать factory-boy и создавать много пользователей, назначая им пароль через хэш.

В случае ПанГурмана установка вот эта строчка в test_settings.py ускорила тесты в 2 раза:

    PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']

Автор: kmike

Поделиться

* - обязательные к заполнению поля