Pull to refresh

«Бар желаний» к 8 марта на Python и Pyramid

Reading time 12 min
Views 21K
Как поздравить девушек на работе с прекрасным праздником весны? В этом году хотелось сделать что-то необычное, чем-то удивить их в дополнение к традиционным подаркам и цветам. Так появилось веб-приложение «Бар желаний», созданное за один день с помощью Python и Pyramid.



Может быть, после прочтения статьи кто-то решит повторно использовать «Бар желаний» для поздравлений. Возможно, кто-то откроет для себя Pyramid — веб-фреймворк, прекрасно подходящий для быстрого создания небольших веб-проектов. Наконец, можно просто забрать исходный код приложения с GitHub для использования в своих целях.

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

Постановка задачи


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

«Бар желаний» — виртуальный бар, в котором девушка может выбрать все, что придется ей по душе, указать особые пожелания и оформить заказ. Этот заказ должен обработать ответственный сотрудник и организовать доставку выбранных «желаний» девушке. Меню бара включает такие «желания», как «Кусочек тортика» или «Клубника с мороженым» — десерты, фрукты, напитки. Меню должно быть составлено с юмором. Заказы должны обрабатываться оперативно — нельзя заставлять девушку ждать.

Первым делом — спецификация и проектирование. Определим сценарии использования.

  1. Девушка: открыть веб-приложение, выбрать из списка «желания», указать при необходимости особые пожелания в форме текста, оформить заказ, восхищаться какие ребята молодцы. При желании повторить процедуру.
  2. Официант (сотрудник-мужчина): оперативно получить оповещение об оформленном заказе, приготовить выбранный девушкой набор «желаний», принести «желания» девушке на рабочее место.

Для того, чтобы официант знал, кому нести выполненный заказ, необходимо различать пользователей веб-приложения. Достаточно знать имя девушки. Значит, попросим ее указать имя перед оформлением заказа. Авторизация нам не нужна: а) мы в локальной сети, злодеев нет; б) вряд ли девушки хотят заполнять формы авторизации в такой день. Зная имя, можно будет обратиться к ней в приложении по имени, это добавляет ощущение индивидуального подхода. Для пущей уверенности при оформлении заказа можно запомнить IP-адрес.

Оповещать официантов решено средствами Google Talk — свой jabber-сервер в локальной сети не прижился, google-аккаунты есть у многих, IM быстрее электронной почты. Наконец, у нас есть опыт работы с Google Talk на Python через xmpppy — на сервере в офисе работает скрипт, который периодически выбирает ответственного за полив цветов и отправляет ему сообщение-напоминание.

Веб-приложение


Дальнейшее описание предполагает, что вы знакомы с Python и понимаете основные принципы построения веб-приложений.

Начнем с создания директории проекта. Назовем проект wishbar (далее по тексту все пути к файлам даны относительно директории проекта). Создадим модуль веб-приложения (файл server.py):

from wsgiref.simple_server import make_server
from pyramid.config import Configurator

def create_app():
    config = Configurator()
    app = config.make_wsgi_app()
    return app

if __name__ == '__main__':
    app = create_app()
    server = make_server('0.0.0.0', 8080, app)
    server.serve_forever()

Для запуска веб-приложений используется простой минималистичный HTTP-сервер с поддержкой WSGI — модуль wsgiref.simple_server из стандартной библиотеки Python. Его вполне достаточно для нашего веб-приложения, как для разработки, так и использования в локальной сети. По крайней мере, пока. В конце статьи будет показано, как Pyramid-приложение подготовить для развертывания на сервере — в боевых условиях мы будем использовать другой сервер.

Проверим доступность приложения через браузер по адресу http://localhost:8080/. Сервер доступен, но возвращает только 404 Not found.

В веб-приложении нам потребуются стили, скрипты и изображения. Создадим директории js, css, img и добавим view-функции (виды, представления; англ. views) для обработки статики.

def create_app():
    config = Configurator()

    path = os.path.abspath(__file__)
    root = path[:path.rindex("/")]
    
    config.add_static_view("css", "{0}/css".format(root))
    config.add_static_view("js", "{0}/js".format(root))
    config.add_static_view("img", "{0}/img".format(root))
    
    app = config.make_wsgi_app()
    return app

Теперь запросы к URL вида /css/*, /js/*, /img/* будут возвращать файлы из соответствующих директорий.
Каждому поступающему на сервер запросу Pyramid должен сопоставить view-функцию, которая будет его обрабатывать. Если функцию найти не удается, клиенту вернется ответ со статусом ошибки. В Pyramid есть несколько способов зарегистрировать view-функции. Для регистрации обработчиков статики мы применяем императивный подход — при вызове add_static_view создается и регистрируется объект класса pyramid.static.static_view, который будет обрабатывать запросы к URL, начинающимся с префикса, переданного в первом параметре.

Займемся HTML. Начнем с «hello world». В конфигурацию веб-приложения добавим маршрут.

config = Configurator()
config.add_route("index_route","/")

Маршрут определяется шаблоном URL (второй параметр). Первый параметр — это имя маршрута. Если запрашиваемый URL подпадает под шаблон маршрута, вызывается view-функция обработки запроса, связанная с маршрутом.

Сопоставление по маршрутам — один из способов привязки view-функций к запросам. Также есть traversal — механизм поиска view-функций на основе дерева ресурсов. В рассматриваемом веб-приложении используется только первый подход. О traversal можно почитать подробнее в документации.

Создадим модуль view-функций views.py, добавим в него код обработчика маршрута index_route:

@view_config(route_name="index_route")
def index(request):
    return render_to_response('pt/index.pt', { 'name' : 'world' }, request)

и примитивный шаблон pt/index.html в папке pt:

<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:tal="http://xml.zope.org/namespaces/tal"	
      xmlns:metal="http://xml.zope.org/namespaces/metal">
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
        <title>wishbar</title>
    </head>
    <body>
        Hello, ${name}!	
    </body>
</html>

render_to_response генерирует HTML-страницу для запроса request по указанному в первом параметре шаблону pt/index.pt, подставляя данные из словаря во втором параметре.

Зарегистрируем в веб-приложении этот обработчик. Можно воспользоваться императивным подходом, но проще попросить веб-приложение в create_app «просканировать» views.py и зарегистрировать все описанные в нем обработчики.

config = Configurator()
config.add_route("index_route","/")
config.scan("views")

Нам потребуется три страницы — страница для ввода имени, страница меню и страница подтверждения заказа. Все три страницы должны иметь единый дизайн. Поэтому желательно использовать шаблоны. В частности, иметь один базовый шаблон HTML-страницы, в тело которой уже для каждого конкретного экрана будет вставлен соответствующий контент. В поставке Pyramid идет движок шаблонов Chameleon, остановим свой выбор на нем. Создадим базовый шаблон pt/base.pt и по шаблону для каждой их трех страниц — pt/login.pt, pt/index.pt (изменим «hello world»), pt/confirm.pt. Для использования шаблона base.pt, как базового, создадим рядом с server.py файл subscribers.py со следующим содержимым:

from pyramid.renderers import get_renderer
from pyramid.events import BeforeRender, subscriber

@subscriber(BeforeRender)
def add_base_template(event):
    base = get_renderer('pt/base.pt').implementation()
    event.update({'base': base})

Осталось зарегистрировать все обработчики событий из subscribers.py в веб-приложении в функции create_app.

config = Configurator()
config.add_route("index_route","/")
config.scan("subscribers")

@subscriber определяет функцию add_base_template как обработчик внутренних событий Pyramid типа BeforeRender. Обработчик событий BeforeRender вызывается сразу перед рендерингом шаблона. В обработчике мы можем изменить набор renderer globals — именованных значений, доступных в шаблонах. В частности, мы добавляем в этот набор рендерер базового шаблона base.pt под именем base.

В pt/base.pt объявим нужные слоты расширения:

<div id="content">
    <tal:block metal:define-slot="content">
    </tal:block>
</div>

Теперь в шаблонах login.pt, index.pt и confirm.pt можно «наследоваться» от базового шаблона:

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:tal="http://xml.zope.org/namespaces/tal"
      xmlns:metal="http://xml.zope.org/namespaces/metal"
      metal:use-macro="base">
    <tal:block metal:fill-slot="content">
        Hello, ${name}!
    </tal:block>
</html>

Каркас веб-приложения готов, можно заняться прикладной логикой. Во view-функции главной страницы надо проверить — знаем ли мы имя пользователя, или это новый клиент. Пусть, когда девушка указывает имя, веб-приложение передает ей cookie с введенным именем. Соответственно, в обработчике главной страницы можем проверить, есть ли cookie в запросе. Если есть — мы знаем имя и можем отобразить список желаний. Если нет — отображаем девушке страницу с формой ввода имени.

if 'username' in request.cookies:
    pass  
else:
    return render_to_response('pt/login.pt', {}, request)

Немного поработаем над версткой и стилями. В помощь возьмем awesome-кнопки.



Поскольку awesome-кнопки это не кнопки, а ссылки, добавим немного jQuery для отправки формы при нажатии на кнопку. Данные формы отправим на URL /login/ в форме POST-запроса. Добавим маршрут и обработчик. В обработчике сохраним имя девушки в форме cookie с ключом username и отправим 302 Found на корень веб-приложения.

config.add_route("index_route","/")

@view_config(route_name="login_route")
def login(request):
    username = request.params['username']
    response = Response()
    response.set_cookie('username', value=username, max_age=86400)
    return HTTPFound(location = "/", headers=response.headers)     

Как и в любом веб-приложении, перенаправление необходимо для того, чтобы при обновлении страницы браузер не предлагал заново отправить POST-запрос с данными формы. Так происходит, если вернуть HTML-страницу в ответе на POST-запрос.

Теперь-то у нас есть уже cookie с именем девушки. При обработке запроса по маршруту index_route можно отобразить список «желаний». Закодируем список желаний в форме списка в Python и передадим его в шаблон вместе с именем девушки.

if 'username' in request.cookies:
    username = request.cookies['username']
    response = render_to_response("pt/index.pt", { "username" : username, "wishbar" : WISHBAR }, request)
    return response

В шаблоне сгенерируем таблицу со строками по каждому из желаний в списке.

<div class="table">
<tal:block repeat="wish wishbar">
    <label for="${wish.name}">
        <div><input id="${wish.name}" name="wish-${wish.name}" type="checkbox"></input><div class="checkbox"></div></div>
        <div>
        <div class="title">${wish.title}</div>
            <div class="description">${wish.description}</div>
        </div>
    </label>
</tal:block>
</div>

Смотрим что получилось.



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

Внизу страницы добавим поле для ввода «особых пожеланий» и кнопки отправки формы на сервер на корневой URL в форме POST-запроса. В соответствующей view-функции добавим проверку типа запроса. В случае POST получим данные формы, создадим объект заказа и перенаправим пользователя на /confirm/id-заказа.

if request.method == "POST":
    username = request.cookies['username']
    wishlist = []
    for key,value in request.POST.items():
        if key.startswith("wish-"):
            wishlist.append(NAME2WISH[key[5:]])
        
    special = request.params["special"]
    bequick = request.params["bequick"]
    order = Order(username,request.remote_addr,wishlist,special,bequick)
    ORDERS[order.id] = order
    return HTTPFound(location = "/confirm/{}".format(order.id))

Добавим соответствующий маршрут и обработчик /confirm/*.

config.add_route("confirm_route","/confirm/{order}")

@view_config(route_name="confirm_route")
def confirm(request):
    order_id = request.matchdict['order']
    if order_id in ORDERS.iterkeys():
        order = ORDERS.pop(order_id)
        notify(order)
        return render_to_response('pt/confirm.pt', { "order" : order }, request)
    else:
        return HTTPFound(location = "/")

На странице подтверждения показываем пользователю содержание заказа и предлагаем «пожелать» что-нибудь еще.



Функция notify предназначена для оповещения официантов о заказе. О ней чуть позже. А пока — закончим с веб-приложением. Осталось немного: надо дать возможность пользователю «выйти» и «войти» под другим именем. Для этого на странице выбора желаний есть ссылка на URL /logout/. Зарегистрируем соответствующую view-функцию logout. В ней достаточно очистить cookie с именем пользователя и перенаправить его на главную.

config.add_route("logout_route","/logout/")

@view_config(route_name="logout_route")
def logout(request):
    response = Response()
    response.set_cookie('username', value=None)
    return HTTPFound(location = "/", headers=response.headers)

Теперь можно заняться обработкой заказов.

Обработка заказов


Предполагалось, что будет несколько «менеджеров», которых надо оповещать о поступающих заказах. Менеджеры должны договориться, кто примет заказ, и организовать его исполнение. Для оповещения было решено использовать Google Talk. С XMPP из Python удобно работать через xmpppy. Поместим реализацию функции notify в notify.py.

USERNAME = 'username' # @gmail.com
PASSWORD = 'password'

class User():
    def __init__(self,uri,name):
        self.uri = uri
        self.name= name

USERS = {
    "user@gmail.com" : User("user@gmail.com",u"Имя пользователя")
}

def notify(order):
    cnx = xmpp.Client('gmail.com')
    cnx.connect( server=('talk.google.com',5223) )

    cnx.auth(USERNAME,PASSWORD,'botty')
   
    message = order.username + " (" + order.remote_addr + "): "
    if order.donothing:
        message += u"ничего не делать"
    else:
        message += order.wishstr
    if order.bequick:
        message += u", быстро-быстро"
    for user in USERS.itervalues():
        cnx.send(xmpp.Message(user.uri, message, typ='chat'))

Вот и все. Как только поступает заказ, notify отправляет сообщение с информацией о заказе всем пользователям из списка USERS.

Изначально планировалось оповещать несколько «менеджеров». Более того, «менеджер» мог ответить на адрес «бара желаний» — тогда всем остальным пришло бы подтверждение о взятии им заказа в обработку. Для этого даже был написан соответствующий gtalk-бот, код которого можно найти далее в файле notify.py. Бот запускался при старте приложения в отдельном потоке, обрабатывал входящие сообщения и рассылал их по списку USERS.

Но при прогоне оказалось, что с определенного момента сообщения перестают доходить до менеджеров. В результате серии экспериментов было выяснено, что в Google Talk встроена защита от большого потока событий — при отправке более 10 событий приблизительно за 100 секунд Google Talk блокирует отправку событий из клиента на пару минут. О чем мною было найдено лишь краткое упоминание на StackOverflow без конкретных цифр.

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

Развертывание


После того, как веб-приложение было готово, встал вопрос о способе его развертывания на сервере в локальной сети под управлением Ubuntu. Я вбил в поиск запрос «Pyramid setup.py» и обнаружил документ, с которым мне следовало ознакомиться в самом начале. Документ описывает стандартный способ создания Pyramid-проектов.
Я намерено вынес эту информацию в конец статьи. Во-первых, для того, чтобы начать непосредственно с задачи и кода и не запутать читателя. Во-вторых, привести свой проект к стандартному для Pyramid легко и быстро. Что я и сделал.

Утилита pcreate автоматически генерирует типовую структуру проекта и создает файл setup.py для нового веб-приложения, в котором уже прописаны все зависимости. Необходимо перейти на уровень выше нашего проекта и запустить в консоли pcreate -s starter wishbar.
pcreate предлагает и другие scaffolds (каркасы) для веб-приложений. Например, alchemy — создает веб-приложение с использованием sqlalchemy.

Ключевым отличием Pyramid-проекта является размещение файлов веб-приложения в отдельном пакете wishbar. Что правильно, модули должны лежать в пакетах. В моем же случае файлы лежали в корне проекта. Перенос не составил труда — убран лишний сгенерированный код, добавлены недостающие директивы import для зависимостей между модулями, добавлен вызов create_app из server.py в __init__.py.

После того, как заключительный результат был выложен на GitHub, развернуть проект на сервере в локальной сети не составило труда:

cd ~
mkdir wishbar
cd wishbar
git init
git remote add origin "https://github.com/rgmih/wishbar.git"
git pull origin master
sudo python ./setup.py develop
pserve production.ini

При таком способе развертывания веб-приложение запускается под WSGI-сервером Waitress на порту 7777.

Заключение


Девушки остались очень довольны. В некоторых случаях даже был достигнут вау-эффект.

«Бар исполнения желаний» был успешно запущен 7го марта и проверен в условиях строжайшей секретности. Утром в праздничный день менеджеры и официанты были на своих боевых постах и непринужденно трепались в чате. Первый заказ поступил около 9 утра со словами «я в шоке» в разделе «особых пожеланий». Приложение успешно проработало до конца праздничного дня. Единственное, чего мы не учли в нашей «идеальной» схеме — что в здании ляжет шлюз и все офисы останутся без интернета. Соответственно, оповещения о заказах перестали передаваться в чат, да и сам чат стал недоступен для официантов. Благо, интернет отключили за десять минут до праздничного банкета, и никто не пострадал.

Приложение разработано и проверено под Python 2.7. Но думаю, что под Python 3 все также заработает без значительных измнений. Много важных для настоящего веб-приложения задач не было решено в силу отсутствия необходимости и ограниченности во времени — нет логирования, локализации, обработки ошибок и др. При разработке использовались: Python 2.7.2, Pyramid 1.4, xmpppy 0.5.0rc1, LESS 1.3.0, jQuery 1.9.1. Я не являюсь профессиональным разработчиком на Python. Поэтому буду признателен любой конструктивной критике и советам, которые позволят мне улучшить статью и свои навыки в этой области.

Исходный код проекта доступен на GitHub.
Tags:
Hubs:
+44
Comments 7
Comments Comments 7

Articles