11 сентября 2009 г.

Magento, подписка на новости во время чекаута

Задача - добавить галочку "Получать новости" к одному из шагов чекаута (checkout - "проход через кассу").



Сразу стало ясно, что придётся писать новый модуль, т.к. модификации дизайнерских файлов здесь не хватит. Как можно добавить своё поле к какому-либо шагу чекаута, можно подсмотреть у модулей Desitex Checkoutnewsletter (он добавляет галочку "подписать на новости" во второй шаг, где надо указать billing address) и у Biebersdorf CustomerOrderComment (он добавляет поле для добавления комментария в последний шаг - страницу подтверждения заказа).

Опускаю процесс копания в указанных модулях и поиска решения :) В итоге, что бы всё получилось, нужно:
  • Модифицировать дизайнерский файл, который рисует нужную страницу чекаута - добавить туда галочку;
  • Каким-либо образом подписаться на событие "сохранение страницы чекаута", что бы:
  • Запомнить состояние галочки в текущей сессии
  • Обработать событие "оформление заказа", возникающее когда пользователь уже оформил заказ, перед тем как сайт перенаправит его на сайт для оплаты (например, PayPal, AlliedWallet). Здесь надо извлечь сохранённое значение из сессии и подписать пользователя на рассылку, если он этого хочет.

Создание модуля

Прежде всего создадим модуль. Пусть он будет в пространстве имён Mage, а называться будет NewsletterSubscribe.

Сначала нужно сказать Magento, что наш модуль есть - создаём файл Mage_NewsletterSubscribe.xml в папке app/etc/modules:
<?xml version="1.0"?>
<config>
    <modules>
        <Mage_NewsletterSubscribe>
            <active>true</active>
            <codePool>local</codePool>
        </Mage_NewsletterSubscribe>
    </modules>
</config>
Согласно XML, модуль активен и находится в пуле local.

Далее создаём папку где будет находится новый модуль - app/code/local/Mage/NewsletterSubscribe, и файл конфигурации config.xml в папке app/code/local/Mage/NewsletterSubscribe/etc:
<?xml version="1.0"?>
<config>
    <global>
        <helpers>
            <newslettersubscribe>
                <class>Mage_NewsletterSubscribe_Helper</class>
            </newslettersubscribe>
        </helpers>
    </global>
</config>
Без хелпера модуль не будет работать как надо, а будет вместо этого падать. Поэтому дадим Magento хелпер, пусть и пустой - файл Data.php в папке Helper:
<?php

class Mage_NewsletterSubscribe_Helper_Data extends Mage_Core_Helper_Abstract {

}

Модификация страницы чекаута

Файл, рисующий нужную страницу чекаута - app\design\frontend\default\sunnyD\template\checkout\onepage\payment\methods.phtml. Добавляем галочку:
...
<?php /* bof Subscribe for newsletter checkbox */ ?>
<dt>Join Our Mailing List</dt>
<dd>
    <input type="checkbox" name="NewsletterSubscribe" id="NewsletterSubscribe" checked="checked" />
    <label for="NewsletterSubscribe"><?php echo Mage::helper('newslettersubscribe')->__('I would like to receive the Century Supplements newsletter') ?></label>
</dd>
<?php /* eof Subscribe for newsletter checkbox */ ?>
...
Да, теперь мы видим нашу галочку на странице выбора метода оплаты. Но почему же она неактивна? А потому что она сделана неактивной JS кодом, расположенным в конце файла:
<script type="text/javascript">payment.init();</script>
Не разбирался зачем он нужен, но в данном случае он делает неактивными все тэги <input>. Выходит, нам надо активировать нашу галочку после выполнения этого кода:
<script type="text/javascript">payment.init();</script>

<script type="text/javascript">$('NewsletterSubscribe').disabled = false;</script>
Теперь галочка стала активна, идём дальше.

Событие "сохранение страницы чекаута"

Я подсмотрел как это делает Checkoutnewsletter. В конфигурационном файле модуля есть строки, которые видимо перехватывают действия, связанные со всем чекаутом, всеми его страницами:
<?xml version="1.0"?>
<config>
    ...
    <global>
        <models>
         <checkout>
          <rewrite>
           <type_onepage>Desitex_Checkoutnewsletter_Model_Checkout_Type_Onepage</type_onepage>
          </rewrite>
         </checkout>
    ...
</config>
Стандартный класс Mage_Checkout_Model_Type_Onepage заменяется классом модуля Desitex_Checkoutnewsletter_Model_Checkout_Type_Onepage (который наследуется от оригинального класса Mage_Checkout_Model_Type_Onepage). В этом классе переопределён всего один метод:
<?php

class Desitex_Checkoutnewsletter_Model_Checkout_Type_Onepage extends Mage_Checkout_Model_Type_Onepage
{
    public function saveBilling($data, $customerAddressId)
    {
        if (isset($data['is_subscribed']) && !empty($data['is_subscribed'])){
            $this->getCheckout()->setCustomerIsSubscribed(1);
        }
        else {
            $this->getCheckout()->setCustomerIsSubscribed(0);
        }
        return parent::saveBilling($data, $customerAddressId);
    }
}
Очевидно, действие saveBilling возникает когда пользователь переходит со страницы ввода billing address (нажимает кнопку Continue). Здесь модуль сохраняет значение своей галочки "подписываться ли на новости" в текущей сессии (или чекауте...). После этого вызывает оригинальный метод стандартного класса.

Мы поступим подобным образом - сделаем класс, отнаследуем его от стандартного, и переопределяем только один метод, возникающий при сохранении формы на нашей странице. Метод будет сохранять значение нашей галочки. Класс поместим в файл Model/Onepage.php. :
<?php

class Mage_NewsletterSubscribe_Model_Onepage extends Mage_Checkout_Model_Type_Onepage {
    public function savePayment($data) {
        if (isset($_POST['NewsletterSubscribe'])){
            $this->getCheckout()->setNewsletterSubscribe((bool) $_POST['NewsletterSubscribe']);
        }
        else {
            $this->getCheckout()->setNewsletterSubscribe(false);
        }
        return parent::savePayment($data);
    }
}

Здесь меня немного настиг ступор. Значение галочки находится среди значений формы, но из текущего места у меня нет доступа к этим переменным. Т.е. доступа к объекту Magento Request, хранящему все GET и POST переменные. Доступны разные интересные объекты типа Quote, Checkout и т.д., с разным интересными данными, но не значениями формы. Я почти отчаялся, соображая что переписывать код ядра очень плохо, но потом вспомнил, что это Php, а значит в любом месте доступны переменные $_GET и $_POST :) Проблема была решена.

Теперь скажем Magento, что бы вместо стандартного класса брал наш. Редактируем etc/config.xml:
<?xml version="1.0"?>
<config>
    <global>
        <models>
            <checkout>
                <rewrite>
                    <type_onepage>Mage_NewsletterSubscribe_Model_Onepage</type_onepage>
                </rewrite>
            </checkout>
        </models>
    ...
</config>

Здесь готово. Только видимо есть одно ограничение - переопределить стандартный класс может только один модуль. Мой метод не вызывался, пока я не убрал переопределение у модуля Checkoutnewsletter. Т.е. это не обычное событие, на которое может подписаться произвольное количество слушателей. Потенциальные трудноотлаживаемые проблемы в будущем :(

Событие "оформление заказа"

В отличие от предыдущего "события", оформление заказа это "настоящее" событие checkout_type_onepage_save_order. Что бы подписаться на него нужно изменить конфиг модуля etc/config.xml:
<?xml version="1.0"?>
<config>
    <global>
        ...
        <events>
            <checkout_type_onepage_save_order>
                <observers>
                    <mage_newslettersubscribe_observer>
                        <type>singleton</type>
                        <class>newslettersubscribe/observer</class>
                        <method>onOrderSave</method>
                    </mage_newslettersubscribe_observer>
                </observers>
            </checkout_type_onepage_save_order>
        </events>
    </global>
</config>
Здесь мы указали какой метод у какого класса вызвать (Mage_NewsletterSubscribe_Model_Observer::onOrderSave), когда пользователь оформит заказ. Теперь создадим этот класс и метод - файл /Model/Observer.php:
<?php
class Mage_NewsletterSubscribe_Model_Observer extends Mage_Core_Helper_Abstract {
    public function onOrderSave($observer) {
        $isCustomerSubscribed = (bool) Mage::getSingleton('checkout/session')->getNewsletterSubscribe();
        if ($isCustomerSubscribed) {
            $quote = $observer->getEvent()->getQuote();
            $session = Mage::getSingleton('core/session');
            try {
                $status = Mage::getModel('newsletter/subscriber')->subscribe($quote->getBillingAddress()->getEmail());
                if ($status == Mage_Newsletter_Model_Subscriber::STATUS_NOT_ACTIVE){
                    $session->addSuccess(Mage::helper('checkoutnewsletter')->__('Confirmation request has been sent regarding your newsletter subscription'));
                }
            }
            catch (Mage_Core_Exception $e) {
                $session->addException($e, Mage::helper('checkoutnewsletter')->__('There was a problem with the newsletter subscription: %s', $e->getMessage()));
            }
            catch (Exception $e) {
                $session->addException($e, Mage::helper('checkoutnewsletter')->__('There was a problem with the newsletter subscription'));
            }
        }

        return $this;
    }
}
Этот код я взял из модуля Checkoutnewsletter, только переделал его что бы он работал :) К счастью в Magento есть класс, позволяющий подписывать пользователей на новости. По-сути всё что нужно сделать - вызвать Mage::getModel('newsletter/subscriber')->subscribe(<user email>);

Итог

В итоге получился небольшой модуль, выполняющий поставленную задачу :)

10 сентября 2009 г.

Джаббер чат на веб-странице

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

Как это вообще должно работать: пользователь заходит на сайт, и видит окошко, куда можно разговаривать. После того как пользователь послал сообщение, оно прилетает на указанный джаббер аккаунт. Получатель этого сообщения может написать ответ и оно придёт посетителю сайта.
Что для этого нужно:
  • Jabber сервер, можно публичный, можно локальный. Я выбрал Openfire и установил его локально. Сервер должен поддерживать Bosh - XEP-0124: Bidirectional-streams Over Synchronous HTTP, об этом чуть позже.
  • JS библиотека, которая будет реализовывать джаббер-клиент на сайте. Я взял Strophe. Это достаточно низкоуровневая библиотека, в которой нет функций типа "ПослатьСообщение(Куда, Текст)". Для достижения нужных действий нужно вручную составлять команды джаббер серверу (в XML). Удобные средства для создания XML в Strophe есть :)

BOSH

JS не умеет создавать TCP соединения с другим сервером/клиентом, что необходимо для реализации джаббер-клиента. JS может посылать только HTTP запросы. Поэтому нужен специальный механизм, который позволит работать с TCP соединениями посредством HTTP. Это и есть BOSH.

По-умолчанию BOSH в Openfire включён и имеет адрес http://localhost:7070/http-bind. Но если указать этот адрес при соединении, ничего не выходит. Проблема хорошо описана здесь, что бы заработало нужно написать редирект для апача и раскомментировать модули proxy_module и proxy_http_module:

httpd.conf:
ProxyRequests Off
ProxyPass /http-bind http://127.0.0.1:7070/http-bind/
ProxyPassReverse /http-bind http://127.0.0.1:7070/http-bind/

Чат на сайте

Итак, всё очень просто, устанавливаем Openfire, создаём пользователя, например, site, и создаём тестовую страницу (в качестве основы я брал строфовский пример echobot). На странице есть поле, куда пользователь пишет своё сообщение, и поле с историей чата.

Пишем обработчик загрузки страницы, который будет логинится на джаббер сервер:
$(function () {
    connection = new Strophe.Connection('/http-bind');
    connection.connect('site@r1c', 'site', onConnect);
});
В обработчике onConnect мы добавляем "слушателя" на события, связанные с приходом сообщений (в случае успешного логина):
function onConnect(status){
    if (status == Strophe.Status.CONNECTING) {
        log('Strophe is connecting.');
    }
    else if (status == Strophe.Status.CONNFAIL) {
        log('Strophe failed to connect.');
    }
    else if (status == Strophe.Status.DISCONNECTING) {
        log('Strophe is disconnecting.');
    }
    else if (status == Strophe.Status.DISCONNECTED) {
        log('Strophe is disconnected.');
    }
    else if (status == Strophe.Status.CONNECTED) {
        log('Strophe is connected, ' + connection.jid);
        
        connection.addHandler(onMessage, null, 'message', null, null, null);
        connection.send($pres().tree());
    }
}
Обработчику onMessage приходят XML данные. Разбираем их что бы узнать текст сообщения, кто отправитель и т.д.:
function onMessage(msg) {
    var to = msg.getAttribute('to');
    var from = msg.getAttribute('from');
    var type = msg.getAttribute('type');
    var elems = msg.getElementsByTagName('body');

    if (type == "chat" && elems.length > 0) {
        var body = elems[0];
    
        AddText(Strophe.getText(body), 'in');
    }

    // we must return true to keep the handler alive.  
    // returning false would remove it after it finishes.
    return true;
}
Обработчик кнопки "Отправить" формирует XML с необходимыми данными и просит Strophe отправить их. В качестве принимающего используется пользователь admin:
function Send(message) {
    var msg = $msg({to: 'admin@r1c', from: connection.jid, type: 'chat'}).c('body').t(document.URL + '\n' + message);
    connection.send(msg.tree());
    
    AddText(message, 'out');
    $('input#message').val('');
}

Итог



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

Можно придумать ещё очень много усовершенствований. Например:
  • Нормальный интерфейс чата на сайте :)
  • Автоматическая регистрация новых пользователей на джаббер-сервере, если в этом есть необходимость. Для этого можно использовать In-band registration - регистрация через XML команды джаббера, а не через веб-интерфейс.
  • Сохранение состояния чата при переходе между разными страницами сайта
  • Сохранение истории чата на сервере
  • и т.д.

9 сентября 2009 г.

Простой бэкап, Python + DropBox

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

Для начала нужно определиться где хранить копии файлов. По-моему DropBox это очень хороший выбор - это сервис для синхронизации данных на разных компьютерах. Бесплатно дают 2 Гб, плюс программу, которая отображает выбранную локальную папку на ихнее онлайн хранилище. Добавил файл в эту специальную папку, и он добавился на всех компьютерх, на которых стоит специальная программа, настроенная на тот же аккаунт. Плюс доступ через веб-интерфейс. Для бэкапа это очень удобно - не надо заморчиваться как скопировать файлы на FTP, а просто копируем их в специальную папку. Всё работу по заливке/синхронизации сделает DropBox клиент.

Дальше нужно собственно скопировать необходимые файлы. Для этого был написан крохотульный скрипт на питоне, backup.py. Этот скрипт создаёт текстовый список файлов, которые нужно скопировать, и передаёт его WinRar'у, который создаёт архив с указанным именем.

Сначала я хотел полностью поручить WinRar'у создание бэкапа, написав немного кода в батниках, но к сожалению он не умеет пропускать заданные папки (только файлы). Поэтому пришлось писать промежуточный скрипт, который создаёт список файлов.

У скрипта есть единственный параметр - имя выходного файла-архива со скопированными файлами.

Алгоритм работы скрипта:
  1. Читает из текущей папки файл tobackup.lst - там хранится список папок, которые нужно скопировать. Каждая строка - отдельная папка. Например:
    d:\projects
    d:\www
  2. Читает список папок, которые нужно исключить из бэкапа. Это опциональный файл igonre.lst в текущей папке. Это либо полный путь (d:\projects\old), либо просто имя папки (.svn). Например:
    .svn
    d:\projects\old
  3. Создаёт текстовый список файлов для копирования list.lst (в текущей папке). После этого добавляет в конец файла-списка опциональный файл extra.lst (из текущей папки). Там содержится список файлов, а не папок, для включения. Например, мы хотим сохранить настройки php, но из всей папки d:\programs\php нам нужен только один файл php.ini. Поэтому вместо добавления папки с php мы добавляем только один файл php.ini в extra.lst.
  4. Вызывает архиватор, который создаёт архив с файлами из списка. Параметры архиватора - сохранять полный путь файлов (вместе с диском) и создать архив без сжатия.

В итоге после работы скрипта мы будем иметь архив, созданный как раз в папке DropBox, клиент которого сам загрузит его на сервер.

Я не зря указывал, что скрипт берёт файлы из текущей папки. Благодаря этому делать разные "профили" очень просто - достаточно создать новую папку, создать там файл tobackup.lst, опциональные ignore.lst и extra.lst, и новый профиль готов! Для удобства можно сделать батник, который будет вызывать backup.py и передавать ему имя файла-архива который должен получиться.

Сейчас, например, у меня есть две папки-профиля, projects (для бэкапа текущих проектов) и other (для бэкапа настроек программ). Сам скрипт лежит в папке core (на том же уровне что и папки профилей), вместе с WinRar'ом.

Папка core:
Rar.exe
WinRAR.exe
rarreg.key
backup.py

Папка projects:
tobackup.lst:
d:\projects
d:\svn

ignore.lst:
.svn
d:\projects\old

backup.bat:
..\core\backup.py "d:\dropbox\my dropbox\backup\dev.rar"

Батник backup.bat вызывает скрипт, передеаёт ему имя получающегося файла-архива, который будет создан в папке, связанной с DropBox. Скрипт берёт файлы-списки из текущей папки (projects в данном случае). Один запуск батника - новый бэкап готов, и DropBox грузит его на сервер :)

Осталось прикрутить запуск по расписанию, но меня устроит и ручной запуск несколько раз в месяц :)

backup.py:
import subprocess
import sys
import os.path
import os

__author__="race1"
__date__ ="$09.09.2009 19:55:06$"

if __name__ == "__main__":
    if (len(sys.argv) < 2):
        print("Usage: backup.py ")
        print("E.g.: \"backup.py d:\\dropbox\\my dropbox\\backup.rar\"")
        exit(1)

    ScriptFolder = os.path.dirname(sys.argv[0])
    ArchiverFile = ScriptFolder + "\\winrar.exe"
    RootFoldersFile = "tobackup.lst"
    IgnoreFoldersFile = "ignore.lst"
    ExtraFile = "extra.lst"
    FilelistFile = "list.lst"
    OutputArchive = sys.argv[1]

    # Only mandatory file is RootFoldersFile, check that it exists
    if (not os.path.isfile(RootFoldersFile)):
        print("%s doesn't exist" % RootFoldersFile)
        exit(1)

    # Archiver file also should exist
    if (not os.path.isfile(ArchiverFile)):
        print("%s doesn't exist" % ArchiverFile)
        exit(1)

    # Read root folders from file
    rootFolders = [i.strip() for i in open(RootFoldersFile, "r").readlines()]

    # Read list of folders that need to be igonred (if specified)
    ignoreList = []
    if (os.path.isfile(IgnoreFoldersFile)):
        ignoreList = [i.strip().lower() for i in open(IgnoreFoldersFile, "r").readlines()]

    # Open filelist file for writing list of files
    out = open(FilelistFile, "w")
    filesCount = 0

    for rootFolder in rootFolders:
        for root, dirs, files in os.walk(rootFolder):
            for file in files:
                out.write(root + "\\" + file + "\n")
                filesCount += 1

            for dir in dirs:
                # Skip ignored folders
                if (dir.lower() in ignoreList or ("%s\%s" % (root, dir)).lower() in ignoreList):
                    dirs.remove(dir)

    # Append some files from extra files list
    if (os.path.isfile(ExtraFile)):
        out.writelines(open(ExtraFile, "r").readlines())
        
    out.close()

    print("Added %d file(s)" % (filesCount))

    # Delete old archive if exists
    if (os.path.isfile(OutputArchive)):
        os.unlink(OutputArchive)

    # Call archiver (winrar)
    subprocess.call(ArchiverFile + " a -m0 -ep3 \"" + OutputArchive + "\" @list.lst")

    os.unlink(FilelistFile)

    print("Done")